diff --git a/build.gradle.kts b/build.gradle.kts index dc6c5e0..521a6e1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,9 @@ +@file:OptIn(ExperimentalWasmDsl::class) + import com.android.build.gradle.LibraryExtension import com.liftric.vault.GetVaultSecretTask import com.vanniktech.maven.publish.KotlinMultiplatform +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion import org.jetbrains.kotlin.gradle.targets.native.tasks.KotlinNativeSimulatorTest @@ -108,6 +111,10 @@ kotlin { } } } + wasmJs { + browser() + binaries.library() + } sourceSets { val commonMain by getting { @@ -164,6 +171,10 @@ kotlin { implementation(kotlin("test-js")) } } + wasmJsMain.dependencies { + api(libs.ktor.client.js) + api(libs.kotlinx.browser) + } all { languageSettings { optIn("kotlinx.serialization.ExperimentalSerializationApi") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ac409b..34a4ce3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ "ktor" = "3.3.1" "otp-java" = "2.1.0" "robolectric" = "4.16" +"browser" = "0.5.0" [libraries] "androidx-test-core" = { module = "androidx.test:core", version.ref = "androidTestCore" } @@ -25,6 +26,7 @@ "ktor-client-js" = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } "otp-java" = { module = "com.github.bastiaanjansen:otp-java", version.ref = "otp-java" } "robolectric" = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +"kotlinx-browser" = { group = "org.jetbrains.kotlinx", name = "kotlinx-browser", version.ref = "browser" } [plugins] "kotlin-serialization" = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/src/commonMain/kotlin/com/liftric/cognito/idp/core/Payload.kt b/src/commonMain/kotlin/com/liftric/cognito/idp/core/Payload.kt index cc01c4e..7b00a95 100644 --- a/src/commonMain/kotlin/com/liftric/cognito/idp/core/Payload.kt +++ b/src/commonMain/kotlin/com/liftric/cognito/idp/core/Payload.kt @@ -86,7 +86,6 @@ internal data class ConfirmForgotPassword( val Password: String ) -@JsExport @Serializable data class UserAttribute( val Name: String, @@ -134,7 +133,6 @@ internal data class SetUserMFAPreference( val SoftwareTokenMfaSettings: MfaSettings? ) -@JsExport @Serializable data class MfaSettings( val Enabled: Boolean, diff --git a/src/commonMain/kotlin/com/liftric/cognito/idp/core/Response.kt b/src/commonMain/kotlin/com/liftric/cognito/idp/core/Response.kt index ca61389..8dd783c 100644 --- a/src/commonMain/kotlin/com/liftric/cognito/idp/core/Response.kt +++ b/src/commonMain/kotlin/com/liftric/cognito/idp/core/Response.kt @@ -22,7 +22,6 @@ data class SignInResponse( val Session: String? ) -@JsExport @Serializable data class AuthenticationResult( val AccessToken: String?, @@ -33,14 +32,12 @@ data class AuthenticationResult( val NewDeviceMetadata: NewDeviceMetadata?, ) -@JsExport @Serializable data class NewDeviceMetadata( val DeviceGroupKey: String?, val DeviceKey: String?, ) -@JsExport @Serializable data class SignUpResponse( val CodeDeliveryDetails: CodeDeliveryDetails?, @@ -48,13 +45,11 @@ data class SignUpResponse( val UserSub: String ) -@JsExport @Serializable data class ResendConfirmationCodeResponse( val CodeDeliveryDetails: CodeDeliveryDetails ) -@JsExport @Serializable data class CodeDeliveryDetails( val AttributeName: String?, @@ -71,7 +66,6 @@ data class GetUserResponse( val Username: String ) -@JsExport @Serializable data class MFAOptions( val AttributeName: String?, @@ -83,26 +77,22 @@ data class UpdateUserAttributesResponse( val CodeDeliveryDetailsList: List = listOf() ) -@JsExport @Serializable data class GetAttributeVerificationCodeResponse( val CodeDeliveryDetails: CodeDeliveryDetails ) -@JsExport @Serializable data class ForgotPasswordResponse( val CodeDeliveryDetails: CodeDeliveryDetails ) -@JsExport @Serializable data class AssociateSoftwareTokenResponse( val SecretCode: String, val Session: String? ) -@JsExport @Serializable data class VerifySoftwareTokenResponse( val Session: String?, diff --git a/src/jsMain/kotlin/IdentityProviderJS.kt b/src/jsMain/kotlin/IdentityProviderJS.kt index 48c9f17..76a7c05 100644 --- a/src/jsMain/kotlin/IdentityProviderJS.kt +++ b/src/jsMain/kotlin/IdentityProviderJS.kt @@ -15,16 +15,16 @@ class IdentityProviderClientJS(region: String, clientId: String) { fun signUp( username: String, password: String, - attributes: Array? = null, + attributes: Array? = null, clientMetadata: Array? = null, - ): Promise = + ): Promise = MainScope().promise { provider.signUp( username = username, password = password, - attributes = attributes?.toList(), + attributes = attributes?.map(UserAttributeJS::toUserAttribute)?.toList(), clientMetadata = clientMetadata?.associate { it.key to it.value } - ).getOrWrapThrowable() + ).getOrWrapThrowable().toSignUpResponseJS() } fun confirmSignUp(username: String, confirmationCode: String): Promise = @@ -35,10 +35,10 @@ class IdentityProviderClientJS(region: String, clientId: String) { ).getOrWrapThrowable() } - fun resendConfirmationCode(username: String): Promise = + fun resendConfirmationCode(username: String): Promise = MainScope().promise { provider.resendConfirmationCode(username) - .getOrWrapThrowable() + .getOrWrapThrowable().toResendConfirmationCodeResponseJS() } fun signIn(username: String, password: String): Promise = @@ -46,7 +46,7 @@ class IdentityProviderClientJS(region: String, clientId: String) { provider.signIn(username, password) .getOrWrapThrowable().let { SignInResponseJS( - AuthenticationResult = it.AuthenticationResult, + AuthenticationResult = it.AuthenticationResult?.toAuthenticationResultJS(), ChallengeParameters = it.ChallengeParameters.toMapEntries(), ChallengeName = it.ChallengeName, Session = it.Session, @@ -59,7 +59,7 @@ class IdentityProviderClientJS(region: String, clientId: String) { provider.refresh(refreshToken) .getOrWrapThrowable().let { SignInResponseJS( - AuthenticationResult = it.AuthenticationResult, + AuthenticationResult = it.AuthenticationResult?.toAuthenticationResultJS(), ChallengeParameters = it.ChallengeParameters.toMapEntries(), ChallengeName = it.ChallengeName, Session = it.Session @@ -72,9 +72,9 @@ class IdentityProviderClientJS(region: String, clientId: String) { provider.getUser(accessToken) .getOrWrapThrowable().let { GetUserResponseJS( - MFAOptions = it.MFAOptions, + MFAOptions = it.MFAOptions?.toMFAOptionsJS(), PreferredMfaSetting = it.PreferredMfaSetting, - UserAttributes = it.UserAttributes.toTypedArray(), + UserAttributes = it.UserAttributes.map(UserAttribute::toUserAttributeJS).toTypedArray(), UserMFASettingList = it.UserMFASettingList.toTypedArray(), Username = it.Username ) @@ -83,14 +83,14 @@ class IdentityProviderClientJS(region: String, clientId: String) { fun updateUserAttributes( accessToken: String, - attributes: Array + attributes: Array ): Promise = MainScope().promise { provider.updateUserAttributes( accessToken = accessToken, - attributes = attributes.toList() + attributes = attributes.map(UserAttributeJS::toUserAttribute).toList() ).getOrWrapThrowable().let { - UpdateUserAttributesResponseJS(it.CodeDeliveryDetailsList.toTypedArray()) + UpdateUserAttributesResponseJS(it.CodeDeliveryDetailsList.map(CodeDeliveryDetails::toCodeDeliveryDetailsJS).toTypedArray()) } } @@ -107,10 +107,10 @@ class IdentityProviderClientJS(region: String, clientId: String) { ).getOrWrapThrowable() } - fun forgotPassword(username: String, clientMetadata: Array? = null): Promise = + fun forgotPassword(username: String, clientMetadata: Array? = null): Promise = MainScope().promise { provider.forgotPassword(username, clientMetadata?.associate { it.key to it.value }) - .getOrWrapThrowable() + .getOrWrapThrowable().toForgotPasswordResponseJS() } fun confirmForgotPassword( @@ -130,13 +130,13 @@ class IdentityProviderClientJS(region: String, clientId: String) { accessToken: String, attributeName: String, clientMetadata: Array? = null - ): Promise = + ): Promise = MainScope().promise { provider.getUserAttributeVerificationCode( accessToken = accessToken, attributeName = attributeName, clientMetadata = clientMetadata?.associate { it.key to it.value } - ).getOrWrapThrowable() + ).getOrWrapThrowable().toGetAttributeVerificationCodeResponseJS() } fun verifyUserAttribute( @@ -172,13 +172,13 @@ class IdentityProviderClientJS(region: String, clientId: String) { fun setUserMFAPreference( accessToken: String, - smsMfaSettings: MfaSettings?, - softwareTokenMfaSettings: MfaSettings? + smsMfaSettings: MfaSettingsJS?, + softwareTokenMfaSettings: MfaSettingsJS? ): Promise = MainScope().promise { provider.setUserMFAPreference( accessToken = accessToken, - smsMfaSettings = smsMfaSettings, - softwareTokenMfaSettings = softwareTokenMfaSettings + smsMfaSettings = smsMfaSettings?.toMfaSettings(), + softwareTokenMfaSettings = softwareTokenMfaSettings?.toMfaSettings() ).getOrWrapThrowable() } @@ -193,7 +193,7 @@ class IdentityProviderClientJS(region: String, clientId: String) { session ).getOrWrapThrowable().let { SignInResponseJS( - AuthenticationResult = it.AuthenticationResult, + AuthenticationResult = it.AuthenticationResult?.toAuthenticationResultJS(), ChallengeParameters = it.ChallengeParameters.toMapEntries(), ChallengeName = it.ChallengeName, Session = it.Session @@ -203,42 +203,42 @@ class IdentityProviderClientJS(region: String, clientId: String) { fun associateSoftwareToken( accessToken: String - ): Promise = MainScope().promise { + ): Promise = MainScope().promise { provider.associateSoftwareToken( accessToken = accessToken - ).getOrWrapThrowable() + ).getOrWrapThrowable().toAssociateSoftwareTokenResponseJS() } fun associateSoftwareTokenBySession( session: String - ): Promise = MainScope().promise { + ): Promise = MainScope().promise { provider.associateSoftwareTokenBySession( session = session - ).getOrWrapThrowable() + ).getOrWrapThrowable().toAssociateSoftwareTokenResponseJS() } fun verifySoftwareToken( accessToken: String, friendlyDeviceName: String?, userCode: String - ): Promise = MainScope().promise { + ): Promise = MainScope().promise { provider.verifySoftwareToken( accessToken = accessToken, friendlyDeviceName = friendlyDeviceName, userCode = userCode - ).getOrWrapThrowable() + ).getOrWrapThrowable().toVerifySoftwareTokenResponseJS() } fun verifySoftwareTokenBySession( session: String, friendlyDeviceName: String?, userCode: String - ): Promise = MainScope().promise { + ): Promise = MainScope().promise { provider.verifySoftwareTokenBySession( friendlyDeviceName = friendlyDeviceName, session = session, userCode = userCode - ).getOrWrapThrowable() + ).getOrWrapThrowable().toVerifySoftwareTokenResponseJS() } private fun Result.getOrWrapThrowable(): T = when (value) { diff --git a/src/jsMain/kotlin/PayloadJS.kt b/src/jsMain/kotlin/PayloadJS.kt new file mode 100644 index 0000000..904a214 --- /dev/null +++ b/src/jsMain/kotlin/PayloadJS.kt @@ -0,0 +1,31 @@ +import com.liftric.cognito.idp.core.MfaSettings +import com.liftric.cognito.idp.core.UserAttribute +import kotlinx.serialization.Serializable + +@JsExport +@Serializable +data class UserAttributeJS( + val Name: String, val Value: String +) + +fun UserAttributeJS.toUserAttribute(): UserAttribute { + return UserAttribute(Name, Value) +} + +fun UserAttribute.toUserAttributeJS(): UserAttributeJS { + return UserAttributeJS(Name, Value) +} + +@JsExport +@Serializable +data class MfaSettingsJS( + val Enabled: Boolean, val PreferredMfa: Boolean +) + +fun MfaSettingsJS.toMfaSettings(): MfaSettings { + return MfaSettings(Enabled, PreferredMfa) +} + +fun MfaSettings.toMfaSettingsJS(): MfaSettingsJS { + return MfaSettingsJS(Enabled, PreferredMfa) +} \ No newline at end of file diff --git a/src/jsMain/kotlin/ResponseJS.kt b/src/jsMain/kotlin/ResponseJS.kt index 61006bd..f09104c 100644 --- a/src/jsMain/kotlin/ResponseJS.kt +++ b/src/jsMain/kotlin/ResponseJS.kt @@ -1,4 +1,5 @@ import com.liftric.cognito.idp.core.* +import kotlinx.serialization.Serializable /** * Adapted [Response.kt] classes for Typescript usage (Map and List aren't compatible for [kotlin.js.JsExport]) @@ -6,7 +7,7 @@ import com.liftric.cognito.idp.core.* @JsExport data class SignInResponseJS( - val AuthenticationResult: AuthenticationResult?, + val AuthenticationResult: AuthenticationResultJS?, val ChallengeParameters: Array, val ChallengeName: String?, val Session: String? @@ -32,9 +33,9 @@ data class SignInResponseJS( @JsExport data class GetUserResponseJS( - val MFAOptions: MFAOptions?, + val MFAOptions: MFAOptionsJS?, val PreferredMfaSetting: String?, - val UserAttributes : Array, + val UserAttributes: Array, val UserMFASettingList: Array, val Username: String ) { @@ -65,7 +66,7 @@ data class GetUserResponseJS( @JsExport data class UpdateUserAttributesResponseJS( - val CodeDeliveryDetailsList: Array = arrayOf() + val CodeDeliveryDetailsList: Array = arrayOf() ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -89,3 +90,123 @@ data class MapEntry(val key: String, val value: String) internal fun Map.toMapEntries(): Array = entries.map { MapEntry(it.key, it.value) }.toTypedArray() + +@JsExport +@Serializable +data class NewDeviceMetadataJS( + val DeviceGroupKey: String?, + val DeviceKey: String?, +) + +fun NewDeviceMetadata.toNewDeviceMetadataJS(): NewDeviceMetadataJS { + return NewDeviceMetadataJS(DeviceGroupKey, DeviceKey) +} + +@JsExport +@Serializable +data class AuthenticationResultJS( + val AccessToken: String?, + val ExpiresIn: Int?, + val IdToken: String?, + val RefreshToken: String?, + val TokenType: String?, + val NewDeviceMetadata: NewDeviceMetadataJS?, +) + +fun AuthenticationResult.toAuthenticationResultJS(): AuthenticationResultJS { + return AuthenticationResultJS( + AccessToken, + ExpiresIn, + IdToken, + RefreshToken, + TokenType, + NewDeviceMetadata?.toNewDeviceMetadataJS() + ) +} + +@JsExport +@Serializable +data class SignUpResponseJS( + val CodeDeliveryDetails: CodeDeliveryDetailsJS?, + val UserConfirmed: Boolean, + val UserSub: String +) + +fun SignUpResponse.toSignUpResponseJS(): SignUpResponseJS { + return SignUpResponseJS(CodeDeliveryDetails?.toCodeDeliveryDetailsJS(), UserConfirmed, UserSub) +} + +@JsExport +@Serializable +data class ResendConfirmationCodeResponseJS( + val CodeDeliveryDetails: CodeDeliveryDetailsJS +) + +fun ResendConfirmationCodeResponse.toResendConfirmationCodeResponseJS(): ResendConfirmationCodeResponseJS { + return ResendConfirmationCodeResponseJS(CodeDeliveryDetails.toCodeDeliveryDetailsJS()) +} + +@JsExport +@Serializable +data class CodeDeliveryDetailsJS( + val AttributeName: String?, + val DeliveryMedium: String?, + val Destination: String? +) + +fun CodeDeliveryDetails.toCodeDeliveryDetailsJS(): CodeDeliveryDetailsJS { + return CodeDeliveryDetailsJS(AttributeName, DeliveryMedium, Destination) +} + +@JsExport +@Serializable +data class MFAOptionsJS( + val AttributeName: String?, + val DeliveryMedium: String? +) + +fun MFAOptions.toMFAOptionsJS(): MFAOptionsJS { + return MFAOptionsJS(AttributeName, DeliveryMedium) +} + +@JsExport +@Serializable +data class GetAttributeVerificationCodeResponseJS( + val CodeDeliveryDetails: CodeDeliveryDetailsJS +) + +fun GetAttributeVerificationCodeResponse.toGetAttributeVerificationCodeResponseJS(): GetAttributeVerificationCodeResponseJS { + return GetAttributeVerificationCodeResponseJS(CodeDeliveryDetails.toCodeDeliveryDetailsJS()) +} + +@JsExport +@Serializable +data class ForgotPasswordResponseJS( + val CodeDeliveryDetails: CodeDeliveryDetailsJS +) + +fun ForgotPasswordResponse.toForgotPasswordResponseJS(): ForgotPasswordResponseJS { + return ForgotPasswordResponseJS(CodeDeliveryDetails.toCodeDeliveryDetailsJS()) +} + +@JsExport +@Serializable +data class AssociateSoftwareTokenResponseJS( + val SecretCode: String, + val Session: String? +) + +fun AssociateSoftwareTokenResponse.toAssociateSoftwareTokenResponseJS(): AssociateSoftwareTokenResponseJS { + return AssociateSoftwareTokenResponseJS(SecretCode, Session) +} + +@JsExport +@Serializable +data class VerifySoftwareTokenResponseJS( + val Session: String?, + val Status: String +) + +fun VerifySoftwareTokenResponse.toVerifySoftwareTokenResponseJS(): VerifySoftwareTokenResponseJS { + return VerifySoftwareTokenResponseJS(Session, Status) +} \ No newline at end of file diff --git a/src/jsTest/kotlin/com/liftric/cognito/idp/IdentityProviderClientJSTests.kt b/src/jsTest/kotlin/com/liftric/cognito/idp/IdentityProviderClientJSTests.kt index 4048e25..68a64a4 100644 --- a/src/jsTest/kotlin/com/liftric/cognito/idp/IdentityProviderClientJSTests.kt +++ b/src/jsTest/kotlin/com/liftric/cognito/idp/IdentityProviderClientJSTests.kt @@ -2,6 +2,7 @@ package com.liftric.cognito.idp import IdentityProviderClientJS import IdentityProviderExceptionJs +import UserAttributeJS import com.liftric.cognito.idp.core.UserAttribute import env import kotlinx.coroutines.await @@ -31,7 +32,7 @@ class IdentityProviderClientJSTests { provider.signUp( username, password, attributes = arrayOf( - UserAttribute(Name = "custom:target_group", Value = "ROLE_PATIENT") + UserAttributeJS(Name = "custom:target_group", Value = "ROLE_PATIENT") ) ).await().also { println("signUpResponse=$it") @@ -56,7 +57,7 @@ class IdentityProviderClientJSTests { provider.signUp( username, password, attributes = arrayOf( - UserAttribute(Name = "custom:target_group", Value = "ROLE_PATIENT") + UserAttributeJS(Name = "custom:target_group", Value = "ROLE_PATIENT") ), clientMetadata = mapOf("fallback_mode" to "true").toMapEntries(), ).await().also { @@ -81,7 +82,7 @@ class IdentityProviderClientJSTests { provider.signUp( "Username", buildString { (1..260).forEach { _ -> append("A") } }, attributes = arrayOf( - UserAttribute(Name = "custom:target_group", Value = "ROLE_USER") + UserAttributeJS(Name = "custom:target_group", Value = "ROLE_USER") ) ).then { fail("signUp must fail") diff --git a/src/wasmJsMain/kotlin/com/liftric/cognito/idp/core/Engine.kt b/src/wasmJsMain/kotlin/com/liftric/cognito/idp/core/Engine.kt new file mode 100644 index 0000000..96af254 --- /dev/null +++ b/src/wasmJsMain/kotlin/com/liftric/cognito/idp/core/Engine.kt @@ -0,0 +1,7 @@ +package com.liftric.cognito.idp.core + +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.js.Js + +internal actual val Engine: HttpClientEngine + get() = Js.create() \ No newline at end of file diff --git a/src/wasmJsMain/kotlin/com/liftric/cognito/idp/jwt/Base64.kt b/src/wasmJsMain/kotlin/com/liftric/cognito/idp/jwt/Base64.kt new file mode 100644 index 0000000..3fd70d2 --- /dev/null +++ b/src/wasmJsMain/kotlin/com/liftric/cognito/idp/jwt/Base64.kt @@ -0,0 +1,28 @@ +package com.liftric.cognito.idp.jwt + +import kotlinx.browser.window + +internal actual class Base64 { + actual companion object { + actual fun decode(input: String): String? { + // Validate the input to ensure it is proper Base64 + if (!input.matches(Regex("^[A-Za-z0-9+/]*={0,2}\$"))) { + return null + } + + return try { + // Decode using the browser's atob function + val decoded = window.atob(input) + + // Re-encode the result to compare with input (remove padding) + val reencoded = window.btoa(decoded) + .replace(Regex("=+\$"), "") + + if (input.replace(Regex("=+\$"), "") != reencoded) null else decoded + } catch (e: Throwable) { + // Return null if decoding fails + null + } + } + } +} \ No newline at end of file diff --git a/src/wasmJsTest/kotlin/idp/IdentityProviderClientTests.kt b/src/wasmJsTest/kotlin/idp/IdentityProviderClientTests.kt new file mode 100644 index 0000000..b7df702 --- /dev/null +++ b/src/wasmJsTest/kotlin/idp/IdentityProviderClientTests.kt @@ -0,0 +1,12 @@ +package com.liftric.cognito.idp + +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.promise + +actual class IdentityProviderClientTests : AbstractIdentityProviderClientTests() + +actual fun runTest(block: suspend () -> Unit) { + MainScope().promise { + block.invoke() + } +} \ No newline at end of file