Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Verified Commit 7b58e083 authored by Romain Hunault's avatar Romain Hunault 🚴🏻
Browse files

feat(workspace): add backend-aware auth routing for Murena login

parent 31c7dd44
Loading
Loading
Loading
Loading
Loading
+25 −16
Original line number Diff line number Diff line
@@ -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
@@ -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
@@ -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() {
@@ -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()
@@ -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() {

+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
        }
}
+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)
    }
}