Skip to content

Commit

Permalink
Implementing Google Sign In in Desktop (#59)
Browse files Browse the repository at this point in the history
* Adding ktor

* Google Authentication main logic in desktop
  • Loading branch information
mirzemehdi authored Oct 18, 2024
1 parent 9951e5d commit f4d813a
Show file tree
Hide file tree
Showing 12 changed files with 245 additions and 84 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
15 changes: 15 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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" }
Expand All @@ -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" }
22 changes: 20 additions & 2 deletions kmpauth-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinNativeCocoaPods)
alias(libs.plugins.kotlinx.serialization)

}

kotlin {
Expand Down Expand Up @@ -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)
}

}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +25,7 @@ public object LibDependencyInitializer {
public fun initialize(modules: List<Module> = emptyList()) {
if (isInitialized()) return
val configModule = module {
single { HttpClientFactory.default() }
includes(modules)
}
koinApp = koinApplication {
Expand All @@ -40,6 +42,6 @@ public object LibDependencyInitializer {
}

private fun Koin.onLibraryInitialized() {
println("Library is initialized")
println("KMPAuth Library is initialized")
}

Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import org.koin.dsl.module

@KMPAuthInternalApi
public actual fun isAndroidPlatform(): Boolean = false
internal actual val platformModule: Module = module { }
internal actual val platformModule: Module = module { }

7 changes: 7 additions & 0 deletions kmpauth-google/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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<String?>()

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)
}


}
65 changes: 0 additions & 65 deletions sampleApp/composeApp/api/composeApp.api

This file was deleted.

Loading

0 comments on commit f4d813a

Please sign in to comment.