diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e391e7ca8e711cd0fbe52b64719cecc1a36ebfd8..56912225d6b62514e42f60aa59140dcdc3e2116e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,7 +55,6 @@ init_submodules: stage: gitlab_release rules: - if: '$CI_COMMIT_REF_PROTECTED == "true"' - - when: manual script: - git clone https://gitlab.e.foundation/e/os/system-apps-update-info.git systemAppsUpdateInfo artifacts: diff --git a/app/build.gradle b/app/build.gradle index ebe50cceb96793d8c105dafa972c7eb376c13ccc..6ecf293c4cbd28d19aa0c70dccff72c7a273ec36 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,7 +30,7 @@ android { def appVersionCode = localProps.getProperty('VERSION_CODE') if (appVersionCode == null) { // Set initial version code if not present - appVersionCode = 403900015 + appVersionCode = 403900016 } else { // Increment version code for subsequent builds appVersionCode = appVersionCode.toInteger() + 1 @@ -43,7 +43,7 @@ android { applicationId "foundation.e.accountmanager" versionCode appVersionCode - versionName '4.3.9-15' + versionName '4.3.9-16' buildConfigField "long", "buildTime", System.currentTimeMillis() + "L" diff --git a/app/src/main/kotlin/at/bitfire/davdroid/authorization/IdentityProvider.kt b/app/src/main/kotlin/at/bitfire/davdroid/authorization/IdentityProvider.kt index 2aad06c6aa18ab101220c478beeb30c9802f2bcf..f48b503596eec73540693153e6137455ded34254 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/authorization/IdentityProvider.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/authorization/IdentityProvider.kt @@ -46,7 +46,7 @@ enum class IdentityProvider( clientSecret = null, redirectUri = BuildConfig.MURENA_REDIRECT_URI + ":/redirect", logoutRedirectUri = BuildConfig.MURENA_LOGOUT_REDIRECT_URI + ":/redirect", - scope = "openid profile email offline_access", + scope = "openid profile email", userInfoEndpoint = null, baseUrl = BuildConfig.MURENA_BASE_URL_PRODUCTION, ), diff --git a/app/src/main/kotlin/at/bitfire/davdroid/authorization/MurenaOfflineAccessValidator.kt b/app/src/main/kotlin/at/bitfire/davdroid/authorization/MurenaOfflineAccessValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..d73ffb02a3e0c407842cfab2825cb5f576def952 --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/authorization/MurenaOfflineAccessValidator.kt @@ -0,0 +1,25 @@ +package at.bitfire.davdroid.authorization + +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException + +object MurenaOfflineAccessValidator { + + fun hasUsableOfflineAccess(authState: AuthState): Boolean { + return hasUsableOfflineAccess( + accessToken = authState.accessToken, + refreshToken = authState.refreshToken, + authorizationException = authState.authorizationException + ) + } + + internal fun hasUsableOfflineAccess( + accessToken: String?, + refreshToken: String?, + authorizationException: AuthorizationException? + ): Boolean { + return authorizationException == null && + !accessToken.isNullOrBlank() && + !refreshToken.isNullOrBlank() + } +} diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt index e601529f6a9fc47c31d600ad999078e9835a1c24..fc658a405985593ec120ea7235c7f7dd111ba6fd 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt @@ -241,6 +241,7 @@ class EeloAuthenticatorFragment : Fragment() { putExtra(LoginActivity.USERNAME_HINT, userNameHint) putExtra(SettingsActivity.EXTRA_IS_RE_AUTHENTICATING, isReAuthenticating) + putExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false) } navigate(MurenaOpenIdAuthFragment()) } else if (userId.isNotBlank() && password.isNotBlank() && validate()) { diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt index e9f290428a14bbbc06668f8e14f76890ff1dbc87..a7dfc99762c90d78b7f6ee2ae85a78dda1dd6e70 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/LoginActivity.kt @@ -42,6 +42,7 @@ class LoginActivity : AppCompatActivity() { const val AUTH_STATE = "authState" const val ACCOUNT_TYPE = "account_type" const val OPENID_AUTH_FLOW_COMPLETE = "openId_authFlow_complete" + const val MURENA_OFFLINE_ACCESS_REQUESTED = "murena_offline_access_requested" const val OPEN_APP_PACKAGE_AFTER_AUTH = "open_app_package_after_auth" const val OPEN_APP_ACTIVITY_AFTER_AUTH = "open_app_activity_after_auth" 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 3ffa9123a7b3f8252a1b38ebbd5751fd417c8107..bd908ffdeedfc188cb1e1e015f8e77e45c7f6aa1 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 @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.View import at.bitfire.davdroid.ECloudAccountHelper import at.bitfire.davdroid.authorization.IdentityProvider +import at.bitfire.davdroid.authorization.MurenaOfflineAccessValidator import at.bitfire.davdroid.murenasso.MurenaSsoMigrationPreferences import at.bitfire.davdroid.ui.account.SettingsActivity import org.json.JSONObject @@ -51,6 +52,16 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid } override fun onAuthenticationComplete(userData: JSONObject) { + if (!hasRequestedOfflineAccess()) { + requestOfflineAccess() + return + } + + if (!MurenaOfflineAccessValidator.hasUsableOfflineAccess(getAuthState())) { + handleLoginFailedToast() + return + } + val userNameKey = "username" if (!userData.has(userNameKey)) { @@ -70,4 +81,21 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid } proceedNext(userName, "$baseUrl$userName") } + + private fun hasRequestedOfflineAccess(): Boolean { + return requireActivity().intent.getBooleanExtra( + LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, + false + ) + } + + private fun requestOfflineAccess() { + requireActivity().intent.apply { + putExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, true) + putExtra(LoginActivity.OPENID_AUTH_FLOW_COMPLETE, false) + removeExtra(LoginActivity.AUTH_STATE) + } + + startAuthFLow() + } } diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt index 0c5ed6f9ad451db6d7f6645256097874ceaf4d35..5a417b26431a08cbddcd49d449a7610803897ee8 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationBaseFragment.kt @@ -35,6 +35,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import net.openid.appauth.AuthState.AuthStateAction +import net.openid.appauth.AuthState import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationResponse import net.openid.appauth.AuthorizationService.TokenResponseCallback @@ -203,6 +204,8 @@ abstract class OpenIdAuthenticationBaseFragment(private val identityProvider: Id finishActivity() } + protected fun getAuthState(): AuthState = viewModel.getAuthState() + protected fun proceedNext(userName: String, baseUrl: String, cardDavUrl: String? = null) { activity?.intent?.putExtra(LoginActivity.OPENID_AUTH_FLOW_COMPLETE, true) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt index 139c0aa7bf325952533e04dd8a23625bd5522d1d..a0d2d1e20fe4b3b5d0d0b625670fa1d376e63c53 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt @@ -95,16 +95,25 @@ class OpenIdAuthenticationViewModel @Inject constructor( intent: Intent ) { authState = AuthState(serviceConfiguration) + val provider = requireNotNull(identityProvider) { "identityProvider must be set before requestAuthCode()" } val loginHint = intent.getStringExtra(LoginActivity.USERNAME_HINT) + val scope = if ( + provider == IdentityProvider.MURENA && + intent.getBooleanExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false) + ) { + "${provider.scope} offline_access" + } else { + provider.scope + } val authRequest = AuthorizationRequest.Builder( serviceConfiguration, - identityProvider!!.clientId, + provider.clientId, ResponseTypeValues.CODE, - identityProvider!!.redirectUri + provider.redirectUri ) - .setScope(identityProvider!!.scope) + .setScope(scope) .setLoginHint(sanitizeHint(loginHint)) .build() @@ -131,7 +140,15 @@ class OpenIdAuthenticationViewModel @Inject constructor( LoginActivity.ACCOUNT_TYPE, providedIntent.getStringExtra(LoginActivity.ACCOUNT_TYPE) ) + intent.putExtra( + LoginActivity.USERNAME_HINT, + providedIntent.getStringExtra(LoginActivity.USERNAME_HINT) + ) intent.putExtra(LoginActivity.OPENID_AUTH_FLOW_COMPLETE, true) + intent.putExtra( + LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, + providedIntent.getBooleanExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false) + ) intent.putExtra( LoginActivity.OPEN_APP_PACKAGE_AFTER_AUTH, providedIntent.getStringExtra(LoginActivity.OPEN_APP_PACKAGE_AFTER_AUTH) diff --git a/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..dfe682b758ff72ea443ce365f44e886a10e5d2b3 --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt @@ -0,0 +1,50 @@ +package at.bitfire.davdroid.authorization + +import net.openid.appauth.AuthorizationException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdentityProviderTest { + + @Test + fun `murena base scope skips offline access`() { + val scope = IdentityProvider.MURENA.scope + + assertEquals("openid profile email", scope) + } + + @Test + fun `murena offline access validator accepts usable auth state data`() { + assertTrue( + MurenaOfflineAccessValidator.hasUsableOfflineAccess( + accessToken = "access-token", + refreshToken = "refresh-token", + authorizationException = null + ) + ) + } + + @Test + fun `murena offline access validator rejects missing refresh token`() { + assertFalse( + MurenaOfflineAccessValidator.hasUsableOfflineAccess( + accessToken = "access-token", + refreshToken = null, + authorizationException = null + ) + ) + } + + @Test + fun `murena offline access validator rejects authorization exception`() { + assertFalse( + MurenaOfflineAccessValidator.hasUsableOfflineAccess( + accessToken = "access-token", + refreshToken = "refresh-token", + authorizationException = AuthorizationException.TokenRequestErrors.INVALID_GRANT + ) + ) + } +}