From f4d813a1ace842595f67012686b7501ced70c3c4 Mon Sep 17 00:00:00 2001 From: Mirzamehdi Karimov <32781662+mirzemehdi@users.noreply.github.com> Date: Sat, 19 Oct 2024 00:44:01 +0200 Subject: [PATCH] Implementing Google Sign In in Desktop (#59) * Adding ktor * Google Authentication main logic in desktop --- build.gradle.kts | 1 + gradle/libs.versions.toml | 15 ++ kmpauth-core/build.gradle.kts | 22 ++- .../com/mmk/kmpauth/core/HttpClientFactory.kt | 23 +++ .../core/di/LibDependencyInitializer.kt | 4 +- .../mmk/kmpauth/core/di/PlatformModule.jvm.kt | 3 +- kmpauth-google/build.gradle.kts | 7 + .../kmpauth/google/GoogleAuthProviderImpl.kt | 8 +- .../google/GoogleAuthUiProviderImpl.kt | 138 +++++++++++++++++- sampleApp/composeApp/api/composeApp.api | 65 --------- .../composeApp/api/desktop/composeApp.api | 2 + .../kotlin/com/mmk/kmpauth/sample/Main.kt | 41 ++++-- 12 files changed, 245 insertions(+), 84 deletions(-) create mode 100644 kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/HttpClientFactory.kt delete mode 100644 sampleApp/composeApp/api/composeApp.api diff --git a/build.gradle.kts b/build.gradle.kts index 9ef72dd..26d104e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.kotlinNativeCocoaPods) apply false alias(libs.plugins.dokka) apply false alias(libs.plugins.googleServices) apply false + alias(libs.plugins.kotlinx.serialization).apply(false) alias(libs.plugins.kotlinx.binary.validator) alias(libs.plugins.nexusPublish) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73c0c57..5b30319 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,8 @@ googleServices = "4.4.2" firebaseGitLiveAuth = "2.1.0" androidLegacyPlayServices = "21.2.0" nexusPublish = "2.0.0" +ktor = "3.0.0" +kotlinx-serialization = "1.7.3" [libraries] @@ -59,6 +61,18 @@ googleIdIdentity = { module = "com.google.android.libraries.identity.googleid:go #Firebase firebase-gitlive-auth = { module = "dev.gitlive:firebase-auth", version.ref = "firebaseGitLiveAuth" } +#Ktor +ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } +ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } +ktor-server-html-builder = { group = "io.ktor", name = "ktor-server-html-builder", version.ref = "ktor" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } [plugins] jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } @@ -71,3 +85,4 @@ kotlinx-binary-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-va dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } nexusPublish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublish" } +kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/kmpauth-core/build.gradle.kts b/kmpauth-core/build.gradle.kts index 25fe44d..b97db75 100644 --- a/kmpauth-core/build.gradle.kts +++ b/kmpauth-core/build.gradle.kts @@ -2,6 +2,8 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinNativeCocoaPods) + alias(libs.plugins.kotlinx.serialization) + } kotlin { @@ -38,15 +40,31 @@ kotlin { sourceSets { + commonMain.dependencies { + implementation(libs.koin.core) + implementation(libs.ktor.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.logging) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + } androidMain.dependencies { implementation(libs.androidx.startup.runtime) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.ktx) + implementation(libs.ktor.client.okhttp) } - commonMain.dependencies { - implementation(libs.koin.core) + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + jsMain.dependencies { + implementation(libs.ktor.client.js) } + jvmMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + } } diff --git a/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/HttpClientFactory.kt b/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/HttpClientFactory.kt new file mode 100644 index 0000000..e63659e --- /dev/null +++ b/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/HttpClientFactory.kt @@ -0,0 +1,23 @@ +package com.mmk.kmpauth.core + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logger +import io.ktor.client.plugins.logging.Logging +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +internal object HttpClientFactory { + internal fun default() = HttpClient { + defaultRequest { + header(HttpHeaders.ContentType, "application/json") + } + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } +} \ No newline at end of file diff --git a/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/di/LibDependencyInitializer.kt b/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/di/LibDependencyInitializer.kt index 5aab1ef..6d069d8 100644 --- a/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/di/LibDependencyInitializer.kt +++ b/kmpauth-core/src/commonMain/kotlin/com/mmk/kmpauth/core/di/LibDependencyInitializer.kt @@ -1,6 +1,7 @@ package com.mmk.kmpauth.core.di +import com.mmk.kmpauth.core.HttpClientFactory import com.mmk.kmpauth.core.KMPAuthInternalApi import org.koin.core.Koin import org.koin.core.KoinApplication @@ -24,6 +25,7 @@ public object LibDependencyInitializer { public fun initialize(modules: List = emptyList()) { if (isInitialized()) return val configModule = module { + single { HttpClientFactory.default() } includes(modules) } koinApp = koinApplication { @@ -40,6 +42,6 @@ public object LibDependencyInitializer { } private fun Koin.onLibraryInitialized() { - println("Library is initialized") + println("KMPAuth Library is initialized") } diff --git a/kmpauth-core/src/jvmMain/kotlin/com/mmk/kmpauth/core/di/PlatformModule.jvm.kt b/kmpauth-core/src/jvmMain/kotlin/com/mmk/kmpauth/core/di/PlatformModule.jvm.kt index 988452d..4a4d5e2 100644 --- a/kmpauth-core/src/jvmMain/kotlin/com/mmk/kmpauth/core/di/PlatformModule.jvm.kt +++ b/kmpauth-core/src/jvmMain/kotlin/com/mmk/kmpauth/core/di/PlatformModule.jvm.kt @@ -6,4 +6,5 @@ import org.koin.dsl.module @KMPAuthInternalApi public actual fun isAndroidPlatform(): Boolean = false -internal actual val platformModule: Module = module { } \ No newline at end of file +internal actual val platformModule: Module = module { } + diff --git a/kmpauth-google/build.gradle.kts b/kmpauth-google/build.gradle.kts index d6e0109..edd437a 100644 --- a/kmpauth-google/build.gradle.kts +++ b/kmpauth-google/build.gradle.kts @@ -54,8 +54,15 @@ kotlin { implementation(compose.foundation) implementation(libs.koin.compose) implementation(libs.koin.core) + implementation(libs.ktor.core) api(project(":kmpauth-core")) } + jvmMain.dependencies { + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.html.builder) + implementation("com.auth0:java-jwt:4.4.0") // Check for the latest version + } } } diff --git a/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthProviderImpl.kt b/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthProviderImpl.kt index cbf5ac1..0164292 100644 --- a/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthProviderImpl.kt +++ b/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthProviderImpl.kt @@ -2,14 +2,16 @@ package com.mmk.kmpauth.google import androidx.compose.runtime.Composable -internal class GoogleAuthProviderImpl : GoogleAuthProvider { +internal class GoogleAuthProviderImpl(private val googleAuthCredentials: GoogleAuthCredentials) : + GoogleAuthProvider { @Composable override fun getUiProvider(): GoogleAuthUiProvider { - TODO("Not yet implemented") + return GoogleAuthUiProviderImpl(credentials = googleAuthCredentials) } + override suspend fun signOut() { - TODO("Not yet implemented") + println("Not implemented") } } \ No newline at end of file diff --git a/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthUiProviderImpl.kt b/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthUiProviderImpl.kt index 067ebb3..0174a6e 100644 --- a/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthUiProviderImpl.kt +++ b/kmpauth-google/src/jvmMain/kotlin/com/mmk/kmpauth/google/GoogleAuthUiProviderImpl.kt @@ -1,8 +1,142 @@ package com.mmk.kmpauth.google -internal class GoogleAuthUiProviderImpl : GoogleAuthUiProvider { +import com.auth0.jwt.JWT +import io.ktor.http.ContentType +import io.ktor.server.engine.embeddedServer +import io.ktor.server.html.respondHtml +import io.ktor.server.netty.Netty +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.html.body +import kotlinx.html.script +import kotlinx.html.unsafe +import java.awt.Desktop +import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.Base64 + +internal class GoogleAuthUiProviderImpl(private val credentials: GoogleAuthCredentials) : + GoogleAuthUiProvider { + + private val authUrl = "https://accounts.google.com/o/oauth2/v2/auth" + override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? { - TODO("Not yet implemented") + val scope = "email profile" + val redirectUri = "http://localhost:8080/callback" + val state: String + val nonce: String + val googleAuthUrl = withContext(Dispatchers.IO) { + state = URLEncoder.encode(generateRandomString(), StandardCharsets.UTF_8.toString()) + val encodedScope = URLEncoder.encode(scope, StandardCharsets.UTF_8.toString()) + nonce = URLEncoder.encode(generateRandomString(), StandardCharsets.UTF_8.toString()) + + "$authUrl?" + + "client_id=${credentials.serverId}" + + "&redirect_uri=$redirectUri" + + "&response_type=id_token" + + "&scope=$encodedScope" + + "&nonce=$nonce" + + "&state=$state" + } + + + openUrlInBrowser(googleAuthUrl) + + val idToken = startHttpServerAndGetToken(state = state) + if (idToken == null) { + println("GoogleAuthUiProvider: idToken is null") + return null + } + val jwt = JWT().decodeJwt(idToken) + val name = jwt.getClaim("name")?.asString() // User's name + val picture = jwt.getClaim("picture")?.asString() + val receivedNonce = jwt.getClaim("nonce")?.asString() + if (receivedNonce != nonce) { + println("GoogleAuthUiProvider: Invalid nonce state: A login callback was received, but no login request was sent.") + return null + } + + return GoogleUser( + idToken = idToken, + accessToken = null, + displayName = name ?: "", + profilePicUrl = picture + ) + } + + private suspend fun startHttpServerAndGetToken( + redirectUriPath: String = "/callback", + state: String + ): String? { + val idTokenDeferred = CompletableDeferred() + + val jsCode = """ + var fragment = window.location.hash; + if (fragment) { + var params = new URLSearchParams(fragment.substring(1)); + var idToken = params.get('id_token'); + var receivedState = params.get('state'); + var expectedState = '${state}'; + if (receivedState === expectedState) { + window.location.href = '$redirectUriPath/token?id_token=' + idToken; + } else { + console.error('State does not match! Possible CSRF attack.'); + window.location.href = '$redirectUriPath/token?id_token=null'; + } + } + """.trimIndent() + + val server = embeddedServer(Netty, port = 8080) { + routing { + get(redirectUriPath) { + call.respondHtml { + body { script { unsafe { +jsCode } } } + } + } + get("$redirectUriPath/token") { + val idToken = call.request.queryParameters["id_token"] + if (idToken.isNullOrEmpty().not()) { + call.respondText( + "Authorization is complete. You can close this window, and return to the application", + contentType = ContentType.Text.Plain + ) + idTokenDeferred.complete(idToken) + } else { + call.respondText( + "Authorization failed", + contentType = ContentType.Text.Plain + ) + idTokenDeferred.complete(null) + } + } + } + }.start(wait = false) + + val idToken = idTokenDeferred.await() + server.stop(1000, 1000) + return idToken } + private fun openUrlInBrowser(url: String) { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI(url)) + } else { + println("GoogleAuthUiProvider: Desktop is not supported on this platform.") + } + } + + private fun generateRandomString(length: Int = 32): String { + val secureRandom = SecureRandom() + val stateBytes = ByteArray(length) + secureRandom.nextBytes(stateBytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes) + } + + } \ No newline at end of file diff --git a/sampleApp/composeApp/api/composeApp.api b/sampleApp/composeApp/api/composeApp.api deleted file mode 100644 index 0e34933..0000000 --- a/sampleApp/composeApp/api/composeApp.api +++ /dev/null @@ -1,65 +0,0 @@ -public final class com/mmk/kmpauth/sample/AppInitializer { - public static final field $stable I - public static final field INSTANCE Lcom/mmk/kmpauth/sample/AppInitializer; - public final fun onApplicationStart ()V -} - -public final class com/mmk/kmpauth/sample/AppKt { - public static final fun App (Landroidx/compose/runtime/Composer;I)V - public static final fun AuthUiHelperButtonsAndFirebaseAuth (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V - public static final fun IconOnlyButtonsAndFirebaseAuth (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V -} - -public final class com/mmk/kmpauth/sample/ComposableSingletons$AppKt { - public static final field INSTANCE Lcom/mmk/kmpauth/sample/ComposableSingletons$AppKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; - public static field lambda-10 Lkotlin/jvm/functions/Function3; - public static field lambda-11 Lkotlin/jvm/functions/Function3; - public static field lambda-2 Lkotlin/jvm/functions/Function3; - public static field lambda-3 Lkotlin/jvm/functions/Function3; - public static field lambda-4 Lkotlin/jvm/functions/Function3; - public static field lambda-5 Lkotlin/jvm/functions/Function3; - public static field lambda-6 Lkotlin/jvm/functions/Function3; - public static field lambda-7 Lkotlin/jvm/functions/Function2; - public static field lambda-8 Lkotlin/jvm/functions/Function3; - public static field lambda-9 Lkotlin/jvm/functions/Function3; - public fun ()V - public final fun getLambda-1$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-10$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-11$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-2$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-3$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-4$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-5$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-6$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-7$composeApp_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-8$composeApp_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-9$composeApp_release ()Lkotlin/jvm/functions/Function3; -} - -public final class com/mmk/kmpauth/sample/ComposableSingletons$MainActivityKt { - public static final field INSTANCE Lcom/mmk/kmpauth/sample/ComposableSingletons$MainActivityKt; - public static field lambda-1 Lkotlin/jvm/functions/Function2; - public fun ()V - public final fun getLambda-1$composeApp_release ()Lkotlin/jvm/functions/Function2; -} - -public final class com/mmk/kmpauth/sample/MainActivity : androidx/activity/ComponentActivity { - public static final field $stable I - public fun ()V -} - -public final class com/mmk/kmpauth/sample/MainActivityKt { - public static final fun AppAndroidPreview (Landroidx/compose/runtime/Composer;I)V -} - -public final class com/mmk/kmpauth/sample/MainApplication : android/app/Application { - public static final field $stable I - public fun ()V - public fun onCreate ()V -} - -public final class com/mmk/kmpauth/sample/Platform_androidKt { - public static final fun onApplicationStartPlatformSpecific ()V -} - diff --git a/sampleApp/composeApp/api/desktop/composeApp.api b/sampleApp/composeApp/api/desktop/composeApp.api index e542adb..cac9012 100644 --- a/sampleApp/composeApp/api/desktop/composeApp.api +++ b/sampleApp/composeApp/api/desktop/composeApp.api @@ -41,9 +41,11 @@ public final class com/mmk/kmpauth/sample/ComposableSingletons$MainKt { public static final field INSTANCE Lcom/mmk/kmpauth/sample/ComposableSingletons$MainKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function3; public fun ()V public final fun getLambda-1$composeApp ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$composeApp ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$composeApp ()Lkotlin/jvm/functions/Function3; } public final class com/mmk/kmpauth/sample/MainKt { diff --git a/sampleApp/composeApp/src/desktopMain/kotlin/com/mmk/kmpauth/sample/Main.kt b/sampleApp/composeApp/src/desktopMain/kotlin/com/mmk/kmpauth/sample/Main.kt index 616600e..dcc01a3 100644 --- a/sampleApp/composeApp/src/desktopMain/kotlin/com/mmk/kmpauth/sample/Main.kt +++ b/sampleApp/composeApp/src/desktopMain/kotlin/com/mmk/kmpauth/sample/Main.kt @@ -5,18 +5,22 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.mmk.kmpauth.firebase.apple.AppleButtonUiContainer -import com.mmk.kmpauth.firebase.google.GoogleButtonUiContainerFirebase -import com.mmk.kmpauth.uihelper.apple.AppleSignInButton -import com.mmk.kmpauth.uihelper.apple.AppleSignInButtonIconOnly +import com.mmk.kmpauth.google.GoogleButtonUiContainer import com.mmk.kmpauth.uihelper.google.GoogleSignInButton -import com.mmk.kmpauth.uihelper.google.GoogleSignInButtonIconOnly fun main() = application { AppInitializer.onApplicationStart() @@ -26,12 +30,29 @@ fun main() = application { ) { println("Desktop app is started") // App() - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp)) { - GoogleSignInButton(modifier = Modifier.fillMaxWidth().height(44.dp), fontSize = 19.sp) { } - AppleSignInButton(modifier = Modifier.fillMaxWidth().height(44.dp)) { } - GoogleSignInButtonIconOnly(onClick = { }) - AppleSignInButtonIconOnly(onClick = { }) + Column( + Modifier.fillMaxSize().padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically) + ) { + + var signedInUserName: String by remember { mutableStateOf("") } + Text( + text = signedInUserName, + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Start, + ) + + //Google Sign-In with Custom Button and authentication without Firebase + GoogleButtonUiContainer(onGoogleSignInResult = { googleUser -> + val idToken = googleUser?.idToken // Send this idToken to your backend to verify + signedInUserName = googleUser?.displayName ?: "Null User" + }) { + GoogleSignInButton(modifier = Modifier.fillMaxWidth().height(44.dp), fontSize = 19.sp) { this.onClick() } + } + + } }