diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6c74facedea67ae2e092b4ac27472734a418574b..8af528d9931dee02c336edab99cc50ea73d94ddc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,6 +9,9 @@ before_script: - echo MURENA_CLIENT_ID=$MURENA_CLIENT_ID >> local.properties - echo MURENA_CLIENT_SECRET=$MURENA_CLIENT_SECRET >> local.properties - echo MURENA_REDIRECT_URI=$MURENA_REDIRECT_URI >> local.properties + - echo MURENA_LOGOUT_REDIRECT_URI=$MURENA_LOGOUT_REDIRECT_URI >> local.properties + - echo MURENA_BASE_URL=$MURENA_BASE_URL >> local.properties + - echo MURENA_DISCOVERY_END_POINT=$MURENA_DISCOVERY_END_POINT >> local.properties - echo GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID >> local.properties - echo GOOGLE_REDIRECT_URI=$GOOGLE_REDIRECT_URI >> local.properties - echo YAHOO_CLIENT_ID=$YAHOO_CLIENT_ID >> local.properties diff --git a/app/build.gradle b/app/build.gradle index a2e14d695c52cd273d60259a933ab60b4e3be0f0..5ea75c8c48f155178c1175b0778cbc67d0cced02 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -111,6 +111,9 @@ android { buildConfigField "String", "MURENA_CLIENT_ID", "\"${retrieveKey("MURENA_CLIENT_ID")}\"" buildConfigField "String", "MURENA_CLIENT_SECRET", "\"${retrieveKey("MURENA_CLIENT_SECRET")}\"" buildConfigField "String", "MURENA_REDIRECT_URI", "\"${retrieveKey("MURENA_REDIRECT_URI")}\"" + buildConfigField "String", "MURENA_LOGOUT_REDIRECT_URI", "\"${retrieveKey("MURENA_LOGOUT_REDIRECT_URI")}\"" + buildConfigField "String", "MURENA_BASE_URL", "\"${retrieveKey("MURENA_BASE_URL")}\"" + buildConfigField "String", "MURENA_DISCOVERY_END_POINT", "\"${retrieveKey("MURENA_DISCOVERY_END_POINT")}\"" buildConfigField "String", "GOOGLE_CLIENT_ID", "\"${retrieveKey("GOOGLE_CLIENT_ID")}\"" buildConfigField "String", "GOOGLE_REDIRECT_URI", "\"${retrieveKey("GOOGLE_REDIRECT_URI")}\"" @@ -120,7 +123,9 @@ android { manifestPlaceholders = [ 'appAuthRedirectScheme': applicationId, "googleAuthRedirectScheme": retrieveKey("GOOGLE_REDIRECT_URI"), - "murenaAuthRedirectScheme": retrieveKey("MURENA_REDIRECT_URI") + "murenaAuthRedirectScheme": retrieveKey("MURENA_REDIRECT_URI"), + + "murenaAuthLogoutRedirectScheme":retrieveKey("MURENA_LOGOUT_REDIRECT_URI") ] } @@ -219,7 +224,7 @@ dependencies { implementation "commons-httpclient:commons-httpclient:3.1@jar" // remove after entire switch to lib v2 implementation 'org.apache.jackrabbit:jackrabbit-webdav:2.13.5' // remove after entire switch to lib v2 implementation 'com.google.code.gson:gson:2.10.1' - implementation("foundation.e:Nextcloud-Android-Library:1.0.6-release") { + implementation("foundation.e:Nextcloud-Android-Library:1.0.7-u2.17-release") { exclude group: 'com.gitlab.bitfireAT', module: 'dav4jvm' exclude group: 'org.ogce', module: 'xpp3' // unused in Android and brings wrong Junit version exclude group: 'com.squareup.okhttp3' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2039cf52d24c53aa27e9e88d1ee273223b1e927c..30e04639bf227a9af7c83a4dbc1829bca94a65c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + @@ -682,6 +683,17 @@ + + + + + + + + + + MURENA + context.getString(R.string.google_account_type) -> GOOGLE + context.getString(R.string.yahoo_account_type) -> YAHOO + else -> null + } + } + } + private val mDiscoveryEndpoint: Uri? private val mAuthEndpoint: Uri? private val mTokenEndpoint: Uri? @@ -71,8 +92,10 @@ enum class IdentityProvider( val clientId: String val clientSecret: String? val redirectUri: Uri + val logoutRedirectUri: Uri? val scope: String val userInfoEndpoint: String? + val baseUrl: String? init { require( @@ -87,8 +110,10 @@ enum class IdentityProvider( this.clientSecret = clientSecret this.redirectUri = retrieveUri(redirectUri) ?: throw IllegalArgumentException("invalid redirect uri") + this.logoutRedirectUri = retrieveUri(logoutRedirectUri) this.scope = scope this.userInfoEndpoint = userInfoEndpoint + this.baseUrl = baseUrl } fun retrieveConfig(callback: RetrieveConfigurationCallback) { @@ -105,4 +130,4 @@ enum class IdentityProvider( null } else Uri.parse(value) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/receiver/AccountRemovedReceiver.kt b/app/src/main/kotlin/at/bitfire/davdroid/receiver/AccountRemovedReceiver.kt index 12be4afe5b058fa39bd19b96607ac40e240470c7..7409c058da21d434203d35a4fbeacb3d433c14c1 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/receiver/AccountRemovedReceiver.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/receiver/AccountRemovedReceiver.kt @@ -20,17 +20,16 @@ import android.accounts.AccountManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.syncadapter.AccountUtils +import at.bitfire.davdroid.ui.signout.OpenIdEndSessionActivity +import at.bitfire.davdroid.util.AuthStatePrefUtils import com.owncloud.android.lib.common.OwnCloudClientManagerFactory class AccountRemovedReceiver : BroadcastReceiver() { - companion object { - private const val ACCOUNT_REMOVAL_ACTION = "android.accounts.action.ACCOUNT_REMOVED" - } - override fun onReceive(context: Context?, intent: Intent?) { - if (context == null || intent == null || intent.action != ACCOUNT_REMOVAL_ACTION) { + if (context == null || intent == null || intent.action != AccountManager.ACTION_ACCOUNT_REMOVED) { return } @@ -38,6 +37,43 @@ class AccountRemovedReceiver : BroadcastReceiver() { val ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton() ownCloudClientManager.removeClientForByName(accountName) + + clearOidcSession( + intent = intent, + context = context, + accountName = accountName + ) + } + + private fun clearOidcSession( + intent: Intent, + context: Context, + accountName: String + ) { + intent.extras?.getString(AccountManager.KEY_ACCOUNT_TYPE)?.let { type -> + val authStateString = + AuthStatePrefUtils.loadAuthState(context, accountName, type) ?: return + startOidcEndSessionActivity( + context = context, + authState = authStateString, + accountType = type + ) + } + } + + private fun startOidcEndSessionActivity( + context: Context, + authState: String, + accountType: String + ) { + val intent = Intent(context, OpenIdEndSessionActivity::class.java) + intent.apply { + putExtra(AccountSettings.KEY_AUTH_STATE, authState) + putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.applicationContext.startActivity(intent) } private fun getAccountName(context: Context, intent: Intent): String? { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index a9ef5bb507d563b7bfccab848552baf698c32a9b..39dee0e6b47b695cba3bdd2b8056c3e756234d88 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -19,6 +19,7 @@ import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.PeriodicSyncWorker import at.bitfire.davdroid.syncadapter.SyncUtils +import at.bitfire.davdroid.util.AuthStatePrefUtils import at.bitfire.davdroid.util.SsoUtils import at.bitfire.davdroid.util.setAndVerifyUserData import at.bitfire.ical4android.TaskProvider @@ -118,8 +119,8 @@ class AccountSettings( const val CONTACTS_APP_INTERACTION = "z-app-generated--contactsinteraction--recent/" - const val COOKIE_KEY = "cookie_key" - const val COOKIE_SEPARATOR = "" + const val COOKIE_KEY = NCAccountUtils.Constants.KEY_OKHTTP_COOKIES + const val COOKIE_SEPARATOR = NCAccountUtils.Constants.OKHTTP_COOKIE_SEPARATOR /** Static property to indicate whether AccountSettings migration is currently running. * **Access must be `synchronized` with `AccountSettings::class.java`.** */ @@ -271,6 +272,8 @@ class AccountSettings( // OAuth accountManager.setAndVerifyUserData(account, KEY_AUTH_STATE, credentials.authState?.jsonSerializeString()) accountManager.setAndVerifyUserData(account, KEY_CLIENT_SECRET, credentials.clientSecret) + + AuthStatePrefUtils.saveAuthState(context, account, credentials.authState?.jsonSerializeString()) } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt index cc19d2dfa086f3be63a4e23adfeb1f0cc8c893bd..da635b072b8bed3064dbdc3864a779fbbe13d8fd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/DefaultAccountAuthenticatorService.kt @@ -32,6 +32,7 @@ import at.bitfire.davdroid.log.Logger import at.bitfire.davdroid.resource.LocalAddressBook import at.bitfire.davdroid.settings.AccountSettings import at.bitfire.davdroid.ui.setup.LoginActivity +import at.bitfire.davdroid.util.AuthStatePrefUtils import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -205,7 +206,9 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL val clientSecretString = accountManager.getUserData(account, AccountSettings.KEY_CLIENT_SECRET) val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString) - AuthorizationService(context).performTokenRequest( + val authorizationService = AuthorizationService(context) + + authorizationService.performTokenRequest( tokenRequest, clientSecret ) { tokenResponse, ex -> @@ -220,11 +223,16 @@ abstract class DefaultAccountAuthenticatorService : Service(), OnAccountsUpdateL AccountSettings.KEY_CLIENT_SECRET, clientSecretString ) + + AuthStatePrefUtils.saveAuthState(context, account!!, authState.jsonSerializeString()) + val result = Bundle() - result.putString(AccountManager.KEY_ACCOUNT_NAME, account!!.name) + result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name) result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type) result.putString(AccountManager.KEY_AUTHTOKEN, authState.accessToken) response?.onResult(result) + + authorizationService.dispose() } val result = Bundle() diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt index 32b18322f439fdb1e11fab06424e89813f4d85d5..503ae7fc40ab48c509d5ba7a0e796359f02d886c 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/SyncManager.kt @@ -194,7 +194,8 @@ abstract class SyncManager, out CollectionType: L val clientSecretString = accountSettings.credentials().clientSecret val clientSecret = OpenIdUtils.getClientAuthentication(clientSecretString) - AuthorizationService(context).performTokenRequest(tokenRequest, clientSecret) { tokenResponse, ex -> + val authorizationService = AuthorizationService(context) + authorizationService.performTokenRequest(tokenRequest, clientSecret) { tokenResponse, ex -> authState.update(tokenResponse, ex) accountSettings.credentials( Credentials( @@ -219,6 +220,8 @@ abstract class SyncManager, out CollectionType: L executor.execute { performSync(DEFAULT_RETRY_AFTER, DEFAULT_SECOND_RETRY_AFTER, DEFAULT_MAX_RETRY_TIME) } + + authorizationService.dispose() } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt index c6d17fb691d33be99edda9fcdde8febb81931ecc..327163806d60e1f24ec16b7344d58a55002ed906 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/AccountDetailsFragment.kt @@ -13,6 +13,7 @@ import android.content.ComponentName import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.net.Uri import android.os.Bundle import android.provider.CalendarContract import android.text.Editable @@ -30,9 +31,11 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.viewModelScope +import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.Constants import at.bitfire.davdroid.InvalidAccountException import at.bitfire.davdroid.R +import at.bitfire.davdroid.authorization.IdentityProvider import at.bitfire.davdroid.databinding.LoginAccountDetailsBinding import at.bitfire.davdroid.db.AppDatabase import at.bitfire.davdroid.db.Credentials @@ -47,6 +50,7 @@ import at.bitfire.davdroid.settings.SettingsManager import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.SyncAllAccountWorker import at.bitfire.davdroid.syncadapter.SyncWorker +import at.bitfire.davdroid.util.AuthStatePrefUtils import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.utils.AccountManagerUtils @@ -56,6 +60,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationService +import net.openid.appauth.EndSessionRequest import java.util.logging.Level import javax.inject.Inject @@ -202,7 +208,6 @@ class AccountDetailsFragment : Fragment() { return v.root } - private fun notifyEdrive(name: String) { val intent = Intent("foundation.e.drive.action.ADD_ACCOUNT") intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES) @@ -300,6 +305,9 @@ class AccountDetailsFragment : Fragment() { // create Android account val userData = AccountSettings.initialUserData(credentials, baseURL, config.cookies, config.calDAV?.emails?.firstOrNull()) + + AuthStatePrefUtils.saveAuthState(context, account, credentials?.authState?.jsonSerializeString()) + Logger.log.log(Level.INFO, "Creating Android account with initial config", arrayOf(account, userData)) val accountManager = AccountManager.get(context) @@ -424,14 +432,18 @@ class AccountDetailsFragment : Fragment() { if (userData.get(AccountSettings.KEY_EMAIL_ADDRESS) == accountManager .getUserData(account, AccountSettings.KEY_EMAIL_ADDRESS) ) { + val authState = userData.getString(AccountSettings.KEY_AUTH_STATE) + accountManager.setUserData( openIdAccount, AccountSettings.KEY_AUTH_STATE, - userData.getString(AccountSettings.KEY_AUTH_STATE) + authState ) accountManager.setUserData( openIdAccount, AccountSettings.KEY_CLIENT_SECRET, userData.getString(AccountSettings.KEY_CLIENT_SECRET) ) + + AuthStatePrefUtils.saveAuthState(context, openIdAccount, authState) } } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt index 8a5636accfa099f12f1f09e08f94ddfe05d34c00..ed1bea7a027eccd935de43e8662365a4248136a5 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt @@ -49,6 +49,6 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid return } - proceedNext(userName, "https://murena.io/remote.php/dav/files/$userName") + proceedNext(userName, "${IdentityProvider.MURENA.baseUrl}$userName") } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/signout/OpenIdEndSessionActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/signout/OpenIdEndSessionActivity.kt new file mode 100644 index 0000000000000000000000000000000000000000..631e4b8f75200e1bb1bec926f08d20d32bc70e00 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/signout/OpenIdEndSessionActivity.kt @@ -0,0 +1,85 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.ui.signout + +import android.accounts.AccountManager +import android.app.Activity +import android.os.Bundle +import at.bitfire.davdroid.authorization.IdentityProvider +import at.bitfire.davdroid.settings.AccountSettings +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.EndSessionRequest + +class OpenIdEndSessionActivity : Activity() { + + private var authorizationService: AuthorizationService? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val authStateString = intent.getStringExtra(AccountSettings.KEY_AUTH_STATE) + val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE) + + if (authStateString.isNullOrBlank() || accountType.isNullOrBlank()) { + finish() + return + } + + val authState = AuthState.jsonDeserialize(authStateString) + + val configuration = authState.authorizationServiceConfiguration + if (configuration?.endSessionEndpoint == null) { + finish() + return + } + + startEndSessionWebIntent( + accountType = accountType, + configuration = configuration, + authState = authState + ) + + finish() + } + + private fun startEndSessionWebIntent( + accountType: String, + configuration: AuthorizationServiceConfiguration, + authState: AuthState + ) { + authorizationService = AuthorizationService(applicationContext) + + val redirectUri = + IdentityProvider.retrieveByAccountType(this, accountType)?.logoutRedirectUri + + val intent = authorizationService!!.getEndSessionRequestIntent( + EndSessionRequest.Builder(configuration) + .setIdTokenHint(authState.idToken) + .setPostLogoutRedirectUri(redirectUri) + .build() + ) + + startActivity(intent) + } + + override fun onDestroy() { + authorizationService?.dispose() + super.onDestroy() + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/AuthStatePrefUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/AuthStatePrefUtils.kt new file mode 100644 index 0000000000000000000000000000000000000000..19ad813d71f4875727f4ef3874e2826d581b326b --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/util/AuthStatePrefUtils.kt @@ -0,0 +1,61 @@ +/* + * Copyright MURENA SAS 2024 + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.bitfire.davdroid.util + +import android.accounts.Account +import android.content.Context +import android.content.SharedPreferences + +object AuthStatePrefUtils { + + private const val AUTH_STATE_SHARED_PREF = "authStateShared_Pref" + + fun saveAuthState(context: Context, account: Account, value: String?) { + val preferences = getSharedPref(context) + preferences.edit() + .putString(getKey(account), value) + .apply() + } + + fun loadAuthState(context: Context, name: String, type: String): String? { + val preferences = getSharedPref(context) + val key = getKey(name = name, type = type) + val value = preferences.getString(key, "") + val authState = if (value.isNullOrBlank()) null else value + + authState.let { + preferences.edit() + .remove(key) + .apply() + } + + return authState + } + + private fun getSharedPref(context: Context): SharedPreferences { + return context.getSharedPreferences(AUTH_STATE_SHARED_PREF, Context.MODE_PRIVATE) + } + + private fun getKey(account: Account): String { + return getKey(name = account.name, + type = account.type) + } + + private fun getKey(name: String, type: String): String { + return "$name==$type" + } +}