From 7240b5a65e1de348f222155d91333a379a162cc4 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Sat, 17 May 2025 16:16:59 -0700 Subject: [PATCH] feat: add android support --- .../rtnpasskeys/AmplifyRtnPasskeysModule.kt | 163 +++++++++++++ .../rtnpasskeys/AmplifyRtnPasskeysPackage.kt | 32 +++ .../kotlin/AmplifyRtnPasskeysModuleTest.kt | 220 ++++++++++++++++++ 3 files changed, 415 insertions(+) create mode 100644 packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysModule.kt create mode 100644 packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysPackage.kt create mode 100644 packages/rtn-passkeys/android/src/test/kotlin/AmplifyRtnPasskeysModuleTest.kt diff --git a/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysModule.kt b/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysModule.kt new file mode 100644 index 00000000000..213367371d7 --- /dev/null +++ b/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysModule.kt @@ -0,0 +1,163 @@ +package com.amazonaws.amplify.rtnpasskeys + +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.CreateCredentialUnsupportedException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialUnsupportedException +import androidx.credentials.exceptions.domerrors.DataError +import androidx.credentials.exceptions.domerrors.InvalidStateError +import androidx.credentials.exceptions.domerrors.NotAllowedError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException + +import com.facebook.fbreact.specs.NativeAmplifyRtnPasskeysSpec +import com.facebook.react.bridge.JSONArguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import kotlinx.coroutines.CoroutineDispatcher + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +import org.json.JSONObject + +@ReactModule(name = AmplifyRtnPasskeysModule.NAME) +class AmplifyRtnPasskeysModule( + reactContext: ReactApplicationContext, + dispatcher: CoroutineDispatcher = Dispatchers.Default +) : + NativeAmplifyRtnPasskeysSpec(reactContext) { + + private val moduleScope = CoroutineScope(dispatcher) + + override fun getName(): String { + return NAME + } + + companion object { + const val NAME = "AmplifyRtnPasskeys" + } + + @ChecksSdkIntAtLeast(api = android.os.Build.VERSION_CODES.P) + override fun getIsPasskeySupported(): Boolean { + // Requires Android SDK >= 28 (PIE) + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P + } + + override fun createPasskey(input: ReadableMap, promise: Promise) { + if (!isPasskeySupported) { + return promise.reject( + "NOT_SUPPORTED", + CreateCredentialUnsupportedException("CreatePasskeyNotSupported") + ) + } + + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) + + val requestJson = JSONObject(input.toHashMap()).toString() + val request = + CreatePublicKeyCredentialRequest(requestJson = requestJson) + + moduleScope.launch { + try { + val result: CreateCredentialResponse = + credentialManager.createCredential( + context = currentActivity ?: reactApplicationContext, + request = request + ) + + val publicKeyResult = + result as? CreatePublicKeyCredentialResponse + ?: throw Exception("CreatePasskeyFailed") + + val jsonObject = JSONObject(publicKeyResult.registrationResponseJson) + + promise.resolve(JSONArguments.fromJSONObject(jsonObject)) + } catch (e: Exception) { + val errorCode = handlePasskeyFailure(e) + promise.reject(errorCode, e) + } + } + } + + override fun getPasskey(input: ReadableMap, promise: Promise) { + if (!isPasskeySupported) { + return promise.reject( + "NOT_SUPPORTED", + GetCredentialUnsupportedException("GetPasskeyNotSupported") + ) + } + + val credentialManager = CredentialManager.create(reactApplicationContext.applicationContext) + + val requestJson = JSONObject(input.toHashMap()).toString() + val options = + GetPublicKeyCredentialOption(requestJson = requestJson) + val request = GetCredentialRequest(credentialOptions = listOf(options)) + + moduleScope.launch { + try { + val result: GetCredentialResponse = + credentialManager.getCredential( + context = currentActivity ?: reactApplicationContext, + request = request + ) + + val publicKeyResult = + result.credential as? PublicKeyCredential ?: throw Exception("GetPasskeyFailed") + + val jsonObject = JSONObject(publicKeyResult.authenticationResponseJson) + + promise.resolve(JSONArguments.fromJSONObject(jsonObject)) + } catch (e: Exception) { + val errorCode = handlePasskeyFailure(e) + promise.reject(errorCode, e) + } + } + } + + private fun handlePasskeyFailure(e: Exception): String { + return when (e) { + is CreatePublicKeyCredentialDomException -> { + when (e.domError) { + is NotAllowedError -> "CANCELED" + is InvalidStateError -> "DUPLICATE" + is DataError -> "RELYING_PARTY_MISMATCH" + else -> "FAILED" + } + } + + is GetPublicKeyCredentialDomException -> { + when (e.domError) { + is NotAllowedError -> "CANCELED" + is DataError -> "RELYING_PARTY_MISMATCH" + else -> "FAILED" + } + } + + is CreateCredentialCancellationException, + is GetCredentialCancellationException -> "CANCELED" + + is CreateCredentialUnsupportedException, + is CreateCredentialProviderConfigurationException, + is GetCredentialUnsupportedException, + is GetCredentialProviderConfigurationException -> "NOT_SUPPORTED" + + else -> "FAILED" + } + } +} diff --git a/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysPackage.kt b/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysPackage.kt new file mode 100644 index 00000000000..7f6584faea8 --- /dev/null +++ b/packages/rtn-passkeys/android/src/main/kotlin/com/amazonaws/amplify/rtnpasskeys/AmplifyRtnPasskeysPackage.kt @@ -0,0 +1,32 @@ +package com.amazonaws.amplify.rtnpasskeys + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +class AmplifyRtnPasskeysPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == AmplifyRtnPasskeysModule.NAME) { + AmplifyRtnPasskeysModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[AmplifyRtnPasskeysModule.NAME] = ReactModuleInfo( + AmplifyRtnPasskeysModule.NAME, + AmplifyRtnPasskeysModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/packages/rtn-passkeys/android/src/test/kotlin/AmplifyRtnPasskeysModuleTest.kt b/packages/rtn-passkeys/android/src/test/kotlin/AmplifyRtnPasskeysModuleTest.kt new file mode 100644 index 00000000000..a2c4d84fe3a --- /dev/null +++ b/packages/rtn-passkeys/android/src/test/kotlin/AmplifyRtnPasskeysModuleTest.kt @@ -0,0 +1,220 @@ +import android.os.Build + +import androidx.credentials.CreateCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialUnsupportedException +import androidx.credentials.exceptions.GetCredentialUnsupportedException +import androidx.credentials.exceptions.domerrors.DataError +import androidx.credentials.exceptions.domerrors.NotAllowedError +import androidx.credentials.exceptions.publickeycredential.CreatePublicKeyCredentialDomException +import androidx.credentials.exceptions.publickeycredential.GetPublicKeyCredentialDomException + +import com.amazonaws.amplify.rtnpasskeys.AmplifyRtnPasskeysModule + +import com.facebook.react.bridge.JSONArguments +import com.facebook.react.bridge.Promise + +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.verify + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +import org.junit.Before +import org.junit.Test + +import org.junit.runner.RunWith + +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class AmplifyRtnPasskeysModuleTest { + private val responseJson = """{"response":"json"}""" + + private val context = mockk(relaxed = true) + + private val promise = mockk(relaxed = true) + + private val readableMap = mockk { + every { toHashMap() } returns hashMapOf("user" to hashMapOf("name" to "james")) + } + + private val credentialManager = mockk() + + private val module = AmplifyRtnPasskeysModule(context) + + @Before + fun setup() { + // setup CredentialManager + mockkObject(CredentialManager) + every { CredentialManager.create(any()) } returns credentialManager + + // setup JSONArguments + mockkStatic(JSONArguments::class) + every { JSONArguments.fromJSONObject(any()) } returns readableMap + } + + @Test + fun getName_returnsCorrectName() { + assert(module.name == "AmplifyRtnPasskeys") + } + + @Config(sdk = [Build.VERSION_CODES.O]) + @Test + fun getIsPasskeySupported_returnsFalse_onUnsupportedDevice() { + assert(!module.isPasskeySupported) + } + + @Test + fun getIsPasskeySupported_returnsTrue_onSupportedDevice() { + assert(module.isPasskeySupported) + } + + @Config(sdk = [Build.VERSION_CODES.O]) + @Test + fun createPasskey_rejectsWithError_onUnsupportedDevice() { + module.createPasskey(readableMap, promise) + verify { promise.reject("NOT_SUPPORTED", any()) } + } + + @Test + fun createPasskey_resolvesWithOutput_onSupportedDevice() = runTest { + coEvery { + credentialManager.getCredential( + any(), + any() + ) + } returns GetCredentialResponse( + PublicKeyCredential(responseJson) + ) + coEvery { + credentialManager.createCredential( + any(), + any() + ) + } returns CreatePublicKeyCredentialResponse( + responseJson + ) + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).createPasskey( + readableMap, + promise + ) + verify { promise.resolve(readableMap) } + } + + @Test + fun createPasskey_rejectsWithError_whenCreateCredentialResultIsInvalid() = runTest { + coEvery { credentialManager.createCredential(any(), any()) } returns mockk() + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).createPasskey( + readableMap, + promise + ) + coVerify { credentialManager.createCredential(any(), any()) } + verify { promise.reject("FAILED", any()) } + } + + @Test + fun createPasskey_rejectsWithError_whenDomExceptionThrown() = runTest { + coEvery { + credentialManager.createCredential( + any(), + any() + ) + } throws CreatePublicKeyCredentialDomException(NotAllowedError()) + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).createPasskey( + readableMap, + promise + ) + coVerify { credentialManager.createCredential(any(), any()) } + verify { promise.reject("CANCELED", any()) } + } + + @Config(sdk = [Build.VERSION_CODES.O]) + @Test + fun getPasskey_rejectsWithError_onUnsupportedDevice() = runTest { + module.getPasskey(readableMap, promise) + verify { promise.reject("NOT_SUPPORTED", any()) } + } + + @Test + fun getPasskey_resolvesWithOutput_onSupportedDevice() = runTest { + coEvery { + credentialManager.getCredential( + any(), + any() + ) + } returns GetCredentialResponse( + PublicKeyCredential(responseJson) + ) + coEvery { + credentialManager.createCredential( + any(), + any() + ) + } returns CreatePublicKeyCredentialResponse( + responseJson + ) + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).getPasskey( + readableMap, + promise + ) + verify { promise.resolve(readableMap) } + } + + @Test + fun getPasskey_rejectsWithError_whenGetCredentialResultIsInvalid() = runTest { + coEvery { + credentialManager.getCredential( + any(), + any() + ) + } returns mockk { + coEvery { credential } returns mockk() + } + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).getPasskey( + readableMap, + promise + ) + coVerify { credentialManager.getCredential(any(), any()) } + verify { promise.reject("FAILED", any()) } + } + + @Test + fun getPasskey_rejectsWithError_whenDomExceptionThrown() = runTest { + coEvery { + credentialManager.getCredential( + any(), + any() + ) + } throws GetPublicKeyCredentialDomException(DataError()) + + AmplifyRtnPasskeysModule(context, UnconfinedTestDispatcher(testScheduler)).getPasskey( + readableMap, + promise + ) + coVerify { credentialManager.getCredential(any(), any()) } + verify { + promise.reject( + "RELYING_PARTY_MISMATCH", + any() + ) + } + } + +}