From e43d3e5e315c6b9905f1946396c9293dfd2578bb Mon Sep 17 00:00:00 2001 From: Romain Hunault Date: Thu, 26 Mar 2026 11:18:21 +0100 Subject: [PATCH 1/6] fix(auth): use two-step Murena OIDC flow Signed-off-by: althafvly --- .../authorization/IdentityProvider.kt | 2 +- .../ui/setup/EeloAuthenticatorFragment.kt | 1 + .../davdroid/ui/setup/LoginActivity.kt | 1 + .../ui/setup/MurenaOpenIdAuthFragment.kt | 22 +++++++++++++++++++ .../ui/setup/OpenIdAuthenticationViewModel.kt | 18 ++++++++++++++- .../authorization/IdentityProviderTest.kt | 14 ++++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt 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 2aad06c6a..f48b50359 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/ui/setup/EeloAuthenticatorFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt index e601529f6..fc658a405 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 e9f290428..a7dfc9976 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 3ffa9123a..e3f0e4bc1 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 @@ -51,6 +51,11 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid } override fun onAuthenticationComplete(userData: JSONObject) { + if (!isOfflineAccessRequested()) { + requestOfflineAccess() + return + } + val userNameKey = "username" if (!userData.has(userNameKey)) { @@ -70,4 +75,21 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid } proceedNext(userName, "$baseUrl$userName") } + + private fun isOfflineAccessRequested(): 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/OpenIdAuthenticationViewModel.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/OpenIdAuthenticationViewModel.kt index 139c0aa7b..533fcc95b 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 @@ -97,6 +97,14 @@ class OpenIdAuthenticationViewModel @Inject constructor( authState = AuthState(serviceConfiguration) val loginHint = intent.getStringExtra(LoginActivity.USERNAME_HINT) + val scope = if ( + identityProvider == IdentityProvider.MURENA && + intent.getBooleanExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false) + ) { + "${identityProvider!!.scope} offline_access" + } else { + identityProvider!!.scope + } val authRequest = AuthorizationRequest.Builder( serviceConfiguration, @@ -104,7 +112,7 @@ class OpenIdAuthenticationViewModel @Inject constructor( ResponseTypeValues.CODE, identityProvider!!.redirectUri ) - .setScope(identityProvider!!.scope) + .setScope(scope) .setLoginHint(sanitizeHint(loginHint)) .build() @@ -131,7 +139,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 000000000..90dd982fc --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt @@ -0,0 +1,14 @@ +package at.bitfire.davdroid.authorization + +import org.junit.Assert.assertEquals +import org.junit.Test + +class IdentityProviderTest { + + @Test + fun `murena base scope skips offline access`() { + val scope = IdentityProvider.MURENA.scope + + assertEquals("openid profile email", scope) + } +} -- GitLab From b1d34d9c3019a0b91b3371b5b6e1752a64bcde46 Mon Sep 17 00:00:00 2001 From: Nishith Khanna Date: Fri, 27 Mar 2026 00:38:06 +0530 Subject: [PATCH 2/6] Fix ci build --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e391e7ca8..56912225d 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: -- GitLab From 3c40a28617beb129dd1b4feebfb196c190dc5679 Mon Sep 17 00:00:00 2001 From: althafvly Date: Fri, 27 Mar 2026 12:10:01 +0530 Subject: [PATCH 3/6] chore: bump version to 4.3.9-16 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index ebe50cceb..6ecf293c4 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" -- GitLab From b1a9de53cd1a810e706c3e1ef03ee07f88781bf0 Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 1 Apr 2026 16:31:01 +0200 Subject: [PATCH 4/6] fix(auth): validate Murena offline access auth state --- .../MurenaOfflineAccessValidator.kt | 25 +++++++++++++ .../ui/setup/MurenaOpenIdAuthFragment.kt | 6 ++++ .../setup/OpenIdAuthenticationBaseFragment.kt | 3 ++ .../authorization/IdentityProviderTest.kt | 36 +++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/authorization/MurenaOfflineAccessValidator.kt 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 000000000..d73ffb02a --- /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/MurenaOpenIdAuthFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt index e3f0e4bc1..d4e4186c4 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 @@ -56,6 +57,11 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid return } + if (!MurenaOfflineAccessValidator.hasUsableOfflineAccess(getAuthState())) { + handleLoginFailedToast() + return + } + val userNameKey = "username" if (!userData.has(userNameKey)) { 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 0c5ed6f9a..5a417b264 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/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt index 90dd982fc..dfe682b75 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/authorization/IdentityProviderTest.kt @@ -1,6 +1,9 @@ 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 { @@ -11,4 +14,37 @@ class IdentityProviderTest { 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 + ) + ) + } } -- GitLab From e4a5ac15810c2653e11b9711ed44fd8616f37a3b Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 1 Apr 2026 16:36:47 +0200 Subject: [PATCH 5/6] refactor(auth): rename Murena offline access flow helper Rename isOfflineAccessRequested() to hasRequestedOfflineAccess() to make the condition read as flow state instead of granted access. The previous name made the negated branch look backwards in onAuthenticationComplete(), while the flag actually tracks whether the offline_access step has already been requested. The new wording matches the intent extra semantics without implying that offline access has already been granted. --- .../at/bitfire/davdroid/ui/setup/MurenaOpenIdAuthFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d4e4186c4..bd908ffde 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 @@ -52,7 +52,7 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid } override fun onAuthenticationComplete(userData: JSONObject) { - if (!isOfflineAccessRequested()) { + if (!hasRequestedOfflineAccess()) { requestOfflineAccess() return } @@ -82,7 +82,7 @@ class MurenaOpenIdAuthFragment : OpenIdAuthenticationBaseFragment(IdentityProvid proceedNext(userName, "$baseUrl$userName") } - private fun isOfflineAccessRequested(): Boolean { + private fun hasRequestedOfflineAccess(): Boolean { return requireActivity().intent.getBooleanExtra( LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false -- GitLab From 564c593b71930d900e44d8be791b6a542b2a4eeb Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 1 Apr 2026 16:39:51 +0200 Subject: [PATCH 6/6] refactor(auth): remove non-null assertions in OIDC auth request --- .../ui/setup/OpenIdAuthenticationViewModel.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 533fcc95b..a0d2d1e20 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,22 +95,23 @@ 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 ( - identityProvider == IdentityProvider.MURENA && + provider == IdentityProvider.MURENA && intent.getBooleanExtra(LoginActivity.MURENA_OFFLINE_ACCESS_REQUESTED, false) ) { - "${identityProvider!!.scope} offline_access" + "${provider.scope} offline_access" } else { - identityProvider!!.scope + provider.scope } val authRequest = AuthorizationRequest.Builder( serviceConfiguration, - identityProvider!!.clientId, + provider.clientId, ResponseTypeValues.CODE, - identityProvider!!.redirectUri + provider.redirectUri ) .setScope(scope) .setLoginHint(sanitizeHint(loginHint)) -- GitLab