diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallback.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallback.kt index 036073bc..244380d8 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallback.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallback.kt @@ -26,13 +26,20 @@ class ReCaptchaEnterpriseCallback : AbstractCallback { lateinit var reCaptchaSiteKey: String private set + /** + * Retrieves the token result. + * + * @return the token result. + */ + lateinit var tokenResult: String + private set + companion object { private val TAG = ReCaptchaEnterpriseCallback::class.java.simpleName private const val INVALID_CAPTCHA_TOKEN = "INVALID_CAPTCHA_TOKEN" private const val UNKNOWN_ERROR = "UNKNOWN_ERROR" } - @Keep @JvmOverloads constructor() @@ -59,15 +66,6 @@ class ReCaptchaEnterpriseCallback : AbstractCallback { super.setValue(value, 0) } - /** - * Set the Action for the ReCAPTCHA - * - * @param value The Action - */ - fun setAction(value: String) { - super.setValue(value, 1) - } - /** * Input the Client Error to the server * @param value Error String. @@ -85,6 +83,15 @@ class ReCaptchaEnterpriseCallback : AbstractCallback { super.setValue(value.toString(), 3) } + /** + * Set the Action for the ReCAPTCHA + * + * @param value The Action + */ + private fun setAction(value: String) { + super.setValue(value, 1) + } + override fun getType(): String { return "ReCaptchaEnterpriseCallback" } @@ -111,7 +118,9 @@ class ReCaptchaEnterpriseCallback : AbstractCallback { if (token == null) { throw Exception(INVALID_CAPTCHA_TOKEN) } + tokenResult = token setValue(token) + setAction(action) } catch (e: Exception) { Logger.error(TAG, e.message) diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackTest.kt index 3873a615..75e34b06 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackTest.kt @@ -30,7 +30,7 @@ import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class ReCaptchaEnterpriseCallbackTest { - val application: Application = ApplicationProvider.getApplicationContext() + private val application: Application = ApplicationProvider.getApplicationContext() private lateinit var callback: ReCaptchaEnterpriseCallback @@ -98,7 +98,7 @@ class ReCaptchaEnterpriseCallbackTest { verify(recaptchaClientProvider).execute(recaptchaClient, RecaptchaAction.custom("login"), 15000L) assert(callback.content.contains("test-token")) - + assert(callback.tokenResult == "test-token") } @Test @@ -153,16 +153,15 @@ class ReCaptchaEnterpriseCallbackTest { ) whenever(recaptchaClientProvider.execute(any(), any(), anyLong())).thenReturn("test-token") - callback.execute(application, "login", 15000L, recaptchaClientProvider) + callback.execute(application, "custom-action", 15000L, recaptchaClientProvider) callback.setPayload(JSONObject().put("key", "value")) - callback.setAction("custom-action") verify(recaptchaClientProvider).fetchClient( application, "6Lf3tbYUAAAAAEm78fAOFRKb-n1M67FDtmpczIBK" ) - verify(recaptchaClientProvider).execute(recaptchaClient, RecaptchaAction.custom("login"), 15000L) + verify(recaptchaClientProvider).execute(recaptchaClient, RecaptchaAction.custom("custom-action"), 15000L) assert(callback.content.contains("test-token")) assert(callback.content.contains("key")) diff --git a/forgerock-integration-tests/build.gradle.kts b/forgerock-integration-tests/build.gradle.kts index a6e47f4d..8479fb21 100644 --- a/forgerock-integration-tests/build.gradle.kts +++ b/forgerock-integration-tests/build.gradle.kts @@ -43,6 +43,7 @@ dependencies { androidTestImplementation(libs.androidx.biometric.ktx) androidTestImplementation(libs.nimbus.jose.jwt) androidTestImplementation(libs.okhttp) + androidTestImplementation(libs.kotlinx.coroutines.test) //For Application Pin androidTestImplementation(libs.bcpkix.jdk15on) @@ -50,4 +51,8 @@ dependencies { //App Integrity androidTestImplementation(libs.integrity) + + // Captcha + androidTestImplementation(libs.play.services.safetynet) + androidTestImplementation(libs.recaptchaEnterprise) } \ No newline at end of file diff --git a/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackBaseTest.kt b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackBaseTest.kt new file mode 100644 index 00000000..fd9218a5 --- /dev/null +++ b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackBaseTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.callback + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.forgerock.android.auth.FRAuth +import org.forgerock.android.auth.FROptionsBuilder +import org.forgerock.android.auth.FRSession +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Logger.Companion.set +import org.junit.After +import org.junit.BeforeClass + +/** + * e2e tests for [ReCaptchaEnterpriseCallback] + */ +open class ReCaptchaEnterpriseCallbackBaseTest { + @After + fun logoutSession() { + if (FRSession.getCurrentSession() != null) { + FRSession.getCurrentSession().logout() + } + } + + companion object { + val context: Context = ApplicationProvider.getApplicationContext() + val application: Application = ApplicationProvider.getApplicationContext() + + protected const val AM_URL: String = "https://openam-recaptcha.forgeblocks.com/am" + protected const val REALM: String = "alpha" + protected const val OAUTH_CLIENT: String = "AndroidTest" + protected const val OAUTH_REDIRECT_URI: String = "org.forgerock.demo:/oauth2redirect" + protected const val SCOPE: String = "openid profile email address phone" + const val TREE: String = "TEST-e2e-recaptcha-enterprise" + const val USERNAME: String = "sdkuser" + const val RECAPTCHA_SITE_KEY: String = "6LfAykUqAAAAAE6aZOg9pNiS3XduyGZ5y-8U-z8B" + + @JvmStatic + @BeforeClass + fun setUpSDK(): Unit { + set(Logger.Level.DEBUG) + + val options = FROptionsBuilder.build { + server { + url = AM_URL + cookieName = "b431aeda2ba0e98" + realm = REALM + } + oauth { + oauthClientId = OAUTH_CLIENT + oauthRedirectUri = OAUTH_REDIRECT_URI + oauthCacheSeconds = 0 + oauthScope = SCOPE + } + service { + authServiceName = TREE + } + } + + FRAuth.start(context, options) + } + } +} + + + + diff --git a/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackFailureTest.kt b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackFailureTest.kt new file mode 100644 index 00000000..0fbe09ef --- /dev/null +++ b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackFailureTest.kt @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.callback + +import com.google.android.recaptcha.RecaptchaException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions +import org.forgerock.android.auth.FRSession +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Node +import org.forgerock.android.auth.NodeListener +import org.junit.Assert +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runners.MethodSorters + +/** + * Failure e2e tests for the [ReCaptchaEnterpriseCallback] + */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) // These tests must run in order +class ReCaptchaEnterpriseCallbackFailureTest : ReCaptchaEnterpriseCallbackBaseTest() { + + @Test + fun test01RecaptchaEnterpriseScoreFailure() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "score_failure" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + + // Execute recaptcha - this should pass... + runBlocking { + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message).contains("VALIDATION_ERROR") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun test02RecaptchaEnterpriseInvalidProjectId() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "invalid_project_id" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + runBlocking { + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message).contains("API_ERROR") + Assertions.assertThat(message) + .contains("reCAPTCHA Enterprise API has not been used in project invalid before or it is disabled") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun test03RecaptchaEnterpriseInvalidVerificationUrl() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "invalid_verification_url" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + runBlocking { + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message).contains("API_ERROR") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun test04RecaptchaEnterpriseInvalidSecretKey() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "invalid_secret_key" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + runBlocking { + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message) + .contains("INVALID_SECRET_KEY:Secret key could not be retrieved") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun test05RecaptchaEnterpriseCustomClientError() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "custom_client_error" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + runBlocking { + callback.setClientError("CUSTOM_CLIENT_ERROR") + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message).contains("CUSTOM_CLIENT_ERROR") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun test06RecaptchaEnterpriseInvalidSiteKey() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + val recaptchaExceptionHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "invalid_site_key" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + + try { + runBlocking { + callback.execute(application = application) + } + } catch (e: Exception) { + recaptchaExceptionHit[0]++ + if (e is RecaptchaException) { + Logger.error("RecaptchaException", "${e.errorCode}:${e.message}") + } + Logger.error("RecaptchaException", e.message) + } + node.next(context, nodeListener) + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.FAILURE + // in the message of the TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + Assertions.assertThat(message).contains("CLIENT_ERROR") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertEquals(1, recaptchaExceptionHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } +} \ No newline at end of file diff --git a/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackSuccessTest.kt b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackSuccessTest.kt new file mode 100644 index 00000000..1c7fc967 --- /dev/null +++ b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/ReCaptchaEnterpriseCallbackSuccessTest.kt @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.callback + +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions +import org.forgerock.android.auth.FRSession +import org.forgerock.android.auth.Logger +import org.forgerock.android.auth.Node +import org.forgerock.android.auth.NodeListener +import org.json.JSONObject +import org.junit.Assert +import org.junit.Test + +/** + * Success e2e tests for the [ReCaptchaEnterpriseCallback] + */ +class ReCaptchaEnterpriseCallbackSuccessTest : ReCaptchaEnterpriseCallbackBaseTest() { + + @Test + fun testRecaptchaEnterpriseSuccess() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "success" + ) { + val nodeListener: NodeListener = this + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + + // Make sure that the ReCaptcha site key is set correctly + Assert.assertNotNull(callback.reCaptchaSiteKey) + Assertions.assertThat(callback.reCaptchaSiteKey).isEqualTo(RECAPTCHA_SITE_KEY) + + // Execute recaptcha - this should pass... + runBlocking { + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + // Make sure that the journey finishes with success + nodeTextOutputHit[0]++ + + // Note: The journey sends back to us the content of CaptchaEnterpriseNode.ASSESSMENT_RESULT + // in the message of a TextOutputCallback... so we can parse it here and verify results... + val message = node.getCallback(TextOutputCallback::class.java).message + val jsonObject = JSONObject(message) + + Assert.assertNotNull(jsonObject) + Logger.debug("RecaptchaEnterpriseCallbackTest", jsonObject.toString(2)) + + val name = jsonObject.getString("name") + val token = jsonObject.getJSONObject("event").getString("token") + val siteKey = jsonObject.getJSONObject("event").getString("siteKey") + val userAgent = jsonObject.getJSONObject("event").getString("userAgent") + val userIpAddress = jsonObject.getJSONObject("event").getString("userIpAddress") + val score = jsonObject.getJSONObject("riskAnalysis").getDouble("score") + val valid = jsonObject.getJSONObject("tokenProperties").getBoolean("valid") + val action = jsonObject.getJSONObject("tokenProperties").getString("action") + val androidPackageName = + jsonObject.getJSONObject("tokenProperties").getString("androidPackageName") + + // Ensure that the name is "recaptcha" + Assertions.assertThat(name).contains("projects/") + // Ensure that the token is not empty + Assertions.assertThat(token).isNotEmpty() + // Ensure that the valid is true + Assertions.assertThat(valid).isTrue() + // Ensure that the score is between 0 and 1 + Assertions.assertThat(score).isBetween(0.0, 1.0) + // Ensure that the action is "login" (this is the default action) + Assertions.assertThat(action).isEqualTo("login") + // Ensure that the siteKey is the expected one + Assertions.assertThat(siteKey).isEqualTo(RECAPTCHA_SITE_KEY) + // Ensure that the userAgent is not empty + Assertions.assertThat(userAgent).contains("okhttp") + // Ensure that the userIPAddress is not empty + Assertions.assertThat(userIpAddress).isNotEmpty() + // Ensure that the androidPackageName is the expected one + Assertions.assertThat(androidPackageName) + .isEqualTo("org.forgerock.android.integration.test") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun testRecaptchaEnterpriseCustomAction() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "custom_action" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + + // Execute recaptcha - this should pass... + runBlocking { + callback.execute(application = application, action = "custom_action") + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + // Make sure that the journey finishes with success + nodeTextOutputHit[0]++ + + val message = node.getCallback(TextOutputCallback::class.java).message + val jsonObject = JSONObject(message) + Logger.debug("RecaptchaEnterpriseCallbackTest", jsonObject.toString(2)) + + val action = jsonObject.getJSONObject("tokenProperties").getString("action") + + // Ensure that the action is "custom_action" + Assertions.assertThat(action).isEqualTo("custom_action") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } + + @Test + fun testRecaptchaEnterpriseCustomPayload() = runTest { + val nodeCaptchaHit = intArrayOf(0) + val nodeTextOutputHit = intArrayOf(0) + + val nodeListenerFuture = object : RecaptchaEnterpriseNodeListener( + context, "custom_payload" + ) { + val nodeListener: NodeListener = this + + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ReCaptchaEnterpriseCallback::class.java) != null) { + val callback = node.getCallback(ReCaptchaEnterpriseCallback::class.java) + nodeCaptchaHit[0]++ + + val customPayloadString = """ + { + "firewallPolicyEvaluation": false, + "express": false, + "transaction_data": { + "transaction_id": "custom-payload-1234567890", + "payment_method": "credit-card", + "card_bin": "1111", + "card_last_four": "1234", + "currency_code": "CAD", + "value": 12.34, + "user": { + "email": "sdkuser@example.com" + }, + "billing_address": { + "recipient": "Sdk User", + "address": [ + "3333 Random Road" + ], + "locality": "Courtenay", + "administrative_area": "BC", + "region_code": "CA", + "postal_code": "V2V 2V2" + } + } + } + """.trimIndent() + val customPayload = JSONObject(customPayloadString) + // Execute recaptcha - this should pass... + runBlocking { + callback.setPayload(customPayload) + callback.execute(application = application) + node.next(context, nodeListener) + } + } + if (node.getCallback(TextOutputCallback::class.java) != null) { + // Make sure that the journey finishes with success + nodeTextOutputHit[0]++ + + val message = node.getCallback(TextOutputCallback::class.java).message + val jsonObject = JSONObject(message) + Logger.debug("RecaptchaEnterpriseCallbackTest", jsonObject.toString(2)) + + val transactionId = + jsonObject.getJSONObject("event").getJSONObject("transactionData") + .getString("transactionId") + val paymentMethod = + jsonObject.getJSONObject("event").getJSONObject("transactionData") + .getString("paymentMethod") + val cardBin = jsonObject.getJSONObject("event").getJSONObject("transactionData") + .getString("cardBin") + val cardLastFour = + jsonObject.getJSONObject("event").getJSONObject("transactionData") + .getString("cardLastFour") + + // Ensure that the custom payload has been sent to the recaptcha assessment API + Assertions.assertThat(transactionId).isEqualTo("custom-payload-1234567890") + Assertions.assertThat(paymentMethod).isEqualTo("credit-card") + Assertions.assertThat(cardBin).isEqualTo("1111") + Assertions.assertThat(cardLastFour).isEqualTo("1234") + + // This one is set in AM via the CaptchaEnterpriseNode.PAYLOAD shared state variable + val accountId = jsonObject.getJSONObject("event").getJSONObject("userInfo") + .getString("accountId") + Assertions.assertThat(accountId).isEqualTo("user_account_id_123") + + node.next(context, nodeListener) + } + super.onCallbackReceived(node) + } + } + + FRSession.authenticate(context, TREE, nodeListenerFuture) + Assert.assertNotNull(nodeListenerFuture.get()) + + // Ensure that the journey finishes with success + Assert.assertEquals(1, nodeCaptchaHit[0].toLong()) + Assert.assertEquals(1, nodeTextOutputHit[0].toLong()) + Assert.assertNotNull(FRSession.getCurrentSession()) + Assert.assertNotNull(FRSession.getCurrentSession().sessionToken) + } +} \ No newline at end of file diff --git a/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/RecaptchaEnterpriseNodeListener.kt b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/RecaptchaEnterpriseNodeListener.kt new file mode 100644 index 00000000..e8c02de0 --- /dev/null +++ b/forgerock-integration-tests/src/androidTest/java/org/forgerock/android/auth/callback/RecaptchaEnterpriseNodeListener.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.callback + +import android.content.Context +import org.forgerock.android.auth.FRSession +import org.forgerock.android.auth.Node +import org.forgerock.android.auth.NodeListenerFuture + +open class RecaptchaEnterpriseNodeListener( + private val context: Context, + private val nodeConfiguration: String +) : NodeListenerFuture() { + override fun onCallbackReceived(node: Node) { + if (node.getCallback(ChoiceCallback::class.java) != null) { + val choiceCallback = node.getCallback( + ChoiceCallback::class.java + ) + val choices = choiceCallback.choices + val choiceIndex = choices.indexOf(nodeConfiguration) + choiceCallback.setSelectedIndex(choiceIndex) + node.next(context, this) + } + if (node.getCallback(NameCallback::class.java) != null) { + node.getCallback(NameCallback::class.java) + .setName(ReCaptchaEnterpriseCallbackBaseTest.USERNAME) + node.next(context, this) + } + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec37c540..c534b462 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ play-services-location = "21.3.0" play-services-safetynet = "18.1.0" powermock-module-junit4 = "2.0.9" powermock-api-mockito2 = "2.0.9" -recaptchaEnterprise = "18.6.0" +recaptchaEnterprise = "18.6.1" rules = "1.6.1" security-crypto = "1.1.0-alpha06" firebase-crashlytics-buildtools = "3.0.2"