[Google OAuth] Support custom Client IDs (bitfireAT/davx5#294)

* Support custom Client IDs

* Refactoring

---------

Co-authored-by: Ricki Hirner <hirner@bitfire.at>
This commit is contained in:
Sunik Kupfer 2023-06-13 18:01:35 +02:00 committed by Ricki Hirner
parent ff4af11bcf
commit ba36d01e11
5 changed files with 156 additions and 79 deletions

View file

@ -4,11 +4,9 @@
package at.bitfire.davdroid.network
import android.content.Context
import android.net.Uri
import at.bitfire.davdroid.BuildConfig
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
@ -27,14 +25,12 @@ object GoogleOAuth {
Uri.parse("https://oauth2.googleapis.com/token")
)
fun authRequestBuilder() =
fun authRequestBuilder(clientId: String?) =
AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
clientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse(BuildConfig.APPLICATION_ID + ":/oauth/redirect")
)
fun createAuthService(context: Context) = AuthorizationService(context)
}

View file

@ -47,6 +47,7 @@ class HttpClient private constructor(
@EntryPoint
@InstallIn(SingletonComponent::class)
interface HttpClientEntryPoint {
fun authorizationService(): AuthorizationService
fun settingsManager(): SettingsManager
}
@ -176,7 +177,7 @@ class HttpClient private constructor(
certificateAlias = credentials.certificateAlias
credentials.authState?.let { authState ->
val newAuthService = GoogleOAuth.createAuthService(context)
val newAuthService = EntryPointAccessors.fromApplication(context, HttpClientEntryPoint::class.java).authorizationService()
authService = newAuthService
BearerAuthInterceptor.fromAuthState(newAuthService, authState, authStateCallback)?.let { bearerAuthInterceptor ->
orig.addNetworkInterceptor(bearerAuthInterceptor)

View file

@ -0,0 +1,22 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import net.openid.appauth.AuthorizationService
@Module
@InstallIn(SingletonComponent::class)
object OAuthModule {
@Provides
fun authorizationService(@ApplicationContext context: Context): AuthorizationService = AuthorizationService(context)
}

View file

@ -13,7 +13,9 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
@ -26,11 +28,14 @@ import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
@ -47,20 +52,30 @@ import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.network.GoogleOAuth
import at.bitfire.davdroid.ui.UiUtils
import com.google.accompanist.themeadapter.material.MdcTheme
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.TokenResponse
import org.apache.commons.lang3.StringUtils
import java.net.URI
import javax.inject.Inject
@AndroidEntryPoint
class GoogleLoginFragment: Fragment() {
companion object {
val URI_TESTED_WITH_GOOGLE: Uri = Uri.parse("https://www.davx5.com/tested-with/google")
fun googleBaseUri(googleAccount: String): URI =
URI("https", "www.google.com", "/calendar/dav/$googleAccount/events/", null)
}
private val loginModel by activityViewModels<LoginModel>()
@ -83,7 +98,15 @@ class GoogleLoginFragment: Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val v = ComposeView(requireActivity())
v.setContent {
GoogleLogin()
GoogleLogin(onLogin = { account, clientId ->
loginModel.baseURI = googleBaseUri(account)
val authRequest = GoogleOAuth.authRequestBuilder(clientId)
.setScopes(*GoogleOAuth.SCOPES)
.setLoginHint(account)
.build()
authRequestContract.launch(authRequest)
})
}
model.credentials.observe(viewLifecycleOwner) { credentials ->
@ -100,77 +123,12 @@ class GoogleLoginFragment: Fragment() {
}
@Composable
@Preview
fun GoogleLogin() {
MdcTheme {
Column(Modifier.padding(8.dp).verticalScroll(rememberScrollState())) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 16.dp))
@HiltViewModel
class Model @Inject constructor(
application: Application,
val authService: AuthorizationService
): AndroidViewModel(application) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Text(
stringResource(R.string.login_google_guide),
modifier = Modifier.padding(vertical = 8.dp))
Button(
onClick = {
UiUtils.launchUri(requireActivity(), Uri.parse("https://www.davx5.com/tested-with/google"))
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
val email = remember { mutableStateOf("") }
val emailError = remember { mutableStateOf<Boolean>(false) }
OutlinedTextField(
email.value,
singleLine = true,
onValueChange = { account ->
email.value = account
loginModel.baseURI = googleBaseUri(account)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
label = { Text(stringResource(R.string.login_google_account)) },
isError = emailError.value,
placeholder = { Text("example@gmail.com") },
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
)
Button({
val valid = email.value.orEmpty().contains('@')
emailError.value = !valid
if (valid) {
val authRequest = GoogleOAuth.authRequestBuilder()
.setScopes(*GoogleOAuth.SCOPES)
.setLoginHint(email.value)
.build()
authRequestContract.launch(authRequest)
}
}, modifier = Modifier.wrapContentSize()) {
Text(stringResource(R.string.login_login))
}
Text(
stringResource(R.string.login_google_disclaimer, getString(R.string.app_name)),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 24.dp))
}
}
}
class Model(application: Application): AndroidViewModel(application) {
val authService = GoogleOAuth.createAuthService(getApplication())
val credentials = MutableLiveData<Credentials>()
fun authenticate(resp: AuthorizationResponse) = viewModelScope.launch(Dispatchers.IO) {
@ -194,4 +152,100 @@ class GoogleLoginFragment: Fragment() {
}
}
@Composable
fun GoogleLogin(
onLogin: (account: String, clientId: String?) -> Unit
) {
val context = LocalContext.current
MdcTheme {
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())) {
Text(
stringResource(R.string.login_type_google),
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(vertical = 16.dp))
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(8.dp)) {
Row {
Image(Icons.Default.Warning, contentDescription = "", modifier = Modifier.padding(top = 8.dp, end = 8.dp, bottom = 8.dp))
Text(stringResource(R.string.login_google_see_tested_with))
}
Text(stringResource(R.string.login_google_unexpected_warnings), modifier = Modifier.padding(vertical = 8.dp))
Button(
onClick = {
UiUtils.launchUri(context, GoogleLoginFragment.URI_TESTED_WITH_GOOGLE)
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()
) {
Text(stringResource(R.string.intro_more_info))
}
}
}
val email = remember { mutableStateOf("") }
val emailError = remember { mutableStateOf(false) }
OutlinedTextField(
email.value,
singleLine = true,
onValueChange = { email.value = it },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
label = { Text(stringResource(R.string.login_google_account)) },
isError = emailError.value,
placeholder = { Text("example@gmail.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
val userClientId = remember { mutableStateOf("") }
val userClientIdError = remember { mutableStateOf(false) }
OutlinedTextField(
userClientId.value,
singleLine = true,
onValueChange = { clientId ->
userClientId.value = clientId
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
label = { Text(stringResource(R.string.login_google_client_id)) },
isError = userClientIdError.value,
placeholder = { Text("[...].apps.googleusercontent.com") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
Button({
val validEmail = email.value.contains('@')
emailError.value = !validEmail
if (validEmail) {
val clientId = StringUtils.trimToNull(userClientId.value.trim())
onLogin(email.value, clientId)
}
}, modifier = Modifier
.padding(top = 8.dp)
.wrapContentSize()) {
Text(stringResource(R.string.login_login))
}
Text(
stringResource(R.string.login_google_disclaimer, stringResource(R.string.app_name)),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 24.dp))
}
}
}
@Composable
@Preview
fun PreviewGoogleLogin() {
GoogleLogin(onLogin = { _, _ -> })
}

View file

@ -293,8 +293,12 @@
<string name="login_no_certificate_found">No certificate found</string>
<string name="login_install_certificate">Install certificate</string>
<string name="login_type_google">Google Contacts / Calendar</string>
<string name="login_google_guide">Please read our guide about the login to Google. There may be unexpected warnings which are explained there.</string>
<string name="login_google_see_tested_with">Please see our \"Tested with Google\" page for up-to-date information.</string>
<string name="login_google_unexpected_warnings">You may experience unexpected warnings and/or have to create your own client ID.</string>
<string name="login_google_account">Google account</string>
<string name="login_google_client_id">Client ID (optional)</string>
<string name="login_google_client_id_description">You may use your own client ID, in case our does not work.</string>
<string name="login_google_client_id_description_link">Show me how!</string>
<string name="login_google_disclaimer">%s is not affiliated to, nor has it been authorized, sponsored or otherwise approved by Google LLC.</string>
<string name="login_configuration_detection">Configuration detection</string>