Skip to content

Commit

Permalink
Plug Google user authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
opatry committed Sep 30, 2024
1 parent cb4b6f9 commit a07dff3
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ local.properties
.kotlin/sessions/

tasks-app-android/google-services.json

*token_cache.*
client_secret_*.apps.googleusercontent.com.json
Binary file not shown.
10 changes: 10 additions & 0 deletions _ci/decrypt_secrets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,13 @@ origin=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) || exit
cd "${origin}"
./decrypt_file.sh "${origin}/google-services.json.gpg" \
"${origin}/../tasks-app-android/google-services.json"

mkdir -p "${origin}/../tasks-app-shared/src/jvmMain/composeResources/files"
./decrypt_file.sh "${origin}/client_secret_191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com.json.gpg" \
"${origin}/../tasks-app-shared/src/jvmMain/composeResources/files/client_secret_191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com.json"

# for now Android & desktop apps use the same GCP Web app credentials, kept split/duplicated in their own source set to ease changing strategy
# it's the same for `store` & `dev` flavors for now, keep in `src/main/assets` but could be dup again in `src/store/assets` & `src/dev/assets` respectively
mkdir -p "${origin}/../tasks-app-android/src/main/assets"
cp "${origin}/../tasks-app-shared/src/jvmMain/composeResources/files/client_secret_191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com.json" \
"${origin}/../tasks-app-android/src/main/assets"
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ androidx-activity-compose = { module = "androidx.activity:activity-compose", ver
firebase-bom = { module = "com.google.firebase:firebase-bom", version = "33.3.0" }
firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }

play-services-auth = { module = "com.google.android.gms:play-services-auth", version = "21.2.0" }

[bundles]
ktor-server = ["ktor-server-core", "ktor-server-cio"]
ktor-client = [
Expand Down
3 changes: 3 additions & 0 deletions tasks-app-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ dependencies {
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics)

implementation(libs.play.services.auth)

implementation(project(":google:oauth"))
implementation(project(":google:tasks"))
implementation(project(":tasks-app-shared"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@
package net.opatry.tasks.app

import android.app.Application
import net.opatry.tasks.app.di.authModule
import net.opatry.tasks.app.di.dataModule
import net.opatry.tasks.app.di.networkModule
import net.opatry.tasks.app.di.platformModule
import net.opatry.tasks.app.di.tasksAppModule
import org.koin.android.ext.koin.androidContext
import org.koin.androix.startup.KoinStartup.onKoinStartup

private const val GCP_CLIENT_ID = "191682949161-esokhlfh7uugqptqnu3su9vgqmvltv95.apps.googleusercontent.com"

class TasksApplication : Application() {

Expand All @@ -39,6 +42,8 @@ class TasksApplication : Application() {
modules(
platformModule(),
dataModule,
authModule(GCP_CLIENT_ID),
networkModule,
tasksAppModule,
)
}
Expand Down
2 changes: 2 additions & 0 deletions tasks-app-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ kotlin {
implementation(libs.androidx.compose.m3.adaptive)
implementation(libs.androidx.compose.m3.adaptive.layout)
implementation(libs.androidx.compose.m3.adaptive.navigation)

implementation(libs.play.services.auth)
}

commonMain.dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 Olivier Patry
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software
* is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
* OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package net.opatry.tasks.app.auth

import android.content.Context
import com.google.android.gms.auth.api.identity.AuthorizationRequest
import com.google.android.gms.auth.api.identity.Identity
import com.google.android.gms.common.api.Scope
import kotlinx.coroutines.withTimeout
import net.opatry.google.auth.GoogleAuthenticator
import net.opatry.google.auth.HttpGoogleAuthenticator
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Duration.Companion.minutes


class PlayServicesGoogleAuthenticator(
private val context: Context,
private val config: ApplicationConfig
) : HttpGoogleAuthenticator(config) {
override suspend fun authorize(
scopes: List<GoogleAuthenticator.Scope>,
force: Boolean,
requestUserAuthorization: (authorizationRequest: Any) -> Unit
): String {
val authorizationRequest = AuthorizationRequest.builder()
.setRequestedScopes(scopes.map { Scope(it.value) })
.requestOfflineAccess(config.clientId, force)
.build()

return withTimeout(5.minutes) {
suspendCoroutine { continuation ->
Identity.getAuthorizationClient(context)
.authorize(authorizationRequest)
.addOnSuccessListener { result ->
if (result.hasResolution()) {
requestUserAuthorization(result)
continuation.resume("")
} else {
result.serverAuthCode?.let { authCode ->
continuation.resume(authCode)
} ?: run {
continuation.resumeWithException(IllegalStateException("No server auth code"))
}
}
}.addOnFailureListener(continuation::resumeWithException)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,34 @@

package net.opatry.tasks.app.di

import android.content.Context
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import net.opatry.google.auth.GoogleAuth
import net.opatry.google.auth.GoogleAuthenticator
import net.opatry.google.auth.HttpGoogleAuthenticator.ApplicationConfig
import net.opatry.tasks.app.auth.PlayServicesGoogleAuthenticator
import org.koin.dsl.module


@OptIn(ExperimentalSerializationApi::class)
actual fun authModule(gcpClientId: String) = module {
single<GoogleAuthenticator> {
val credentialsFilename = "client_secret_${gcpClientId}.json"
val context = get<Context>()
val credentials = context.assets.open(credentialsFilename).use { inputStream ->
// expects a `web` credentials, if `installed` is needed, inject another way
requireNotNull(Json.decodeFromStream<GoogleAuth>(inputStream).webCredentials)
}

val config = ApplicationConfig(
redirectUrl = credentials.redirectUris.first(),
clientId = credentials.clientId,
clientSecret = credentials.clientSecret,
authUri = credentials.authUri,
tokenUri = credentials.tokenUri,
)
PlayServicesGoogleAuthenticator(get(), config)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,27 @@

package net.opatry.tasks.app.di

import android.content.Context
import androidx.room.Room
import net.opatry.tasks.CredentialsStorage
import net.opatry.tasks.FileCredentialsStorage
import net.opatry.tasks.data.TasksAppDatabase
import org.koin.core.module.Module
import org.koin.dsl.module
import java.io.File

actual fun platformModule(): Module = module {
single {
val context = get<Context>()
val appContext = context.applicationContext
val dbFile = appContext.getDatabasePath("tasks.db")
Room.databaseBuilder<TasksAppDatabase>(appContext, dbFile.absolutePath)
}

single<CredentialsStorage> {
// TODO store in database
val context = get<Context>()
val credentialsFile = File(context.cacheDir, "google_auth_token_cache.json")
FileCredentialsStorage(credentialsFile.absolutePath)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,142 @@

package net.opatry.tasks.app.ui.component

import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.google.android.gms.auth.api.identity.AuthorizationResult
import com.google.android.gms.auth.api.identity.Identity
import kotlinx.coroutines.launch
import net.opatry.google.auth.GoogleAuthenticator
import net.opatry.google.tasks.TasksScopes
import net.opatry.tasks.resources.Res
import net.opatry.tasks.resources.onboarding_screen_authorize_cta
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject


@Composable
actual fun AuthorizeGoogleTasksButton(
modifier: Modifier,
onSuccess: (GoogleAuthenticator.OAuthToken
) -> Unit) {
val coroutineScope = rememberCoroutineScope()

val context = LocalContext.current

val authenticator = koinInject<GoogleAuthenticator>()
var ongoingAuth by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }

val startForResult = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val authResult = Identity.getAuthorizationClient(context).getAuthorizationResultFromIntent(result.data)
val authCode = authResult.serverAuthCode
if (authCode != null) {
coroutineScope.launch {
runAuthFlow(
authenticator,
authCode,
onAuth = {
error = "Unexpectedly requesting auth flow again"
ongoingAuth = false
},
onSuccess = onSuccess,
onError = { e ->
error = e.message
ongoingAuth = false
}
)
}
} else {
error = "No auth code in result"
ongoingAuth = false
}
}
}

Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (ongoingAuth) {
CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 1.dp)
} else {
Spacer(Modifier.size(24.dp))
}
Button(
onClick = {
ongoingAuth = true
coroutineScope.launch {
runAuthFlow(
authenticator,
null,
onAuth = { result ->
val pendingIntent = result.pendingIntent
if (pendingIntent != null) {
startForResult.launch(IntentSenderRequest.Builder(pendingIntent).build())
} else {
error = "No pending intent in auth result"
ongoingAuth = false
}
},
onSuccess = onSuccess,
onError = { e ->
error = e.message
ongoingAuth = false
}
)
}
},
enabled = !ongoingAuth
) {
Text(stringResource(Res.string.onboarding_screen_authorize_cta))
}
}
AnimatedContent(error, label = "authorize_error_message") {
Text(it ?: "")
}
}

suspend fun runAuthFlow(
authenticator: GoogleAuthenticator,
providedAuthCode: String?,
onAuth: (AuthorizationResult) -> Unit,
onSuccess: (GoogleAuthenticator.OAuthToken) -> Unit,
onError: (Exception) -> Unit,
) {
val scope = listOf(
GoogleAuthenticator.Scope.Profile,
GoogleAuthenticator.Scope(TasksScopes.Tasks),
)

try {
val authCode = providedAuthCode ?: authenticator.authorize(scope, true) {
val result = it as AuthorizationResult
onAuth(result)
}
if (authCode.isNotEmpty()) {
val grant = GoogleAuthenticator.Grant.AuthorizationCode(authCode)
val oauthToken = authenticator.getToken(grant)
onSuccess(oauthToken)
}
} catch (e: Exception) {
onError(e)
}
}

0 comments on commit a07dff3

Please sign in to comment.