Support Fastmail OAuth (#1509)

* Add Fastmail OAuth login implementation

* [CI] Run tests on API level 36, too

* Add Fastmail OAuth login support

* Remove logging and move companion object to bottom

* Remove FastmailLogin and GoogleLogin to OAuthLogin and OAuthGoogle

- Remove FastmailLogin class
- Refactor GoogleLogin class to OAuthGoogle object
- Update AndroidManifest.xml to use ${applicationId} for OAuth redirect URI
- Add OAuthFastmail object for Fastmail OAuth integration
- Update GoogleLoginModel and FastmailLoginModel to use OAuthGoogle and OAuthFastmail respectively
- Add OAuthIntegration object for shared OAuth functionality

* Update Fastmail authentication error message and add redirect URI documentation

* Add error handling for refresh token exception
This commit is contained in:
Ricki Hirner
2025-06-05 11:54:14 +02:00
committed by GitHub
parent fa50fe4c30
commit de8c1d160d
12 changed files with 471 additions and 107 deletions

View File

@@ -1,91 +0,0 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenResponse
import java.net.URI
import java.util.logging.Logger
class GoogleLogin(
val authService: AuthorizationService
) {
private val logger: Logger = Logger.getGlobal()
companion object {
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
private val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun googleBaseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
"https://accounts.google.com/o/oauth2/v2/auth".toUri(),
"https://oauth2.googleapis.com/token".toUri()
)
}
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
suspend fun authenticate(authResponse: AuthorizationResponse): Credentials {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
logger.info("Refresh token response: ${tokenResponse?.jsonSerializeString()}")
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
}
}
}
return credentials.await()
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import java.net.URI
object OAuthFastmail {
// DAVx5 Client ID (issued by Fastmail)
private const val CLIENT_ID = "34ce41ae"
private val SCOPES = arrayOf(
"https://www.fastmail.com/dev/protocol-caldav", // CalDAV
"https://www.fastmail.com/dev/protocol-carddav" // CardDAV
)
/**
* The base URL for Fastmail. Note that this URL is used for both CalDAV and CardDAV;
* the SRV records of the domain are checked to determine the respective service base URL.
*/
val baseUri: URI = URI.create("https://fastmail.com/")
private val serviceConfig = AuthorizationServiceConfiguration(
"https://api.fastmail.com/oauth/authorize".toUri(),
"https://api.fastmail.com/oauth/refresh".toUri()
)
fun signIn(email: String, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
ResponseTypeValues.CODE,
OAuthIntegration.redirectUri
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import java.net.URI
object OAuthGoogle {
// davx5integration@gmail.com (for davx5-ose)
private const val CLIENT_ID = "1069050168830-eg09u4tk1cmboobevhm4k3bj1m4fav9i.apps.googleusercontent.com"
private val SCOPES = arrayOf(
"https://www.googleapis.com/auth/calendar", // CalDAV
"https://www.googleapis.com/auth/carddav" // CardDAV
)
/**
* Gets the Google CalDAV/CardDAV base URI. See https://developers.google.com/calendar/caldav/v2/guide;
* _calid_ of the primary calendar is the account name.
*
* This URL allows CardDAV (over well-known URLs) and CalDAV detection including calendar-homesets and secondary
* calendars.
*/
fun baseUri(googleAccount: String): URI =
URI("https", "apidata.googleusercontent.com", "/caldav/v2/$googleAccount/user", null)
private val serviceConfig = AuthorizationServiceConfiguration(
"https://accounts.google.com/o/oauth2/v2/auth".toUri(),
"https://oauth2.googleapis.com/token".toUri()
)
fun signIn(email: String, customClientId: String?, locale: String?): AuthorizationRequest {
val builder = AuthorizationRequest.Builder(
serviceConfig,
customClientId ?: CLIENT_ID,
ResponseTypeValues.CODE,
OAuthIntegration.redirectUri
)
return builder
.setScopes(*SCOPES)
.setLoginHint(email)
.setUiLocales(locale)
.build()
}
}

View File

@@ -0,0 +1,50 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import androidx.core.net.toUri
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.db.Credentials
import at.bitfire.davdroid.network.OAuthIntegration.redirectUri
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.TokenResponse
/**
* Integration with OpenID AppAuth (Android)
*/
object OAuthIntegration {
/** redirect URI, must be registered in Manifest */
val redirectUri =
(BuildConfig.APPLICATION_ID + ":/oauth2/redirect").toUri()
/**
* Called by the authorization service when the login is finished and [redirectUri] is launched.
*/
suspend fun authenticate(authService: AuthorizationService, authResponse: AuthorizationResponse): Credentials {
val authState = AuthState(authResponse, null) // authorization code must not be stored; exchange it to refresh token
val credentials = CompletableDeferred<Credentials>()
withContext(Dispatchers.IO) {
authService.performTokenRequest(authResponse.createTokenExchangeRequest()) { tokenResponse: TokenResponse?, refreshTokenException: AuthorizationException? ->
if (tokenResponse != null) {
// success, save authState (= refresh token)
authState.update(tokenResponse, refreshTokenException)
credentials.complete(Credentials(authState = authState))
} else if (refreshTokenException != null)
credentials.completeExceptionally(refreshTokenException)
}
}
return credentials.await()
}
}

View File

@@ -0,0 +1,184 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.ActivityNotFoundException
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Email
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import at.bitfire.davdroid.Constants
import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import java.util.logging.Level
import java.util.logging.Logger
object FastmailLogin : LoginType {
override val title: Int
get() = R.string.login_fastmail
override val helpUrl: Uri
get() = Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("fastmail")
.withStatParams("LoginTypeFastmail")
.build()
@Composable
override fun LoginScreen(
snackbarHostState: SnackbarHostState,
initialLoginInfo: LoginInfo,
onLogin: (LoginInfo) -> Unit
) {
val model: FastmailLoginModel = hiltViewModel(
creationCallback = { factory: FastmailLoginModel.Factory ->
factory.create(loginInfo = initialLoginInfo)
}
)
val uiState = model.uiState
LaunchedEffect(uiState.result) {
if (uiState.result != null) {
onLogin(uiState.result)
model.resetResult()
}
}
LaunchedEffect(uiState.error) {
if (uiState.error != null)
snackbarHostState.showSnackbar(uiState.error)
}
// contract to open the browser for authentication
val authRequestContract = rememberLauncherForActivityResult(contract = model.AuthorizationContract()) { authResponse ->
if (authResponse != null)
model.authenticate(authResponse)
else
model.authCodeFailed()
}
FastmailLoginScreen(
email = uiState.email,
onSetEmail = model::setEmail,
canContinue = uiState.canContinue,
onLogin = {
if (uiState.canContinue) {
val authRequest = model.signIn()
try {
authRequestContract.launch(authRequest)
} catch (e: ActivityNotFoundException) {
Logger.getGlobal().log(Level.WARNING, "Couldn't start OAuth intent", e)
model.signInFailed()
}
}
}
)
}
}
@Composable
fun FastmailLoginScreen(
email: String,
onSetEmail: (String) -> Unit = {},
canContinue: Boolean,
onLogin: () -> Unit = {}
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
Column(
Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
Text(
stringResource(R.string.login_fastmail),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
email,
singleLine = true,
onValueChange = onSetEmail,
leadingIcon = {
Icon(Icons.Default.Email, null)
},
label = { Text(stringResource(R.string.login_fastmail_account)) },
placeholder = { Text("example@fastmail.com") },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next
),
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
if (email.isEmpty())
focusRequester.requestFocus()
}
Button(
enabled = canContinue,
onClick = { onLogin() },
modifier = Modifier
.padding(top = 8.dp)
.wrapContentSize()
) {
Text(stringResource(R.string.login_fastmail_sign_in))
}
}
}
@Composable
@Preview(showBackground = true)
fun FastmailLoginScreen_Preview_Empty() {
FastmailLoginScreen(
email = "",
canContinue = false
)
}
@Composable
@Preview(showBackground = true)
fun FastmailLoginScreen_Preview_WithDefaultEmail() {
FastmailLoginScreen(
email = "example@gmail.com",
canContinue = true
)
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.ui.setup
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.OAuthFastmail
import at.bitfire.davdroid.network.OAuthIntegration
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import java.util.Locale
import java.util.logging.Level
import java.util.logging.Logger
@HiltViewModel(assistedFactory = FastmailLoginModel.Factory::class)
class FastmailLoginModel @AssistedInject constructor(
@Assisted val initialLoginInfo: LoginInfo,
private val authService: AuthorizationService,
@ApplicationContext val context: Context,
private val logger: Logger
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(loginInfo: LoginInfo): FastmailLoginModel
}
override fun onCleared() {
authService.dispose()
}
data class UiState(
val email: String = "",
val error: String? = null,
/** login info (set after successful login) */
val result: LoginInfo? = null
) {
val canContinue = email.isNotEmpty()
val emailWithDomain = if (email.contains("@")) email else "$email@fastmail.com"
}
var uiState by mutableStateOf(UiState())
private set
init {
uiState = uiState.copy(
email = initialLoginInfo.credentials?.username ?: "",
error = null,
result = null
)
}
fun setEmail(email: String) {
uiState = uiState.copy(email = email)
}
fun signIn() =
OAuthFastmail.signIn(
email = uiState.emailWithDomain,
locale = Locale.getDefault().toLanguageTag()
)
fun signInFailed() {
uiState = uiState.copy(error = context.getString(R.string.install_browser))
}
fun authenticate(authResponse: AuthorizationResponse) {
viewModelScope.launch {
try {
val credentials = OAuthIntegration.authenticate(authService, authResponse)
// success, provide login info to continue
uiState = uiState.copy(
result = LoginInfo(
baseUri = OAuthFastmail.baseUri,
credentials = credentials,
suggestedAccountName = uiState.emailWithDomain
)
)
} catch (e: Exception) {
logger.log(Level.WARNING, "Fastmail authentication failed", e)
uiState = uiState.copy(error = e.message)
}
}
}
fun authCodeFailed() {
uiState = uiState.copy(error = context.getString(R.string.login_oauth_couldnt_obtain_auth_code))
}
fun resetResult() {
uiState = uiState.copy(result = null)
}
inner class AuthorizationContract() : ActivityResultContract<AuthorizationRequest, AuthorizationResponse?>() {
override fun createIntent(context: Context, input: AuthorizationRequest) =
authService.getAuthorizationRequestIntent(input)
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResponse? =
intent?.let { AuthorizationResponse.fromIntent(it) }
}
}

View File

@@ -50,6 +50,7 @@ import at.bitfire.davdroid.Constants.withStatParams
import at.bitfire.davdroid.R
import at.bitfire.davdroid.ui.UiUtils.toAnnotatedString
import at.bitfire.davdroid.ui.setup.GoogleLogin.GOOGLE_POLICY_URL
import at.bitfire.davdroid.ui.setup.GoogleLogin.helpUrl
import java.util.logging.Level
import java.util.logging.Logger
@@ -70,13 +71,6 @@ object GoogleLogin : LoginType {
const val GOOGLE_POLICY_URL =
"https://developers.google.com/terms/api-services-user-data-policy#additional_requirements_for_specific_api_scopes"
// Support site
val URI_TESTED_WITH_GOOGLE: Uri =
Constants.HOMEPAGE_URL.buildUpon()
.appendPath(Constants.HOMEPAGE_PATH_TESTED_SERVICES)
.appendPath("google")
.build()
@Composable
override fun LoginScreen(
@@ -171,7 +165,7 @@ fun GoogleLoginScreen(
)
Button(
onClick = {
uriHandler.openUri(GoogleLogin.URI_TESTED_WITH_GOOGLE.toString())
uriHandler.openUri(helpUrl.toString())
},
colors = ButtonDefaults.outlinedButtonColors(),
modifier = Modifier.wrapContentSize()

View File

@@ -14,7 +14,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.davdroid.R
import at.bitfire.davdroid.network.GoogleLogin
import at.bitfire.davdroid.network.OAuthGoogle
import at.bitfire.davdroid.network.OAuthIntegration
import at.bitfire.davdroid.util.trimToNull
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@@ -42,8 +43,6 @@ class GoogleLoginModel @AssistedInject constructor(
fun create(loginInfo: LoginInfo): GoogleLoginModel
}
val googleLogin = GoogleLogin(authService)
override fun onCleared() {
authService.dispose()
}
@@ -81,7 +80,7 @@ class GoogleLoginModel @AssistedInject constructor(
}
fun signIn() =
googleLogin.signIn(
OAuthGoogle.signIn(
email = uiState.emailWithDomain,
customClientId = uiState.customClientId.trimToNull(),
locale = Locale.getDefault().toLanguageTag()
@@ -94,12 +93,12 @@ class GoogleLoginModel @AssistedInject constructor(
fun authenticate(authResponse: AuthorizationResponse) {
viewModelScope.launch {
try {
val credentials = googleLogin.authenticate(authResponse)
val credentials = OAuthIntegration.authenticate(authService, authResponse)
// success, provide login info to continue
uiState = uiState.copy(
result = LoginInfo(
baseUri = GoogleLogin.googleBaseUri(uiState.emailWithDomain),
baseUri = OAuthGoogle.baseUri(uiState.emailWithDomain),
credentials = credentials,
suggestedAccountName = uiState.emailWithDomain
)

View File

@@ -302,6 +302,9 @@
<string name="login_client_certificate_selected">Client certificate: %s</string>
<string name="login_no_certificate_found">No certificate found</string>
<string name="login_install_certificate">Install certificate</string>
<string name="login_fastmail">Fastmail</string>
<string name="login_fastmail_account">Fastmail account</string>
<string name="login_fastmail_sign_in">Sign in with Fastmail</string>
<string name="login_type_google">Google Contacts / Calendar</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>

View File

@@ -17,7 +17,7 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data
tools:ignore="AppLinkUrlError"
android:scheme="at.bitfire.davdroid"
android:scheme="${applicationId}"
android:path="/oauth2/redirect"/>
</intent-filter>
</activity>

View File

@@ -30,7 +30,7 @@ fun StandardLoginTypePage(
selectedLoginType: LoginType,
onSelectLoginType: (LoginType) -> Unit,
@Suppress("UNUSED_PARAMETER") // for build variants
@Suppress("unused") // for build variants
setInitialLoginInfo: (LoginInfo) -> Unit,
onContinue: () -> Unit = {}

View File

@@ -23,6 +23,7 @@ class StandardLoginTypesProvider @Inject constructor(
)
val specificLoginTypes = listOf(
FastmailLogin,
GoogleLogin,
NextcloudLogin
)