diff --git a/app/src/androidTestOse/kotlin/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt b/app/src/androidTestOse/kotlin/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt
index bda06c6dc1c30a2576ad279476c4f388b816e7d7..82c1b86ca6be97d4ff088a975bfce661ad73ed45 100644
--- a/app/src/androidTestOse/kotlin/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt
+++ b/app/src/androidTestOse/kotlin/at/bitfire/davdroid/syncadapter/AccountUtilsTest.kt
@@ -6,10 +6,14 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
+import android.net.Uri
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
+import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
+import at.bitfire.davdroid.settings.AccountSettings
import at.bitfire.davdroid.settings.SettingsManager
+import com.owncloud.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Assert.assertEquals
@@ -37,6 +41,7 @@ class AccountUtilsTest {
val context by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
val account by lazy { Account("Test Account", context.getString(R.string.account_type)) }
+ val murenaAccount by lazy { Account("Murena Test Account", context.getString(R.string.eelo_account_type)) }
@Test
fun testCreateAccount() {
@@ -56,4 +61,22 @@ class AccountUtilsTest {
}
}
-}
\ No newline at end of file
+ @Test
+ fun isMurenaAccount_persistsWorkspaceDomainForLegacyAccounts() {
+ val userData = Bundle(1)
+ userData.putString(KEY_OC_BASE_URL, BuildConfig.MURENA_BASE_URL_PRODUCTION)
+
+ try {
+ assertTrue(AccountUtils.createAccount(context, murenaAccount, userData))
+ assertTrue(AccountUtils.isMurenaAccount(context, murenaAccount))
+
+ val expectedDomain = Uri.parse(AccountUtils.extractBaseUrl(BuildConfig.MURENA_BASE_URL_PRODUCTION)).host
+ val storedDomain = AccountManager.get(context).getUserData(murenaAccount, AccountSettings.KEY_WORKSPACE_DOMAIN)
+ assertEquals(expectedDomain, storedDomain)
+ } finally {
+ val futureResult = AccountManager.get(context).removeAccount(murenaAccount, {}, null)
+ assertTrue(futureResult.getResult(10, TimeUnit.SECONDS))
+ }
+ }
+
+}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
index 40f23a5a35317e72cdf0e83223c04ce280a489ea..5a07dcaa6f3c8dd6eeee9f4d8d1fdb9f660385dc 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt
@@ -30,10 +30,5 @@ object Constants {
const val AUTH_TOKEN_TYPE = "oauth2-access-token"
- const val EELO_SYNC_HOST = "murena.io"
- const val E_SYNC_URL = "e.email"
-
- const val MURENA_DAV_URL = "https://murena.io/remote.php/dav"
-
const val E_BROWSER_PACKAGE_NAME = "foundation.e.browser"
}
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 9ad42b35a186bfcb842336d3422c78eb1eea121a..95f9f05b37e3426cd940a994ae0573bd8951bcd6 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt
@@ -22,6 +22,7 @@ import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.util.UserIdFetcher
import at.bitfire.davdroid.util.AuthStatePrefUtils
import at.bitfire.davdroid.util.setAndVerifyUserData
+import at.bitfire.davdroid.workspace.MurenaWorkspaceDescriptor
import at.bitfire.ical4android.TaskProvider
import at.bitfire.vcard4android.GroupMethod
import dagger.hilt.EntryPoint
@@ -59,7 +60,7 @@ class AccountSettings(
companion object {
- const val CURRENT_VERSION = 15
+ const val CURRENT_VERSION = 16
const val KEY_SETTINGS_VERSION = "version"
const val KEY_SYNC_INTERVAL_ADDRESSBOOKS = "sync_interval_addressbooks"
@@ -126,12 +127,27 @@ class AccountSettings(
const val AUTH_EXCEPTION_DETECTED = "auth_exception_detected"
+ /** Workspace domain for Murena Workspace backend identity (bare domain, e.g. `"murena.io"`).
+ * Stored at account creation; null for non-workspace or legacy accounts. */
+ const val KEY_WORKSPACE_DOMAIN = "workspace_domain"
+
+ /** OIDC issuer URL for Murena Workspace (e.g. `"https://accounts.murena.io/auth/realms/murena"`).
+ * Populated after [at.bitfire.davdroid.workspace.MurenaOidcDiscovery.discover] succeeds;
+ * null until discovery runs or if OIDC is not configured on the server. */
+ const val KEY_WORKSPACE_OIDC_ISSUER = "workspace_oidc_issuer"
+
/** Static property to indicate whether AccountSettings migration is currently running.
* **Access must be `synchronized` with `AccountSettings::class.java`.** */
@Volatile
var currentlyUpdating = false
- fun initialUserData(credentials: Credentials?, url: String? = null, cookies: String? = null, email: String? = null): Bundle {
+ fun initialUserData(
+ credentials: Credentials?,
+ url: String? = null,
+ cookies: String? = null,
+ email: String? = null,
+ descriptor: MurenaWorkspaceDescriptor? = null
+ ): Bundle {
val bundle = Bundle()
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
@@ -169,6 +185,11 @@ class AccountSettings(
bundle.putString(KEY_EVENT_COLORS, ENABLED_EVENT_COLORS)
+ descriptor?.let {
+ bundle.putString(KEY_WORKSPACE_DOMAIN, it.workspaceDomain)
+ // oidcIssuer is null at creation time; stored later via persistWorkspaceDescriptor
+ }
+
addUserIdToBundle(
bundle = bundle,
url = url,
@@ -299,6 +320,40 @@ class AccountSettings(
}
+ // workspace backend identity
+
+ /** Returns the workspace domain stored with this account, or null for non-workspace or legacy accounts. */
+ fun workspaceDomain(): String? =
+ accountManager.getUserData(account, KEY_WORKSPACE_DOMAIN)
+
+ /** Returns the OIDC issuer stored with this account, or null if discovery has not run yet. */
+ fun oidcIssuer(): String? =
+ accountManager.getUserData(account, KEY_WORKSPACE_OIDC_ISSUER)
+
+ /**
+ * Reconstructs a [MurenaWorkspaceDescriptor] from persisted account metadata.
+ * Returns null if no workspace domain is stored (non-workspace or legacy account).
+ */
+ fun workspaceDescriptor(): MurenaWorkspaceDescriptor? {
+ val domain = workspaceDomain() ?: return null
+ return MurenaWorkspaceDescriptor(
+ workspaceDomain = domain,
+ oidcIssuer = oidcIssuer()
+ )
+ }
+
+ /**
+ * Persists the backend identity and resolved metadata from [descriptor] into this account's
+ * user data. Call at account creation (to store [MurenaWorkspaceDescriptor.workspaceDomain])
+ * and again after [at.bitfire.davdroid.workspace.MurenaOidcDiscovery.discover] succeeds
+ * (to store [MurenaWorkspaceDescriptor.oidcIssuer]).
+ */
+ fun persistWorkspaceDescriptor(descriptor: MurenaWorkspaceDescriptor) {
+ accountManager.setAndVerifyUserData(account, KEY_WORKSPACE_DOMAIN, descriptor.workspaceDomain)
+ accountManager.setAndVerifyUserData(account, KEY_WORKSPACE_OIDC_ISSUER, descriptor.oidcIssuer)
+ }
+
+
// sync. settings
/**
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt
index f39ff8038c00c641a6d1ded12a47abc23bbb92d1..2203cf4810d8cb7a27c18c77a46589b48c9c611f 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettingsMigrations.kt
@@ -16,6 +16,7 @@ import android.util.Base64
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import at.bitfire.davdroid.Constants
+import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
import at.bitfire.davdroid.db.AppDatabase
import at.bitfire.davdroid.db.Collection
@@ -23,6 +24,7 @@ import at.bitfire.davdroid.db.Service
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.resource.LocalAddressBook
import at.bitfire.davdroid.resource.LocalTask
+import at.bitfire.davdroid.syncadapter.AccountUtils
import at.bitfire.davdroid.resource.TaskUtils
import at.bitfire.davdroid.syncadapter.SyncUtils
import at.bitfire.davdroid.util.setAndVerifyUserData
@@ -33,6 +35,7 @@ import at.bitfire.ical4android.UnknownProperty
import at.bitfire.vcard4android.ContactsStorageException
import at.bitfire.vcard4android.GroupMethod
import at.techbee.jtx.JtxContract.asSyncAdapter
+import com.owncloud.android.lib.common.accounts.AccountUtils.Constants as NCAccountUtilsConstants
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.model.property.Url
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -50,6 +53,42 @@ class AccountSettingsMigrations(
val accountSettings: AccountSettings
) {
+ /**
+ * Persist explicit workspace backend identity for legacy Murena accounts that predate
+ * descriptor persistence.
+ */
+ fun update_15_16() {
+ if (account.type != context.getString(R.string.eelo_account_type)) {
+ return
+ }
+
+ if (!accountManager.getUserData(account, AccountSettings.KEY_WORKSPACE_DOMAIN).isNullOrBlank()) {
+ return
+ }
+
+ val baseUrl = accountManager
+ .getUserData(account, NCAccountUtilsConstants.KEY_OC_BASE_URL)
+ ?.let(AccountUtils::extractBaseUrl)
+ ?: return
+
+ val murenaBaseUrls = setOf(
+ AccountUtils.extractBaseUrl(BuildConfig.MURENA_BASE_URL_PRODUCTION),
+ AccountUtils.extractBaseUrl(BuildConfig.MURENA_BASE_URL_STAGING)
+ )
+
+ if (baseUrl !in murenaBaseUrls) {
+ return
+ }
+
+ val workspaceDomain = baseUrl.toHttpUrlOrNull()?.host
+ if (workspaceDomain.isNullOrBlank()) {
+ Logger.log.warning("Could not derive workspace domain from legacy base URL: $baseUrl")
+ return
+ }
+
+ accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WORKSPACE_DOMAIN, workspaceDomain)
+ }
+
/**
* We may get upstream conflict for later update because of version number.
* from Murena's Squad
@@ -413,4 +452,4 @@ class AccountSettingsMigrations(
// updates from AccountSettings version 2 and below are not supported anymore
-}
\ No newline at end of file
+}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt
index feabee062c99ab757c4e1b1b1d3d807fef8227c4..26117948ce8f6c5c92c1c560ac472c3ac653bff2 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt
@@ -7,6 +7,7 @@ package at.bitfire.davdroid.syncadapter
import android.accounts.Account
import android.accounts.AccountManager
import android.content.Context
+import android.net.Uri
import android.os.Bundle
import at.bitfire.davdroid.BuildConfig
import at.bitfire.davdroid.R
@@ -169,6 +170,13 @@ object AccountUtils {
fun isMurenaAccount(context: Context, account: Account): Boolean {
val accountManager = AccountManager.get(context)
+ // Explicit backend identity: workspace domain stored for accounts created after descriptor
+ // persistence was introduced. Its presence is sufficient to identify a Murena Workspace account.
+ val workspaceDomain = accountManager.getUserData(account, AccountSettings.KEY_WORKSPACE_DOMAIN)
+ if (workspaceDomain != null) return true
+
+ // Legacy path: derive identity from URL and account type for accounts that predate
+ // workspace descriptor persistence, then persist explicit backend identity.
val urlData =
accountManager.getUserData(account, Constants.KEY_OC_BASE_URL) ?: return false
@@ -183,7 +191,15 @@ object AccountUtils {
val isMurenaCloud = baseUrl in murenaBaseUrls
val isMurenaAccountType = (account.type == context.getString(R.string.eelo_account_type))
- return isMurenaCloud && isMurenaAccountType
+ if (!isMurenaCloud || !isMurenaAccountType) {
+ return false
+ }
+
+ Uri.parse(baseUrl).host?.let { domain ->
+ accountManager.setAndVerifyUserData(account, AccountSettings.KEY_WORKSPACE_DOMAIN, domain)
+ }
+
+ return true
}
fun isLoggedInWithMurenaSso(context: Context, account: Account): Boolean {
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 46e2c974d7c5c0ed195bb124bc04c05f89f19973..76895c7b98cfaac96e8e0e91ef7d3773d9e5d6b2 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
@@ -51,7 +51,10 @@ 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.network.HttpClient
import at.bitfire.davdroid.util.AuthStatePrefUtils
+import at.bitfire.davdroid.util.MurenaServerConfig
+import at.bitfire.davdroid.workspace.MurenaOidcDiscovery
import at.bitfire.vcard4android.GroupMethod
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.utils.AccountManagerUtils
@@ -352,7 +355,11 @@ class AccountDetailsFragment : Fragment() {
// During Murena SSO migration, `basicAuthMurenaAccount` will be non-null. Otherwise, always null.
val account = basicAuthMurenaAccount ?: getOrCreateAccount(name, accountType)
- val userData = AccountSettings.initialUserData(credentials, baseURL, config.cookies, config.calDAV?.emails?.firstOrNull())
+ val workspaceDescriptor = if (accountType == context.getString(R.string.eelo_account_type)) {
+ MurenaServerConfig.getDescriptor(context)
+ } else null
+
+ val userData = AccountSettings.initialUserData(credentials, baseURL, config.cookies, config.calDAV?.emails?.firstOrNull(), workspaceDescriptor)
AuthStatePrefUtils.saveAuthState(context, account, credentials?.authState?.jsonSerializeString())
@@ -652,6 +659,23 @@ class AccountDetailsFragment : Fragment() {
ContentResolver.setIsSyncable(account, CalendarContract.AUTHORITY, 0)
}
+ // Discover and persist OIDC issuer for Murena Workspace accounts.
+ // Non-blocking: NotConfigured and Failed outcomes are logged but do not fail setup.
+ if (workspaceDescriptor != null) {
+ HttpClient.Builder(context).build().use { client ->
+ when (val discovery = MurenaOidcDiscovery.discover(workspaceDescriptor, client.okHttpClient)) {
+ is MurenaOidcDiscovery.Result.Discovered -> {
+ accountSettings.persistWorkspaceDescriptor(discovery.descriptor)
+ Logger.log.info("OIDC issuer persisted for ${workspaceDescriptor.workspaceDomain}: ${discovery.descriptor.oidcIssuer}")
+ }
+ is MurenaOidcDiscovery.Result.NotConfigured ->
+ Logger.log.info("OIDC not configured for ${workspaceDescriptor.workspaceDomain}")
+ is MurenaOidcDiscovery.Result.Failed ->
+ Logger.log.warning("OIDC discovery failed for ${workspaceDescriptor.workspaceDomain}: ${discovery.cause}")
+ }
+ }
+ }
+
} catch(e: InvalidAccountException) {
Logger.log.log(Level.SEVERE, "Couldn't access account settings", e)
result.postValue(false)
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt
index 91c54f510dbe8ddcac7ce756d4d4a5821fc6112e..78ba74c8f0dfcc37c2124a74fb84229459434516 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt
@@ -71,23 +71,11 @@ class DetectConfigurationFragment: Fragment() {
parentFragmentManager.popBackStack()
if (result.calDAV != null || result.cardDAV != null) {
- intent.putExtra(LoginActivity.RETRY_ON_401, false)
-
parentFragmentManager.beginTransaction()
.replace(android.R.id.content, AccountDetailsFragment())
.addToBackStack(null)
.commit()
- } else if (intent.getBooleanExtra(
- LoginActivity.RETRY_ON_401,
- false
- ) && loginModel.configuration?.encountered401 == true
- ) {
- // murena account has encounters 401, most-probably user put wrong accountId (ex: abc@murena.io instead of abc@e.email)
- // do nothing, EeloAuthenticatorFragment will retry with another time with another user email
- return@observe
} else {
- intent.putExtra(LoginActivity.RETRY_ON_401, false)
-
parentFragmentManager.beginTransaction()
.add(NothingDetectedFragment(), null)
.commit()
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 fc658a405985593ec120ea7235c7f7dd111ba6fd..3a3e7dee84ddb03ec781847751e77b8cd453b1db 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
@@ -32,14 +32,16 @@ import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
-import at.bitfire.davdroid.Constants
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
@@ -68,6 +70,14 @@ class EeloAuthenticatorFragment : Fragment() {
requireActivity().intent.getStringExtra(SettingsActivity.EXTRA_ACCOUNT_NAME_HINT)
}
+ 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
val activeNetworkInfo = connectivityManager.activeNetworkInfo
@@ -147,75 +157,25 @@ class EeloAuthenticatorFragment : Fragment() {
override fun onResume() {
super.onResume()
- if (requireActivity().intent.getBooleanExtra(LoginActivity.RETRY_ON_401, false) && switchUserName()) {
- // user wants to login with murena account, but most probably provided wrong accountId as email.
- // switching email is done, retry login
- login()
- requireActivity().intent.putExtra(LoginActivity.RETRY_ON_401, false) // disable retry option to mitigate infinite looping
- }
-
val accountName = requireActivity().intent.getStringExtra(LoginActivity.EXTRA_USERNAME)
if (accountName != null) {
userIdEditText.setText(accountName)
}
}
- /***
- * user put wrong accountId for the first time (for ex: abc@murena.io instead of abc@e.email)
- * this method check provided email & switch to alternative email if possible so retry can be handled in bg
- * @return email field's value is successfully switched or not. If true, proceed to retry
- */
- @SuppressLint("SetTextI18n")
- private fun switchUserName(): Boolean {
- if (userIdEditText.text.toString().contains("@")) {
- val username = userIdEditText.text.toString().substringBefore("@")
- val dns = userIdEditText.text.toString().substringAfter("@")
-
- if (dns == Constants.E_SYNC_URL) {
- userIdEditText.setText(username + "@" + Constants.EELO_SYNC_HOST)
- return true
- }
-
- if (dns == Constants.EELO_SYNC_HOST) {
- userIdEditText.setText(username + "@" + Constants.E_SYNC_URL)
- return true
- }
- }
-
- return false
- }
-
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(toggleButtonCheckedKey, toggleButtonState)
super.onSaveInstanceState(outState)
}
- /**
- * murena.io account can have userName which is not email address
- * But, we want to have email as userName so that auth to services like Mail doesn't break.
- * This method check the provided userName if it is not email & server is https://murena.io;
- * then add `@murena.io` after the userName to make full email address.
- */
- @SuppressLint("SetTextI18n")
- private fun purifyUserName(serverUrl: String) {
- val providedUserName = userIdEditText.text.toString()
-
- if (!providedUserName.contains("@") && serverUrl == "https://${Constants.EELO_SYNC_HOST}") {
- userIdEditText.setText("$providedUserName@${Constants.EELO_SYNC_HOST}")
- }
- }
-
private fun computeDomain(username: CharSequence?) : String {
- var domain = "https://${Constants.EELO_SYNC_HOST}"
-
- if (!username.isNullOrBlank() && username.toString().contains("@")) {
- var dns = username.toString().substringAfter("@")
- if (dns == Constants.E_SYNC_URL) {
- dns = Constants.EELO_SYNC_HOST
- }
- 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() {
@@ -230,21 +190,29 @@ class EeloAuthenticatorFragment : Fragment() {
private fun login() {
handleNoNetworkAvailable()
- val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && !toggleButtonState
+ val serverUrl = serverUrlEditText.text?.toString().orEmpty().trim()
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()
@@ -259,25 +227,10 @@ class EeloAuthenticatorFragment : Fragment() {
.commit()
}
- // if user wants to login with murena account, add support to automated retry
- // if user in any case provide wrong accountId as email (ex: abc@murena.io instead of abc@e.email)
- private fun addSupportRetryOn401IfPossible(serverUrl: String) {
- if ("https://${Constants.EELO_SYNC_HOST}" == serverUrl) {
- requireActivity().intent.putExtra(LoginActivity.RETRY_ON_401, true)
- }
- }
-
- 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())
- addSupportRetryOn401IfPossible(serverUrl)
- }
-
- purifyUserName(serverUrl)
+ val serverUrl = serverUrlToValidate ?: computeDomain(userIdEditText.text.toString())
fun validateUrl() {
@@ -314,8 +267,8 @@ class EeloAuthenticatorFragment : Fragment() {
}
private fun getServerURI(serverUrl: String): URI {
- if (serverUrl.startsWith("https://${Constants.EELO_SYNC_HOST}")) {
- return URI(Constants.MURENA_DAV_URL)
+ if (serverUrl.startsWith(workspaceDescriptor.baseWebUrl)) {
+ return URI(workspaceDescriptor.davBaseUrl)
}
return URI(serverUrl)
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 a7dfc99762c90d78b7f6ee2ae85a78dda1dd6e70..69ab6c06e0f3301fc4790b4e18f3115c9c34bd81 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
@@ -48,8 +48,6 @@ class LoginActivity : AppCompatActivity() {
const val OPEN_APP_ACTIVITY_AFTER_AUTH = "open_app_activity_after_auth"
const val IGNORE_ACCOUNT_SETUP = "ignore_account_setup"
-
- const val RETRY_ON_401 = "retry_on_401"
}
@Inject
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/util/MurenaServerConfig.kt b/app/src/main/kotlin/at/bitfire/davdroid/util/MurenaServerConfig.kt
index 3f47989d0104bb5b1b5ca71c54da95dbff3f573f..23b854ac951bc42ea69f6b79bcf188e895c41cd8 100644
--- a/app/src/main/kotlin/at/bitfire/davdroid/util/MurenaServerConfig.kt
+++ b/app/src/main/kotlin/at/bitfire/davdroid/util/MurenaServerConfig.kt
@@ -18,8 +18,10 @@
package at.bitfire.davdroid.util
import android.content.Context
+import android.net.Uri
import android.provider.Settings
import at.bitfire.davdroid.BuildConfig
+import at.bitfire.davdroid.workspace.MurenaWorkspaceDescriptor
object MurenaServerConfig {
const val MURENA_STAGING_SERVER_GLOBAL_KEY = "murena_server.staging"
@@ -44,4 +46,15 @@ object MurenaServerConfig {
BuildConfig.MURENA_DISCOVERY_END_POINT_PRODUCTION
}
}
+
+ /**
+ * Returns the [MurenaWorkspaceDescriptor] for the currently active environment
+ * (staging or production), resolved from [BuildConfig].
+ */
+ fun getDescriptor(context: Context): MurenaWorkspaceDescriptor {
+ val baseUrl = getBaseUrl(context)
+ val domain = Uri.parse(baseUrl).host
+ ?: throw IllegalArgumentException("Cannot extract domain from Murena base URL: $baseUrl")
+ return MurenaWorkspaceDescriptor(workspaceDomain = domain)
+ }
}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fc2988674954f6904384b862b791b4205bfde0ba
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt
@@ -0,0 +1,143 @@
+/*
+ * 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 .
+ */
+
+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
+ }
+}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f0cdf65522bdfa0c4832ef6bba7ea514fc138b39
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt
@@ -0,0 +1,179 @@
+/*
+ * 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 .
+ */
+
+package at.bitfire.davdroid.workspace
+
+import at.bitfire.davdroid.log.Logger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+/**
+ * Discovers the OIDC issuer for a Murena Workspace by following the unauthenticated redirect
+ * from the Nextcloud `oidc_login` endpoint.
+ *
+ * When accessing `https://{workspaceDomain}/apps/oidc_login/oidc` without credentials,
+ * Nextcloud redirects directly to the IdP authorization endpoint, e.g.:
+ * `https://accounts.murena.io/auth/realms/murena/protocol/openid-connect/auth?...`
+ *
+ * The OIDC issuer (Keycloak realm URL) is extracted by stripping the
+ * `/protocol/openid-connect` suffix.
+ */
+object MurenaOidcDiscovery {
+
+ private const val OIDC_LOGIN_PATH = "/apps/oidc_login/oidc"
+ private const val KEYCLOAK_OIDC_PATH = "/protocol/openid-connect"
+ private const val MAX_TRANSIENT_RETRIES = 2
+ private const val RETRY_DELAY_MS = 300L
+
+ /**
+ * Outcome of an OIDC discovery attempt.
+ */
+ sealed class Result {
+ /** OIDC is configured and the issuer was successfully resolved. */
+ data class Discovered(val descriptor: MurenaWorkspaceDescriptor) : Result()
+
+ /** The workspace does not have OIDC configured (app absent or redirect to non-OIDC target). */
+ object NotConfigured : Result()
+
+ /** Discovery failed due to a transient error (network, server unavailable, etc.). */
+ data class Failed(val cause: Exception) : Result()
+ }
+
+ /**
+ * Runs OIDC discovery for [descriptor].
+ *
+ * @param descriptor The workspace descriptor to enrich.
+ * @param httpClient An [OkHttpClient] used for the discovery request. Redirect-following is
+ * disabled internally; the caller's configuration is otherwise respected.
+ * @return [Result.Discovered] with the enriched descriptor on success,
+ * [Result.NotConfigured] if the workspace has no OIDC set up,
+ * [Result.Failed] if a transient error prevented discovery.
+ */
+ suspend fun discover(
+ descriptor: MurenaWorkspaceDescriptor,
+ httpClient: OkHttpClient
+ ): Result = withContext(Dispatchers.IO) {
+ val url = "https://${descriptor.workspaceDomain}$OIDC_LOGIN_PATH"
+ val request = Request.Builder().url(url).build()
+
+ val noRedirectClient = httpClient.newBuilder()
+ .followRedirects(false)
+ .followSslRedirects(false)
+ .build()
+
+ var attempt = 0
+ var lastFailure: Exception? = null
+ while (attempt <= MAX_TRANSIENT_RETRIES) {
+ try {
+ val (outcome, statusCode) = noRedirectClient.newCall(request).execute().use { response ->
+ classifyResponse(response.code, response.header("Location")) to response.code
+ }
+
+ when (outcome) {
+ is ResponseOutcome.Discovered ->
+ return@withContext Result.Discovered(descriptor.copy(oidcIssuer = outcome.issuer))
+ ResponseOutcome.NotConfigured ->
+ return@withContext Result.NotConfigured
+ is ResponseOutcome.Failed -> {
+ val shouldRetry = outcome.transient && attempt < MAX_TRANSIENT_RETRIES
+ if (shouldRetry) {
+ attempt += 1
+ lastFailure = IOException("OIDC discovery failed for ${descriptor.workspaceDomain} with HTTP $statusCode")
+ Logger.log.info("OIDC discovery transient HTTP failure for ${descriptor.workspaceDomain} (code=$statusCode), retry $attempt/$MAX_TRANSIENT_RETRIES")
+ delay(RETRY_DELAY_MS * attempt)
+ continue
+ }
+ val cause = IOException("OIDC discovery failed for ${descriptor.workspaceDomain} with HTTP $statusCode")
+ return@withContext Result.Failed(cause)
+ }
+ }
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ val isTransient = e is IOException
+ val shouldRetry = isTransient && attempt < MAX_TRANSIENT_RETRIES
+ if (shouldRetry) {
+ attempt += 1
+ lastFailure = e
+ Logger.log.info("OIDC discovery transient error for ${descriptor.workspaceDomain}: $e, retry $attempt/$MAX_TRANSIENT_RETRIES")
+ delay(RETRY_DELAY_MS * attempt)
+ continue
+ }
+ Logger.log.warning("OIDC discovery failed for ${descriptor.workspaceDomain}: $e")
+ return@withContext Result.Failed(e)
+ }
+ }
+
+ Result.Failed(lastFailure ?: IOException("OIDC discovery failed for ${descriptor.workspaceDomain} after retries"))
+ }
+
+ internal sealed class ResponseOutcome {
+ data class Discovered(val issuer: String) : ResponseOutcome()
+ object NotConfigured : ResponseOutcome()
+ data class Failed(val transient: Boolean) : ResponseOutcome()
+ }
+
+ internal fun classifyResponse(statusCode: Int, location: String?): ResponseOutcome {
+ if (statusCode in 300..399) {
+ if (location == null) {
+ Logger.log.info("OIDC discovery: redirect status without Location header (code=$statusCode)")
+ return ResponseOutcome.Failed(transient = false)
+ }
+
+ val issuer = extractIssuer(location)
+ return if (issuer != null) {
+ ResponseOutcome.Discovered(issuer)
+ } else {
+ ResponseOutcome.NotConfigured
+ }
+ }
+
+ if (statusCode == 401 || statusCode == 403 || statusCode == 404) {
+ return ResponseOutcome.NotConfigured
+ }
+
+ if (statusCode == 408 || statusCode == 429 || statusCode in 500..599) {
+ return ResponseOutcome.Failed(transient = true)
+ }
+
+ Logger.log.info("OIDC discovery: unexpected HTTP status $statusCode")
+ return ResponseOutcome.Failed(transient = false)
+ }
+
+ /**
+ * Extracts the OIDC issuer (Keycloak realm URL) from a Keycloak authorization endpoint URL.
+ *
+ * Example:
+ * `https://accounts.murena.io/auth/realms/murena/protocol/openid-connect/auth?...`
+ * → `https://accounts.murena.io/auth/realms/murena`
+ *
+ * Returns null if [authUrl] does not contain the expected Keycloak path segment, which
+ * indicates the redirect target is not an OIDC authorization endpoint (e.g. a plain login page).
+ */
+ internal fun extractIssuer(authUrl: String): String? {
+ val idx = authUrl.indexOf(KEYCLOAK_OIDC_PATH)
+ if (idx == -1) {
+ Logger.log.info("OIDC discovery: redirect target is not an OIDC endpoint, assuming OIDC not configured ($authUrl)")
+ return null
+ }
+ return authUrl.substring(0, idx)
+ }
+}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptor.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e0d8569fd15f8dfe47359aa244cd5143764e40d0
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptor.kt
@@ -0,0 +1,75 @@
+/*
+ * 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 .
+ */
+
+package at.bitfire.davdroid.workspace
+
+/**
+ * Backend descriptor for a Murena Workspace instance.
+ *
+ * Structurally derivable endpoints (DAV, mail autoconfig, dashboard) are computed eagerly
+ * from [workspaceDomain]. The OIDC issuer cannot be derived statically because its path
+ * depends on the Keycloak realm name, which varies per deployment; populate [oidcIssuer]
+ * via [MurenaOidcDiscovery.discover] before using OIDC-related properties.
+ *
+ * Create instances via [at.bitfire.davdroid.util.MurenaServerConfig.getDescriptor].
+ *
+ * @param workspaceDomain Bare domain of the workspace, without scheme or trailing slash
+ * (e.g. `"murena.io"`).
+ * @param oidcIssuer OIDC issuer URL (e.g. `"https://accounts.murena.io/auth/realms/murena"`),
+ * populated by [MurenaOidcDiscovery.discover]. Null until discovery runs.
+ * @param branding Branding metadata exposed to clients.
+ */
+data class MurenaWorkspaceDescriptor(
+ val workspaceDomain: String,
+ val oidcIssuer: String? = null,
+ val branding: Branding = Branding()
+) {
+ /** HTTPS base web URL of the workspace (e.g. `https://murena.io`). */
+ val baseWebUrl: String = "https://$workspaceDomain"
+
+ /** Workspace dashboard URL (Nextcloud dashboard page). */
+ val dashboardUrl: String = "$baseWebUrl/apps/dashboard"
+
+ /** DAV base URL for CalDAV and CardDAV sync. */
+ val davBaseUrl: String = "$baseWebUrl/remote.php/dav"
+
+ /**
+ * OIDC discovery endpoint (RFC 8414), derived from [oidcIssuer].
+ * Null until [MurenaOidcDiscovery.discover] has been called.
+ */
+ val oidcDiscoveryUrl: String? = oidcIssuer?.let { "$it/.well-known/openid-configuration" }
+
+ /**
+ * Primary mail autoconfig endpoint (Thunderbird autoconfig protocol).
+ * Clients should try this URL first, then fall back to [mailAutoconfigWellKnownUrl].
+ */
+ val mailAutoconfigUrl: String = "https://autoconfig.$workspaceDomain/mail/config-v1.1.xml"
+
+ /** Well-known fallback mail autoconfig endpoint (RFC 5785). */
+ val mailAutoconfigWellKnownUrl: String = "$baseWebUrl/.well-known/autoconfig/mail/config-v1.1.xml"
+
+ /**
+ * Branding metadata for this Murena Workspace instance.
+ *
+ * @param displayName Human-readable service name shown to the user.
+ * @param logoUrl Optional HTTPS URL of the service logo image.
+ */
+ data class Branding(
+ val displayName: String = "Murena Workspace",
+ val logoUrl: String? = null
+ )
+}
diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/package.md b/app/src/main/kotlin/at/bitfire/davdroid/workspace/package.md
new file mode 100644
index 0000000000000000000000000000000000000000..884a72ac237fe3fa4416dcacfb1a2805ca13c86c
--- /dev/null
+++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/package.md
@@ -0,0 +1,104 @@
+# Package at.bitfire.davdroid.workspace
+
+Murena Workspace backend integration contract.
+
+## Overview
+
+This package defines the authoritative contract for backend-aware Murena Workspace integration
+across /e/OS apps. It provides a structured description of a workspace instance (the *descriptor*)
+and the rules for discovering dynamic configuration (OIDC issuer, mail autoconfig).
+
+All public APIs in this package are considered stable client-facing contract unless explicitly
+marked otherwise.
+
+---
+
+## Backend descriptor: `MurenaWorkspaceDescriptor`
+
+`MurenaWorkspaceDescriptor` is the central data class. It holds all endpoints needed to integrate
+with a Murena Workspace instance. Downstream apps **must** obtain a descriptor via
+[at.bitfire.davdroid.util.MurenaServerConfig.getDescriptor] rather than constructing one directly.
+
+### Stable contract fields
+
+These fields are derived deterministically from `workspaceDomain` and are safe to persist or
+cache across sessions:
+
+| Field | Example value | Derivation rule |
+|---|---|---|
+| `baseWebUrl` | `https://murena.io` | `https://{workspaceDomain}` |
+| `dashboardUrl` | `https://murena.io/apps/dashboard` | `{baseWebUrl}/apps/dashboard` |
+| `davBaseUrl` | `https://murena.io/remote.php/dav` | `{baseWebUrl}/remote.php/dav` |
+| `mailAutoconfigUrl` | `https://autoconfig.murena.io/mail/config-v1.1.xml` | `https://autoconfig.{workspaceDomain}/mail/config-v1.1.xml` |
+| `mailAutoconfigWellKnownUrl` | `https://murena.io/.well-known/autoconfig/mail/config-v1.1.xml` | `{baseWebUrl}/.well-known/autoconfig/mail/config-v1.1.xml` (RFC 5785) |
+
+### Dynamic fields (null until discovery)
+
+These fields require a network discovery step before use:
+
+| Field | Populated by | Null meaning |
+|---|---|---|
+| `oidcIssuer` | `MurenaOidcDiscovery.discover` | Discovery has not run yet, or OIDC is not configured |
+| `oidcDiscoveryUrl` | derived from `oidcIssuer` | Same as above |
+
+Callers **must not** assume `oidcIssuer` is non-null without having run discovery first.
+
+### `Branding` (informational)
+
+`MurenaWorkspaceDescriptor.Branding` is purely informational and not required for any
+integration flow. Its values may change without notice and must not be used as identifiers.
+
+---
+
+## Discovery rules
+
+### Mail autoconfig
+
+Mail clients should attempt the two endpoints in order:
+
+1. **Primary** — `mailAutoconfigUrl` (`https://autoconfig.{domain}/mail/config-v1.1.xml`)
+2. **Fallback** — `mailAutoconfigWellKnownUrl` (`{baseWebUrl}/.well-known/autoconfig/mail/config-v1.1.xml`, RFC 5785)
+
+Both endpoints serve the Thunderbird autoconfig XML format (version 1.1).
+
+### OIDC issuer discovery (`MurenaOidcDiscovery`)
+
+The OIDC issuer cannot be derived statically from the domain because the Keycloak realm name
+varies per deployment. `MurenaOidcDiscovery.discover` resolves it by following the
+unauthenticated redirect from the Nextcloud `oidc_login` endpoint:
+
+```
+GET https://{workspaceDomain}/apps/oidc_login/oidc
+ → 302 https://accounts.example.com/auth/realms/{realm}/protocol/openid-connect/auth?...
+```
+
+The issuer is extracted by stripping the `/protocol/openid-connect` suffix from the redirect
+location. The discovery result is one of three sealed outcomes:
+
+- `Result.Discovered` — OIDC is configured; the returned descriptor is enriched with `oidcIssuer`.
+- `Result.NotConfigured` — OIDC is absent or the redirect target is not an OIDC endpoint.
+- `Result.Failed` — A transient network or server error prevented discovery.
+
+Callers should treat `Result.NotConfigured` as a permanent signal for the current server
+configuration, and `Result.Failed` as a transient condition eligible for retry.
+
+---
+
+## Internal implementation details
+
+The following are **not** part of the downstream contract and may change without notice:
+
+- The HTTP redirect-following strategy in `MurenaOidcDiscovery` (currently single-hop, no-redirect client).
+- The exact path segment used to detect Keycloak endpoints (`/protocol/openid-connect`).
+- The `MurenaOidcDiscovery.extractIssuer` function (package-internal).
+
+---
+
+## Compatibility expectations
+
+- `workspaceDomain` is the only required constructor parameter; all other fields are derived or optional.
+- The URL derivation rules (scheme, paths, subdomains) are stable and will not change without a
+ major version bump of this module.
+- Fields introduced in future versions will default to null or a safe default to remain
+ backward-compatible with existing descriptors.
+- `MurenaWorkspaceDescriptor` is a `data class`: equality and copy are part of the contract.
diff --git a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..127524bd044ee3ab65edc4789a8cc13d08a07671
--- /dev/null
+++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt
@@ -0,0 +1,68 @@
+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)
+ }
+}
diff --git a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d4107cccc2f645b4d183502e5b3306b82e07a711
--- /dev/null
+++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt
@@ -0,0 +1,160 @@
+package at.bitfire.davdroid.workspace
+
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicInteger
+
+class MurenaOidcDiscoveryTest {
+
+ private val descriptor = MurenaWorkspaceDescriptor(workspaceDomain = "murena.io")
+
+ // --- extractIssuer ---
+
+ @Test
+ fun `extractIssuer strips Keycloak protocol path from auth URL`() {
+ val authUrl = "https://accounts.murena.io/auth/realms/murena/protocol/openid-connect/auth" +
+ "?response_type=code&client_id=murena.io&nonce=abc&state=xyz&scope=openid"
+
+ assertEquals(
+ "https://accounts.murena.io/auth/realms/murena",
+ MurenaOidcDiscovery.extractIssuer(authUrl)
+ )
+ }
+
+ @Test
+ fun `extractIssuer works for custom realm names`() {
+ val authUrl = "https://auth.workspace.example.com/auth/realms/my-org/protocol/openid-connect/auth"
+
+ assertEquals(
+ "https://auth.workspace.example.com/auth/realms/my-org",
+ MurenaOidcDiscovery.extractIssuer(authUrl)
+ )
+ }
+
+ @Test
+ fun `extractIssuer returns null when redirect target is a plain login page`() {
+ assertNull(MurenaOidcDiscovery.extractIssuer("https://murena.io/login"))
+ }
+
+ @Test
+ fun `extractIssuer returns null when Keycloak path segment is absent`() {
+ assertNull(MurenaOidcDiscovery.extractIssuer("https://example.com/some/other/auth"))
+ }
+
+ @Test
+ fun `extractIssuer returns null for empty string`() {
+ assertNull(MurenaOidcDiscovery.extractIssuer(""))
+ }
+
+ // --- Result type ---
+
+ @Test
+ fun `Discovered carries enriched descriptor with oidcIssuer set`() {
+ val issuer = "https://accounts.murena.io/auth/realms/murena"
+ val result = MurenaOidcDiscovery.Result.Discovered(descriptor.copy(oidcIssuer = issuer))
+
+ assertEquals(issuer, result.descriptor.oidcIssuer)
+ assertEquals(
+ "$issuer/.well-known/openid-configuration",
+ result.descriptor.oidcDiscoveryUrl
+ )
+ }
+
+ @Test
+ fun `Failed carries the originating exception`() {
+ val cause = Exception("connection refused")
+ val result = MurenaOidcDiscovery.Result.Failed(cause)
+
+ assertEquals(cause, result.cause)
+ }
+
+ @Test
+ fun `NotConfigured is a singleton`() {
+ assertTrue(
+ MurenaOidcDiscovery.Result.NotConfigured === MurenaOidcDiscovery.Result.NotConfigured
+ )
+ }
+
+ // --- discover HTTP classification + retry ---
+
+ @Test
+ fun `discover retries transient HTTP failure then discovers issuer`() = runBlocking {
+ val calls = AtomicInteger(0)
+ val client = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val n = calls.incrementAndGet()
+ if (n == 1) {
+ response(chain.request(), 503)
+ } else {
+ response(
+ chain.request(),
+ 302,
+ "https://accounts.murena.io/auth/realms/murena/protocol/openid-connect/auth?client_id=murena.io"
+ )
+ }
+ }
+ .build()
+
+ val result = MurenaOidcDiscovery.discover(descriptor, client)
+ assertTrue(result is MurenaOidcDiscovery.Result.Discovered)
+ assertEquals(2, calls.get())
+
+ val discovered = result as MurenaOidcDiscovery.Result.Discovered
+ assertEquals("https://accounts.murena.io/auth/realms/murena", discovered.descriptor.oidcIssuer)
+ }
+
+ @Test
+ fun `discover does not retry non-transient not-configured HTTP status`() = runBlocking {
+ val calls = AtomicInteger(0)
+ val client = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ calls.incrementAndGet()
+ response(chain.request(), 404)
+ }
+ .build()
+
+ val result = MurenaOidcDiscovery.discover(descriptor, client)
+ assertSame(MurenaOidcDiscovery.Result.NotConfigured, result)
+ assertEquals(1, calls.get())
+ }
+
+ @Test
+ fun `discover retries transient IO failures and eventually returns Failed`() = runBlocking {
+ val calls = AtomicInteger(0)
+ val client = OkHttpClient.Builder()
+ .addInterceptor { _ ->
+ calls.incrementAndGet()
+ throw IOException("timeout")
+ }
+ .build()
+
+ val result = MurenaOidcDiscovery.discover(descriptor, client)
+ assertTrue(result is MurenaOidcDiscovery.Result.Failed)
+ assertEquals(3, calls.get())
+ }
+
+ private fun response(request: Request, code: Int, location: String? = null): Response {
+ val builder = Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(code)
+ .message("HTTP $code")
+ .body("".toResponseBody())
+
+ if (location != null) {
+ builder.header("Location", location)
+ }
+
+ return builder.build()
+ }
+}
diff --git a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptorTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5d28d86600732086a657c2fd70fd9aeb75add288
--- /dev/null
+++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptorTest.kt
@@ -0,0 +1,105 @@
+package at.bitfire.davdroid.workspace
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class MurenaWorkspaceDescriptorTest {
+
+ private val descriptor = MurenaWorkspaceDescriptor(workspaceDomain = "murena.io")
+
+ @Test
+ fun `baseWebUrl uses https scheme and workspace domain`() {
+ assertEquals("https://murena.io", descriptor.baseWebUrl)
+ }
+
+ @Test
+ fun `dashboardUrl is rooted at baseWebUrl`() {
+ assertEquals("https://murena.io/apps/dashboard", descriptor.dashboardUrl)
+ }
+
+ @Test
+ fun `davBaseUrl follows Nextcloud remote-php-dav pattern`() {
+ assertEquals("https://murena.io/remote.php/dav", descriptor.davBaseUrl)
+ }
+
+ @Test
+ fun `mailAutoconfigUrl uses autoconfig subdomain`() {
+ assertEquals(
+ "https://autoconfig.murena.io/mail/config-v1.1.xml",
+ descriptor.mailAutoconfigUrl
+ )
+ }
+
+ @Test
+ fun `mailAutoconfigWellKnownUrl uses RFC 5785 well-known path`() {
+ assertEquals(
+ "https://murena.io/.well-known/autoconfig/mail/config-v1.1.xml",
+ descriptor.mailAutoconfigWellKnownUrl
+ )
+ }
+
+ @Test
+ fun `oidcIssuer is null before discovery`() {
+ assertNull(descriptor.oidcIssuer)
+ }
+
+ @Test
+ fun `oidcDiscoveryUrl is null before discovery`() {
+ assertNull(descriptor.oidcDiscoveryUrl)
+ }
+
+ @Test
+ fun `oidcDiscoveryUrl is derived from issuer after discovery`() {
+ val enriched = descriptor.copy(
+ oidcIssuer = "https://accounts.murena.io/auth/realms/murena"
+ )
+ assertEquals(
+ "https://accounts.murena.io/auth/realms/murena/.well-known/openid-configuration",
+ enriched.oidcDiscoveryUrl
+ )
+ }
+
+ @Test
+ fun `all URLs are derived consistently from a custom workspace domain`() {
+ val custom = MurenaWorkspaceDescriptor(workspaceDomain = "workspace.example.com")
+
+ assertEquals("https://workspace.example.com", custom.baseWebUrl)
+ assertEquals("https://workspace.example.com/apps/dashboard", custom.dashboardUrl)
+ assertEquals("https://workspace.example.com/remote.php/dav", custom.davBaseUrl)
+ assertEquals(
+ "https://autoconfig.workspace.example.com/mail/config-v1.1.xml",
+ custom.mailAutoconfigUrl
+ )
+ assertEquals(
+ "https://workspace.example.com/.well-known/autoconfig/mail/config-v1.1.xml",
+ custom.mailAutoconfigWellKnownUrl
+ )
+ assertNull(custom.oidcIssuer)
+ assertNull(custom.oidcDiscoveryUrl)
+ }
+
+ @Test
+ fun `default branding has expected display name`() {
+ assertEquals("Murena Workspace", descriptor.branding.displayName)
+ }
+
+ @Test
+ fun `default branding has no logo URL`() {
+ assertNull(descriptor.branding.logoUrl)
+ }
+
+ @Test
+ fun `custom branding is preserved`() {
+ val branded = MurenaWorkspaceDescriptor(
+ workspaceDomain = "murena.io",
+ branding = MurenaWorkspaceDescriptor.Branding(
+ displayName = "My Workspace",
+ logoUrl = "https://murena.io/logo.png"
+ )
+ )
+
+ assertEquals("My Workspace", branded.branding.displayName)
+ assertEquals("https://murena.io/logo.png", branded.branding.logoUrl)
+ }
+}
diff --git a/doc/workspace-backend-compatibility.md b/doc/workspace-backend-compatibility.md
new file mode 100644
index 0000000000000000000000000000000000000000..26f4526d904903c6a829d9976c9315630fc4f316
--- /dev/null
+++ b/doc/workspace-backend-compatibility.md
@@ -0,0 +1,47 @@
+# Workspace Backend Compatibility (Legacy Murena Accounts)
+
+Related work items:
+- https://gitlab.e.foundation/e/os/AccountManager/-/work_items/114
+- https://gitlab.e.foundation/e/os/AccountManager/-/work_items/118
+
+## Goal
+
+Keep already provisioned public Murena accounts working while moving to explicit backend
+identity and backend-portable behavior.
+
+## Account Shapes and Compatibility Path
+
+1. New Murena Workspace account
+- Shape: `workspace_domain` is present in Android account userData.
+- Behavior: account is identified from explicit backend metadata.
+- Compatibility note: no legacy fallback needed.
+
+2. Legacy public Murena account (pre-descriptor persistence)
+- Shape: `workspace_domain` is missing, but account type is `eelo_account_type` and
+ `oc_base_url` matches known Murena public base URLs.
+- Behavior: legacy detection remains available, then `workspace_domain` is persisted.
+- Compatibility note: this is a lazy resolution path to converge to explicit metadata.
+
+3. Migrated legacy Murena account
+- Shape: account was created before descriptor persistence, then migration `15 -> 16` ran.
+- Behavior: `workspace_domain` is backfilled from legacy base URL and persisted durably.
+- Compatibility note: subsequent behavior no longer depends on implicit Murena defaults.
+
+4. Non-Murena or custom backend account
+- Shape: no `workspace_domain`, and Murena legacy URL/type check does not match.
+- Behavior: not treated as Murena Workspace account.
+- Compatibility note: no Murena-specific backfill is applied.
+
+## Strategy Summary
+
+- Migration path: account settings migration `update_15_16` backfills explicit backend identity
+ for compatible legacy public Murena accounts.
+- Lazy path: legacy detection in `isMurenaAccount()` persists `workspace_domain` when it can
+ infer Murena identity safely.
+- Safety: only known public Murena base URLs are used for legacy backfill.
+
+## Expected Outcome
+
+- Existing public Murena accounts continue to function.
+- Backend identity becomes explicit and durable over time.
+- Runtime behavior progressively stops depending on implicit defaults.