Loading app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt +25 −16 Original line number Diff line number Diff line Loading @@ -34,12 +34,14 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import at.bitfire.davdroid.ECloudAccountHelper import at.bitfire.davdroid.R import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.databinding.FragmentEeloAuthenticatorBinding import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.murenasso.MurenaSsoMigrationPreferences import at.bitfire.davdroid.ui.ShowUrlActivity import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.davdroid.util.MurenaServerConfig import at.bitfire.davdroid.workspace.MurenaAuthRouting import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout Loading Loading @@ -71,6 +73,10 @@ class EeloAuthenticatorFragment : Fragment() { private val workspaceDescriptor by lazy { MurenaServerConfig.getDescriptor(requireContext()) } private val murenaPrimaryDomain by lazy { MurenaAuthRouting.extractHost(BuildConfig.MURENA_BASE_URL_PRODUCTION) ?: workspaceDescriptor.workspaceDomain } private fun isNetworkAvailable(): Boolean { val connectivityManager = requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager Loading Loading @@ -163,13 +169,13 @@ class EeloAuthenticatorFragment : Fragment() { } private fun computeDomain(username: CharSequence?) : String { var domain = workspaceDescriptor.baseWebUrl if (!username.isNullOrBlank() && username.toString().contains("@")) { val dns = username.toString().substringAfter("@") domain = "https://$dns" } return domain val decision = MurenaAuthRouting.route( loginIdentifier = username?.toString().orEmpty(), explicitServerUrl = null, defaultBackendDomain = workspaceDescriptor.workspaceDomain, murenaPrimaryDomain = murenaPrimaryDomain ) return "https://${decision.targetBackendDomain}" } private fun handleNoNetworkAvailable() { Loading @@ -185,21 +191,28 @@ class EeloAuthenticatorFragment : Fragment() { handleNoNetworkAvailable() val serverUrl = serverUrlEditText.text?.toString().orEmpty().trim() val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && serverUrl.isEmpty() val userId = userIdEditText.text.toString() val password = passwordEditText.text.toString() val routing = MurenaAuthRouting.route( loginIdentifier = userId, explicitServerUrl = serverUrl, defaultBackendDomain = workspaceDescriptor.workspaceDomain, murenaPrimaryDomain = murenaPrimaryDomain ) val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && routing.authMode == MurenaAuthRouting.AuthMode.OIDC if (handleOpenIdAuth && userId.isNotBlank()) { requireActivity().intent.apply { val userNameHint = if (!userNameHint.isNullOrBlank()) userNameHint else userIdEditText.text.toString() if (!userNameHint.isNullOrBlank()) userNameHint else routing.canonicalLogin.normalizedLoginIdentifier 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()) { } else if (userId.isNotBlank() && password.isNotBlank() && validate(routing.basicServerUrl)) { navigate(DetectConfigurationFragment()) } else { Toast.makeText(context, R.string.invalid_credentials, Toast.LENGTH_LONG).show() Loading @@ -214,14 +227,10 @@ class EeloAuthenticatorFragment : Fragment() { .commit() } private fun validate(): Boolean { private fun validate(serverUrlToValidate: String?): Boolean { var valid = false var serverUrl = serverUrlEditText.text.toString() if (serverUrl.isEmpty()) { serverUrl = computeDomain(userIdEditText.text.toString()) } val serverUrl = serverUrlToValidate ?: computeDomain(userIdEditText.text.toString()) fun validateUrl() { Loading app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt 0 → 100644 +143 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * 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 <https://www.gnu.org/licenses/>. */ package at.bitfire.davdroid.workspace import java.net.URI /** * Canonicalizes login input and routes authentication for Murena Workspace account setup. */ object MurenaAuthRouting { private const val MURENA_ALIAS_DOMAIN = "e.email" enum class ResolutionSource { EXPLICIT_URL, ALIAS, INFERRED_DOMAIN, DEFAULT_BACKEND } enum class AuthMode { OIDC, BASIC } data class CanonicalLogin( val normalizedLoginIdentifier: String, val inferredBackendDomain: String, val source: ResolutionSource ) data class Decision( val canonicalLogin: CanonicalLogin, val targetBackendDomain: String, val authMode: AuthMode, val basicServerUrl: String? ) fun route( loginIdentifier: String, explicitServerUrl: String?, defaultBackendDomain: String, murenaPrimaryDomain: String ): Decision { val canonical = canonicalize( loginIdentifier = loginIdentifier, explicitServerUrl = explicitServerUrl, defaultBackendDomain = defaultBackendDomain, murenaPrimaryDomain = murenaPrimaryDomain ) val authMode = when (canonical.source) { ResolutionSource.EXPLICIT_URL -> AuthMode.BASIC ResolutionSource.ALIAS -> AuthMode.OIDC ResolutionSource.DEFAULT_BACKEND -> if (canonical.inferredBackendDomain == murenaPrimaryDomain) AuthMode.OIDC else AuthMode.BASIC ResolutionSource.INFERRED_DOMAIN -> AuthMode.OIDC } val basicServerUrl = if (authMode == AuthMode.BASIC) { explicitServerUrl?.trim()?.takeIf { it.isNotEmpty() } ?: "https://${canonical.inferredBackendDomain}" } else { null } return Decision( canonicalLogin = canonical, targetBackendDomain = canonical.inferredBackendDomain, authMode = authMode, basicServerUrl = basicServerUrl ) } fun canonicalize( loginIdentifier: String, explicitServerUrl: String?, defaultBackendDomain: String, murenaPrimaryDomain: String ): CanonicalLogin { val normalizedLogin = loginIdentifier.trim() val explicitDomain = explicitServerUrl ?.trim() ?.takeIf { it.isNotEmpty() } ?.let(::extractHost) if (!explicitDomain.isNullOrBlank()) { return CanonicalLogin( normalizedLoginIdentifier = normalizedLogin, inferredBackendDomain = explicitDomain, source = ResolutionSource.EXPLICIT_URL ) } val atIndex = normalizedLogin.lastIndexOf("@") if (atIndex > 0 && atIndex < normalizedLogin.lastIndex) { val localPart = normalizedLogin.substring(0, atIndex) val rawDomain = normalizedLogin.substring(atIndex + 1) val normalizedDomain = rawDomain.lowercase() if (normalizedDomain == MURENA_ALIAS_DOMAIN) { return CanonicalLogin( normalizedLoginIdentifier = "$localPart@$murenaPrimaryDomain", inferredBackendDomain = murenaPrimaryDomain, source = ResolutionSource.ALIAS ) } return CanonicalLogin( normalizedLoginIdentifier = "$localPart@$normalizedDomain", inferredBackendDomain = normalizedDomain, source = ResolutionSource.INFERRED_DOMAIN ) } return CanonicalLogin( normalizedLoginIdentifier = normalizedLogin, inferredBackendDomain = defaultBackendDomain.lowercase(), source = ResolutionSource.DEFAULT_BACKEND ) } fun extractHost(url: String): String? = try { URI(url).host?.lowercase() } catch (_: Exception) { null } } app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt 0 → 100644 +68 −0 Original line number Diff line number Diff line package at.bitfire.davdroid.workspace import org.junit.Assert.assertEquals import org.junit.Test class MurenaAuthRoutingTest { @Test fun `username without domain routes to murena oidc when default backend is murena dot io`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice", explicitServerUrl = null, defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.DEFAULT_BACKEND, decision.canonicalLogin.source) assertEquals("alice", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("murena.io", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) } @Test fun `e email alias maps to murena backend and routes to oidc`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@e.email", explicitServerUrl = null, defaultBackendDomain = "custom.example", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.ALIAS, decision.canonicalLogin.source) assertEquals("alice@murena.io", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("murena.io", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) } @Test fun `non alias domain routes to inferred backend with oidc auth`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@workspace.example", explicitServerUrl = null, defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.INFERRED_DOMAIN, decision.canonicalLogin.source) assertEquals("alice@workspace.example", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("workspace.example", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) assertEquals(null, decision.basicServerUrl) } @Test fun `explicit server url takes precedence over identifier inference`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@e.email", explicitServerUrl = "https://other.example", defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.EXPLICIT_URL, decision.canonicalLogin.source) assertEquals("other.example", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.BASIC, decision.authMode) assertEquals("https://other.example", decision.basicServerUrl) } } Loading
app/src/main/kotlin/at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt +25 −16 Original line number Diff line number Diff line Loading @@ -34,12 +34,14 @@ import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import at.bitfire.davdroid.ECloudAccountHelper import at.bitfire.davdroid.R import at.bitfire.davdroid.BuildConfig import at.bitfire.davdroid.databinding.FragmentEeloAuthenticatorBinding import at.bitfire.davdroid.db.Credentials import at.bitfire.davdroid.murenasso.MurenaSsoMigrationPreferences import at.bitfire.davdroid.ui.ShowUrlActivity import at.bitfire.davdroid.ui.account.SettingsActivity import at.bitfire.davdroid.util.MurenaServerConfig import at.bitfire.davdroid.workspace.MurenaAuthRouting import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout Loading Loading @@ -71,6 +73,10 @@ class EeloAuthenticatorFragment : Fragment() { private val workspaceDescriptor by lazy { MurenaServerConfig.getDescriptor(requireContext()) } private val murenaPrimaryDomain by lazy { MurenaAuthRouting.extractHost(BuildConfig.MURENA_BASE_URL_PRODUCTION) ?: workspaceDescriptor.workspaceDomain } private fun isNetworkAvailable(): Boolean { val connectivityManager = requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager Loading Loading @@ -163,13 +169,13 @@ class EeloAuthenticatorFragment : Fragment() { } private fun computeDomain(username: CharSequence?) : String { var domain = workspaceDescriptor.baseWebUrl if (!username.isNullOrBlank() && username.toString().contains("@")) { val dns = username.toString().substringAfter("@") domain = "https://$dns" } return domain val decision = MurenaAuthRouting.route( loginIdentifier = username?.toString().orEmpty(), explicitServerUrl = null, defaultBackendDomain = workspaceDescriptor.workspaceDomain, murenaPrimaryDomain = murenaPrimaryDomain ) return "https://${decision.targetBackendDomain}" } private fun handleNoNetworkAvailable() { Loading @@ -185,21 +191,28 @@ class EeloAuthenticatorFragment : Fragment() { handleNoNetworkAvailable() val serverUrl = serverUrlEditText.text?.toString().orEmpty().trim() val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && serverUrl.isEmpty() val userId = userIdEditText.text.toString() val password = passwordEditText.text.toString() val routing = MurenaAuthRouting.route( loginIdentifier = userId, explicitServerUrl = serverUrl, defaultBackendDomain = workspaceDescriptor.workspaceDomain, murenaPrimaryDomain = murenaPrimaryDomain ) val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && routing.authMode == MurenaAuthRouting.AuthMode.OIDC if (handleOpenIdAuth && userId.isNotBlank()) { requireActivity().intent.apply { val userNameHint = if (!userNameHint.isNullOrBlank()) userNameHint else userIdEditText.text.toString() if (!userNameHint.isNullOrBlank()) userNameHint else routing.canonicalLogin.normalizedLoginIdentifier 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()) { } else if (userId.isNotBlank() && password.isNotBlank() && validate(routing.basicServerUrl)) { navigate(DetectConfigurationFragment()) } else { Toast.makeText(context, R.string.invalid_credentials, Toast.LENGTH_LONG).show() Loading @@ -214,14 +227,10 @@ class EeloAuthenticatorFragment : Fragment() { .commit() } private fun validate(): Boolean { private fun validate(serverUrlToValidate: String?): Boolean { var valid = false var serverUrl = serverUrlEditText.text.toString() if (serverUrl.isEmpty()) { serverUrl = computeDomain(userIdEditText.text.toString()) } val serverUrl = serverUrlToValidate ?: computeDomain(userIdEditText.text.toString()) fun validateUrl() { Loading
app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt 0 → 100644 +143 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * 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 <https://www.gnu.org/licenses/>. */ package at.bitfire.davdroid.workspace import java.net.URI /** * Canonicalizes login input and routes authentication for Murena Workspace account setup. */ object MurenaAuthRouting { private const val MURENA_ALIAS_DOMAIN = "e.email" enum class ResolutionSource { EXPLICIT_URL, ALIAS, INFERRED_DOMAIN, DEFAULT_BACKEND } enum class AuthMode { OIDC, BASIC } data class CanonicalLogin( val normalizedLoginIdentifier: String, val inferredBackendDomain: String, val source: ResolutionSource ) data class Decision( val canonicalLogin: CanonicalLogin, val targetBackendDomain: String, val authMode: AuthMode, val basicServerUrl: String? ) fun route( loginIdentifier: String, explicitServerUrl: String?, defaultBackendDomain: String, murenaPrimaryDomain: String ): Decision { val canonical = canonicalize( loginIdentifier = loginIdentifier, explicitServerUrl = explicitServerUrl, defaultBackendDomain = defaultBackendDomain, murenaPrimaryDomain = murenaPrimaryDomain ) val authMode = when (canonical.source) { ResolutionSource.EXPLICIT_URL -> AuthMode.BASIC ResolutionSource.ALIAS -> AuthMode.OIDC ResolutionSource.DEFAULT_BACKEND -> if (canonical.inferredBackendDomain == murenaPrimaryDomain) AuthMode.OIDC else AuthMode.BASIC ResolutionSource.INFERRED_DOMAIN -> AuthMode.OIDC } val basicServerUrl = if (authMode == AuthMode.BASIC) { explicitServerUrl?.trim()?.takeIf { it.isNotEmpty() } ?: "https://${canonical.inferredBackendDomain}" } else { null } return Decision( canonicalLogin = canonical, targetBackendDomain = canonical.inferredBackendDomain, authMode = authMode, basicServerUrl = basicServerUrl ) } fun canonicalize( loginIdentifier: String, explicitServerUrl: String?, defaultBackendDomain: String, murenaPrimaryDomain: String ): CanonicalLogin { val normalizedLogin = loginIdentifier.trim() val explicitDomain = explicitServerUrl ?.trim() ?.takeIf { it.isNotEmpty() } ?.let(::extractHost) if (!explicitDomain.isNullOrBlank()) { return CanonicalLogin( normalizedLoginIdentifier = normalizedLogin, inferredBackendDomain = explicitDomain, source = ResolutionSource.EXPLICIT_URL ) } val atIndex = normalizedLogin.lastIndexOf("@") if (atIndex > 0 && atIndex < normalizedLogin.lastIndex) { val localPart = normalizedLogin.substring(0, atIndex) val rawDomain = normalizedLogin.substring(atIndex + 1) val normalizedDomain = rawDomain.lowercase() if (normalizedDomain == MURENA_ALIAS_DOMAIN) { return CanonicalLogin( normalizedLoginIdentifier = "$localPart@$murenaPrimaryDomain", inferredBackendDomain = murenaPrimaryDomain, source = ResolutionSource.ALIAS ) } return CanonicalLogin( normalizedLoginIdentifier = "$localPart@$normalizedDomain", inferredBackendDomain = normalizedDomain, source = ResolutionSource.INFERRED_DOMAIN ) } return CanonicalLogin( normalizedLoginIdentifier = normalizedLogin, inferredBackendDomain = defaultBackendDomain.lowercase(), source = ResolutionSource.DEFAULT_BACKEND ) } fun extractHost(url: String): String? = try { URI(url).host?.lowercase() } catch (_: Exception) { null } }
app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt 0 → 100644 +68 −0 Original line number Diff line number Diff line package at.bitfire.davdroid.workspace import org.junit.Assert.assertEquals import org.junit.Test class MurenaAuthRoutingTest { @Test fun `username without domain routes to murena oidc when default backend is murena dot io`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice", explicitServerUrl = null, defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.DEFAULT_BACKEND, decision.canonicalLogin.source) assertEquals("alice", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("murena.io", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) } @Test fun `e email alias maps to murena backend and routes to oidc`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@e.email", explicitServerUrl = null, defaultBackendDomain = "custom.example", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.ALIAS, decision.canonicalLogin.source) assertEquals("alice@murena.io", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("murena.io", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) } @Test fun `non alias domain routes to inferred backend with oidc auth`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@workspace.example", explicitServerUrl = null, defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.INFERRED_DOMAIN, decision.canonicalLogin.source) assertEquals("alice@workspace.example", decision.canonicalLogin.normalizedLoginIdentifier) assertEquals("workspace.example", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.OIDC, decision.authMode) assertEquals(null, decision.basicServerUrl) } @Test fun `explicit server url takes precedence over identifier inference`() { val decision = MurenaAuthRouting.route( loginIdentifier = "alice@e.email", explicitServerUrl = "https://other.example", defaultBackendDomain = "murena.io", murenaPrimaryDomain = "murena.io" ) assertEquals(MurenaAuthRouting.ResolutionSource.EXPLICIT_URL, decision.canonicalLogin.source) assertEquals("other.example", decision.targetBackendDomain) assertEquals(MurenaAuthRouting.AuthMode.BASIC, decision.authMode) assertEquals("https://other.example", decision.basicServerUrl) } }