Skip to content

Commit

Permalink
Adding Google Sign In custom scopes (#84)
Browse files Browse the repository at this point in the history
* Adding scopes field to sign in method

* Implementing custom scope in JVM

* ios added CADisableMinimumFrameDurationOnPhone

* Ios add custom scopes

* Desktop fix google external scopes

* Adding serverAuthCode field to GoogleUser

* Adding serverAuthCode field to GoogleUser in iosMain

* Adding custom scopes to android side
  • Loading branch information
mirzemehdi authored Jan 15, 2025
1 parent b5898a7 commit 0b05328
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ internal class GoogleAuthUiProviderImpl(
private val googleLegacyAuthentication: GoogleLegacyAuthentication,
) :
GoogleAuthUiProvider {
override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? {
return try {
override suspend fun signIn(
filterByAuthorizedAccounts: Boolean,
scopes: List<String>
): GoogleUser? {

val googleUser = try {
// Temporary solution until to find out requesting additional scopes with Credential Manager.
if (scopes != GoogleAuthUiProvider.BASIC_AUTH_SCOPE) throw GetCredentialProviderConfigurationException() //Will open Legacy Sign In

getGoogleUserFromCredential(filterByAuthorizedAccounts = filterByAuthorizedAccounts)
} catch (e: NoCredentialException) {
if (!filterByAuthorizedAccounts) return handleCredentialException(e)
Expand All @@ -38,6 +45,7 @@ internal class GoogleAuthUiProviderImpl(
} catch (e: NullPointerException) {
null
}
return googleUser
}

private suspend fun handleCredentialException(e: GetCredentialException): GoogleUser? {
Expand Down Expand Up @@ -98,7 +106,10 @@ internal class GoogleAuthUiProviderImpl(
.build()
}

private fun getGoogleIdOption(serverClientId: String, filterByAuthorizedAccounts: Boolean): GetGoogleIdOption {
private fun getGoogleIdOption(
serverClientId: String,
filterByAuthorizedAccounts: Boolean
): GetGoogleIdOption {
return GetGoogleIdOption.Builder()
.setFilterByAuthorizedAccounts(filterByAuthorizedAccounts)
.setAutoSelectEnabled(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.auth.api.signin.GoogleSignInClient
import com.google.android.gms.auth.api.signin.GoogleSignInOptions
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.common.api.Scope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import java.util.concurrent.atomic.AtomicReference


internal class GoogleLegacyAuthentication(
Expand All @@ -25,18 +24,20 @@ internal class GoogleLegacyAuthentication(

) : GoogleAuthUiProvider {

override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? {
val signInClient = getGoogleSignInClient().signInIntent
override suspend fun signIn(
filterByAuthorizedAccounts: Boolean,
scopes: List<String>
): GoogleUser? {
val signInClient = getGoogleSignInClient(scopes = scopes).signInIntent
activityResultState.isInProgress = true
try {
activityResultLauncher.launch(signInClient)
}
catch (e: ActivityNotFoundException){
} catch (e: ActivityNotFoundException) {
println(e.message)
return null
}

withContext(Dispatchers.Default){
withContext(Dispatchers.Default) {
while (activityResultState.isInProgress) yield()
}
val data: Intent? = activityResultState.data?.data
Expand All @@ -52,6 +53,7 @@ internal class GoogleLegacyAuthentication(
GoogleUser(
idToken = idToken,
accessToken = null,
serverAuthCode = account.serverAuthCode,
email = account.email,
displayName = account.displayName ?: "",
profilePicUrl = account.photoUrl?.toString()
Expand All @@ -66,15 +68,25 @@ internal class GoogleLegacyAuthentication(
}
}

private fun getGoogleSignInOptions(): GoogleSignInOptions {
return GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
private fun getGoogleSignInOptions(scopes: List<String>): GoogleSignInOptions {
val builder = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
.requestIdToken(credentials.serverId)
.requestEmail()
.build()


if (scopes != GoogleAuthUiProvider.BASIC_AUTH_SCOPE) {
scopes.forEach { scope ->
builder.requestScopes(Scope(scope))
}
builder.requestServerAuthCode(credentials.serverId)
}


return builder.build()
}

private fun getGoogleSignInClient(): GoogleSignInClient {
return GoogleSignIn.getClient(activityContext, getGoogleSignInOptions())
private fun getGoogleSignInClient(scopes: List<String>): GoogleSignInClient {
return GoogleSignIn.getClient(activityContext, getGoogleSignInOptions(scopes))
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,36 @@ package com.mmk.kmpauth.google
*/
public interface GoogleAuthUiProvider {

public companion object {
internal val BASIC_AUTH_SCOPE = listOf("email", "profile")
}

/**
* Opens Sign In with Google UI, and returns [GoogleUser]
* if sign-in was successful, otherwise, null
* By default all available accounts are listed to choose from
* @see signIn(filterByAuthorizedAccounts: Boolean)
* @return returns GoogleUser or null(if sign-in was not successful)
*/
public suspend fun signIn(): GoogleUser? = signIn(filterByAuthorizedAccounts = false)
public suspend fun signIn(): GoogleUser? =
signIn(filterByAuthorizedAccounts = false, scopes = BASIC_AUTH_SCOPE)

/**
* @param filterByAuthorizedAccounts set to true so users can choose between available accounts to sign in.
* setting to false list any accounts that have previously been used to sign in to your app.
*/
public suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser?
public suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? =
signIn(filterByAuthorizedAccounts = filterByAuthorizedAccounts, scopes = BASIC_AUTH_SCOPE)


/**
* @param filterByAuthorizedAccounts set to true so users can choose between available accounts to sign in.
* setting to false list any accounts that have previously been used to sign in to your app. Default value is false.
* @param scopes Custom scopes to retrieve more information. Default value listOf("email", "profile")
*
*/
public suspend fun signIn(
filterByAuthorizedAccounts: Boolean = false,
scopes: List<String> = BASIC_AUTH_SCOPE
): GoogleUser?
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ package com.mmk.kmpauth.google
*/
public data class GoogleUser(
val idToken: String,
val accessToken:String? = null,
val accessToken: String? = null,
val email: String? = null,
val displayName: String = "",
val profilePicUrl: String? = null,
val serverAuthCode: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,18 @@ import kotlin.coroutines.suspendCoroutine

internal class GoogleAuthUiProviderImpl : GoogleAuthUiProvider {
@OptIn(ExperimentalForeignApi::class)
override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? = suspendCoroutine { continutation ->
override suspend fun signIn(
filterByAuthorizedAccounts: Boolean,
scopes: List<String>
): GoogleUser? = suspendCoroutine { continutation ->

val rootViewController =
UIApplication.sharedApplication.keyWindow?.rootViewController

if (rootViewController == null) continutation.resume(null)
else {
GIDSignIn.sharedInstance
.signInWithPresentingViewController(rootViewController) { gidSignInResult, nsError ->
.signInWithPresentingViewController(rootViewController,null, scopes) { gidSignInResult, nsError ->
nsError?.let { println("Error While signing: $nsError") }

val user = gidSignInResult?.user
Expand All @@ -28,6 +31,7 @@ internal class GoogleAuthUiProviderImpl : GoogleAuthUiProvider {
idToken = idToken,
accessToken = accessToken,
email = profile?.email,
serverAuthCode = gidSignInResult.serverAuthCode,
displayName = profile?.name ?: "",
profilePicUrl = profile?.imageURLWithDimension(320u)?.absoluteString
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.mmk.kmpauth.google

internal class GoogleAuthUiProviderImpl : GoogleAuthUiProvider {
override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? {
override suspend fun signIn(
filterByAuthorizedAccounts: Boolean,
scopes: List<String>
): GoogleUser? {
TODO("Not yet implemented")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,25 @@ internal class GoogleAuthUiProviderImpl(private val credentials: GoogleAuthCrede

private val authUrl = "https://accounts.google.com/o/oauth2/v2/auth"

override suspend fun signIn(filterByAuthorizedAccounts: Boolean): GoogleUser? {
val scope = "email profile"
override suspend fun signIn(
filterByAuthorizedAccounts: Boolean,
scopes: List<String>
): GoogleUser? {
val responseType = "id_token token"
val scopeString = scopes.joinToString(" ")
val redirectUri = "http://localhost:8080/callback"
val state: String
val nonce: String
var nonce: String?
val googleAuthUrl = withContext(Dispatchers.IO) {
val encodedResponseType =
URLEncoder.encode(responseType, StandardCharsets.UTF_8.toString())
state = URLEncoder.encode(generateRandomString(), StandardCharsets.UTF_8.toString())
val encodedScope = URLEncoder.encode(scope, StandardCharsets.UTF_8.toString())
val encodedScope = URLEncoder.encode(scopeString, StandardCharsets.UTF_8.toString())
nonce = URLEncoder.encode(generateRandomString(), StandardCharsets.UTF_8.toString())

"$authUrl?" +
"client_id=${credentials.serverId}" +
"&redirect_uri=$redirectUri" +
"&response_type=id_token" +
"&response_type=$encodedResponseType" +
"&scope=$encodedScope" +
"&nonce=$nonce" +
"&state=$state"
Expand All @@ -48,45 +53,52 @@ internal class GoogleAuthUiProviderImpl(private val credentials: GoogleAuthCrede

openUrlInBrowser(googleAuthUrl)

val idToken = startHttpServerAndGetToken(state = state)
if (idToken == null) {
println("GoogleAuthUiProvider: idToken is null")
val (idToken, accessToken) = startHttpServerAndGetToken(state = state)
if (idToken == null && accessToken == null) {
println("GoogleAuthUiProvider: token is null")
return null
}
val jwt = JWT().decodeJwt(idToken)
val email = jwt.getClaim("email")?.asString()
val name = jwt.getClaim("name")?.asString() // User's name
val picture = jwt.getClaim("picture")?.asString()
val receivedNonce = jwt.getClaim("nonce")?.asString()


val jwt = idToken?.let { JWT().decodeJwt(it) }
val email = jwt?.getClaim("email")?.asString()
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,
idToken = idToken ?: "",
accessToken = accessToken,
email = email,
displayName = name ?: "",
profilePicUrl = picture
)
}

//Pair, first one is idToken, second one is accessToken
private suspend fun startHttpServerAndGetToken(
redirectUriPath: String = "/callback",
state: String
): String? {
val idTokenDeferred = CompletableDeferred<String?>()
): Pair<String?, String?> {
val tokenPairDeferred = CompletableDeferred<Pair<String?, String?>>()

val jsCode = """
var fragment = window.location.hash;
if (fragment) {
var params = new URLSearchParams(fragment.substring(1));
var idToken = params.get('id_token');
var accessToken = params.get('access_token');
var receivedState = params.get('state');
var expectedState = '${state}';
if (receivedState === expectedState) {
window.location.href = '$redirectUriPath/token?id_token=' + idToken;
window.location.href = '$redirectUriPath/token?' +
(idToken ? 'id_token=' + idToken : '') +
(idToken && accessToken ? '&' : '') +
(accessToken ? 'access_token=' + accessToken : '');
} else {
console.error('State does not match! Possible CSRF attack.');
window.location.href = '$redirectUriPath/token?id_token=null';
Expand All @@ -103,26 +115,27 @@ internal class GoogleAuthUiProviderImpl(private val credentials: GoogleAuthCrede
}
get("$redirectUriPath/token") {
val idToken = call.request.queryParameters["id_token"]
if (idToken.isNullOrEmpty().not()) {
val accessToken = call.request.queryParameters["access_token"]
if (idToken.isNullOrEmpty().not() || accessToken.isNullOrEmpty().not()) {
call.respondText(
"Authorization is complete. You can close this window, and return to the application",
contentType = ContentType.Text.Plain
)
idTokenDeferred.complete(idToken)
tokenPairDeferred.complete(Pair(idToken, accessToken))
} else {
call.respondText(
"Authorization failed",
contentType = ContentType.Text.Plain
)
idTokenDeferred.complete(null)
tokenPairDeferred.complete(Pair(null, null))
}
}
}
}.start(wait = false)

val idToken = idTokenDeferred.await()
val idTokenAndAccessTokenPair = tokenPairDeferred.await()
server.stop(1000, 1000)
return idToken
return idTokenAndAccessTokenPair
}

private fun openUrlInBrowser(url: String) {
Expand Down
2 changes: 2 additions & 0 deletions sampleApp/iosApp/iosApp/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>

0 comments on commit 0b05328

Please sign in to comment.