From 300565b66cecd9d9ff53feb0842969dca7e7e181 Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 8 Apr 2026 17:25:16 +0200 Subject: [PATCH 01/10] feat(workspace): introduce Murena Workspace backend descriptor Add MurenaWorkspaceDescriptor to centralize all service endpoints derived from a workspace domain (DAV, mail autoconfig, dashboard, OIDC). OIDC issuer is discovered at runtime via MurenaOidcDiscovery by following the redirect from /apps/oidc_login/oidc. Closes #113 --- .../davdroid/util/MurenaServerConfig.kt | 13 +++ .../davdroid/workspace/MurenaOidcDiscovery.kt | 100 +++++++++++++++++ .../workspace/MurenaWorkspaceDescriptor.kt | 75 +++++++++++++ .../workspace/MurenaOidcDiscoveryTest.kt | 39 +++++++ .../MurenaWorkspaceDescriptorTest.kt | 105 ++++++++++++++++++ 5 files changed, 332 insertions(+) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptor.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaWorkspaceDescriptorTest.kt 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 3f47989d0..23b854ac9 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/MurenaOidcDiscovery.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt new file mode 100644 index 000000000..a47f4dcbe --- /dev/null +++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt @@ -0,0 +1,100 @@ +/* + * 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.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * 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/auth` suffix. + */ +object MurenaOidcDiscovery { + + private const val OIDC_LOGIN_PATH = "/apps/oidc_login/oidc" + private const val KEYCLOAK_OIDC_PATH = "/protocol/openid-connect" + + /** + * Runs OIDC discovery for [descriptor] and returns a copy with [MurenaWorkspaceDescriptor.oidcIssuer] + * populated. Returns the original descriptor unchanged if discovery fails. + * + * @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. + */ + suspend fun discover( + descriptor: MurenaWorkspaceDescriptor, + httpClient: OkHttpClient + ): MurenaWorkspaceDescriptor = withContext(Dispatchers.IO) { + val issuer = fetchIssuer(descriptor.workspaceDomain, httpClient) + if (issuer != null) descriptor.copy(oidcIssuer = issuer) else descriptor + } + + private fun fetchIssuer(workspaceDomain: String, httpClient: OkHttpClient): String? { + val url = "https://$workspaceDomain$OIDC_LOGIN_PATH" + val request = Request.Builder().url(url).build() + + val noRedirectClient = httpClient.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + + return try { + noRedirectClient.newCall(request).execute().use { response -> + val location = response.header("Location") + if (location == null) { + Logger.log.warning("OIDC discovery: no Location header in response from $url") + null + } else { + extractIssuer(location) + } + } + } catch (e: Exception) { + Logger.log.warning("OIDC discovery failed for $workspaceDomain: $e") + null + } + } + + /** + * 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. + */ + internal fun extractIssuer(authUrl: String): String? { + val idx = authUrl.indexOf(KEYCLOAK_OIDC_PATH) + if (idx == -1) { + Logger.log.warning("OIDC discovery: unexpected IdP URL format (no '$KEYCLOAK_OIDC_PATH'): $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 000000000..e0d8569fd --- /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/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt new file mode 100644 index 000000000..05b09e20b --- /dev/null +++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt @@ -0,0 +1,39 @@ +package at.bitfire.davdroid.workspace + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MurenaOidcDiscoveryTest { + + @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 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("")) + } +} 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 000000000..5d28d8660 --- /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) + } +} -- GitLab From a786638047ad4941b2f19603315adee3a02815b4 Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 8 Apr 2026 17:47:01 +0200 Subject: [PATCH 02/10] feat(workspace): expose typed result from OIDC discovery Replace the nullable return with a sealed Result type (Discovered, NotConfigured, Failed) so callers can handle each case explicitly, including when OIDC is a prerequisite for the account setup flow. --- .../davdroid/workspace/MurenaOidcDiscovery.kt | 53 ++++++++++++------- .../workspace/MurenaOidcDiscoveryTest.kt | 39 ++++++++++++++ 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt index a47f4dcbe..d32bd532f 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt @@ -32,7 +32,7 @@ import okhttp3.Request * `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/auth` suffix. + * `/protocol/openid-connect` suffix. */ object MurenaOidcDiscovery { @@ -40,23 +40,34 @@ object MurenaOidcDiscovery { private const val KEYCLOAK_OIDC_PATH = "/protocol/openid-connect" /** - * Runs OIDC discovery for [descriptor] and returns a copy with [MurenaWorkspaceDescriptor.oidcIssuer] - * populated. Returns the original descriptor unchanged if discovery fails. + * 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 - ): MurenaWorkspaceDescriptor = withContext(Dispatchers.IO) { - val issuer = fetchIssuer(descriptor.workspaceDomain, httpClient) - if (issuer != null) descriptor.copy(oidcIssuer = issuer) else descriptor - } - - private fun fetchIssuer(workspaceDomain: String, httpClient: OkHttpClient): String? { - val url = "https://$workspaceDomain$OIDC_LOGIN_PATH" + ): Result = withContext(Dispatchers.IO) { + val url = "https://${descriptor.workspaceDomain}$OIDC_LOGIN_PATH" val request = Request.Builder().url(url).build() val noRedirectClient = httpClient.newBuilder() @@ -64,19 +75,24 @@ object MurenaOidcDiscovery { .followSslRedirects(false) .build() - return try { + try { noRedirectClient.newCall(request).execute().use { response -> val location = response.header("Location") if (location == null) { - Logger.log.warning("OIDC discovery: no Location header in response from $url") - null + Logger.log.info("OIDC discovery: $url did not redirect, assuming OIDC not configured") + Result.NotConfigured } else { - extractIssuer(location) + val issuer = extractIssuer(location) + if (issuer != null) { + Result.Discovered(descriptor.copy(oidcIssuer = issuer)) + } else { + Result.NotConfigured + } } } } catch (e: Exception) { - Logger.log.warning("OIDC discovery failed for $workspaceDomain: $e") - null + Logger.log.warning("OIDC discovery failed for ${descriptor.workspaceDomain}: $e") + Result.Failed(e) } } @@ -87,12 +103,13 @@ object MurenaOidcDiscovery { * `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. + * 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.warning("OIDC discovery: unexpected IdP URL format (no '$KEYCLOAK_OIDC_PATH'): $authUrl") + 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/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt index 05b09e20b..d9cbf2230 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt @@ -2,10 +2,15 @@ package at.bitfire.davdroid.workspace import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test 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" + @@ -27,6 +32,11 @@ class MurenaOidcDiscoveryTest { ) } + @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")) @@ -36,4 +46,33 @@ class MurenaOidcDiscoveryTest { 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 + ) + } } -- GitLab From 7ecc375d256488727718bd097ea1509832eef07a Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 8 Apr 2026 18:00:18 +0200 Subject: [PATCH 03/10] docs(workspace): define backend-aware Workspace integration contract Adds package.md (Dokka convention) documenting the authoritative cross-app contract: stable descriptor fields, discovery rules for mail autoconfig and OIDC, internal implementation details, and compatibility expectations. Closes #117 --- .../at/bitfire/davdroid/workspace/package.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/workspace/package.md 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 000000000..884a72ac2 --- /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. -- GitLab From 41540485653c8a9461b0b813956983515a0b577f Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 8 Apr 2026 18:18:14 +0200 Subject: [PATCH 04/10] feat(workspace): persist backend identity and metadata with Android account - Add KEY_WORKSPACE_DOMAIN and KEY_WORKSPACE_OIDC_ISSUER constants to AccountSettings; these are the durable identity and resolved-metadata keys for Murena Workspace accounts. - Extend initialUserData() to accept an optional MurenaWorkspaceDescriptor and write workspace_domain at account-creation time. - Add workspaceDomain(), oidcIssuer(), workspaceDescriptor(), and persistWorkspaceDescriptor() to AccountSettings for callers that need to read or update stored metadata (e.g. after OIDC discovery). - Pass MurenaServerConfig.getDescriptor() when creating eelo accounts in AccountDetailsFragment so workspace_domain is stored from the first login. - Update AccountUtils.isMurenaAccount() to check KEY_WORKSPACE_DOMAIN first; fall back to URL+type matching for legacy accounts that predate this change. Closes #114 --- .../davdroid/settings/AccountSettings.kt | 57 ++++++++++++++++++- .../davdroid/syncadapter/AccountUtils.kt | 7 +++ .../ui/setup/AccountDetailsFragment.kt | 7 ++- 3 files changed, 69 insertions(+), 2 deletions(-) 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 9ad42b35a..594af24ec 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 @@ -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/syncadapter/AccountUtils.kt b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt index feabee062..9472d1f32 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/syncadapter/AccountUtils.kt @@ -169,6 +169,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. val urlData = accountManager.getUserData(account, Constants.KEY_OC_BASE_URL) ?: return false 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 46e2c974d..150b15a44 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 @@ -52,6 +52,7 @@ import at.bitfire.davdroid.syncadapter.AccountUtils import at.bitfire.davdroid.syncadapter.SyncAllAccountWorker import at.bitfire.davdroid.syncadapter.SyncWorker import at.bitfire.davdroid.util.AuthStatePrefUtils +import at.bitfire.davdroid.util.MurenaServerConfig import at.bitfire.vcard4android.GroupMethod import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.utils.AccountManagerUtils @@ -352,7 +353,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()) -- GitLab From 800ead1cb3d46c518e6772d84c22a8024f7142df Mon Sep 17 00:00:00 2001 From: Romain Date: Wed, 8 Apr 2026 18:25:41 +0200 Subject: [PATCH 05/10] feat(workspace): wire OIDC discovery into account setup to persist issuer After a Murena Workspace account is created or re-authenticated, run MurenaOidcDiscovery.discover() and, on success, persist the resolved oidcIssuer via AccountSettings.persistWorkspaceDescriptor(). Discovery is non-blocking: NotConfigured and Failed outcomes are logged but do not abort account setup. The oidcIssuer field remains null until a successful discovery round completes. --- .../ui/setup/AccountDetailsFragment.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 150b15a44..76895c7b9 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,8 +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 @@ -657,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) -- GitLab From 2350cf23e761a9e3383af4833d36c2c7af069fa8 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 10 Apr 2026 15:59:04 +0200 Subject: [PATCH 06/10] fix(workspace): handle transient OIDC discovery failures Classify discovery HTTP responses and retry only transient failures.\nAdd tests covering transient retry, not-configured handling, and IOException retry exhaustion. --- .../davdroid/workspace/MurenaOidcDiscovery.kt | 92 ++++++++++++++++--- .../workspace/MurenaOidcDiscoveryTest.kt | 82 +++++++++++++++++ 2 files changed, 159 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt index d32bd532f..f0cdf6552 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscovery.kt @@ -19,9 +19,11 @@ 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 @@ -38,6 +40,8 @@ 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. @@ -75,25 +79,83 @@ object MurenaOidcDiscovery { .followSslRedirects(false) .build() - try { - noRedirectClient.newCall(request).execute().use { response -> - val location = response.header("Location") - if (location == null) { - Logger.log.info("OIDC discovery: $url did not redirect, assuming OIDC not configured") - Result.NotConfigured - } else { - val issuer = extractIssuer(location) - if (issuer != null) { - Result.Discovered(descriptor.copy(oidcIssuer = issuer)) - } else { - Result.NotConfigured + 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) } - } catch (e: Exception) { - Logger.log.warning("OIDC discovery failed for ${descriptor.workspaceDomain}: $e") - 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) } /** diff --git a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt index d9cbf2230..d4107cccc 100644 --- a/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt +++ b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaOidcDiscoveryTest.kt @@ -1,9 +1,18 @@ 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 { @@ -75,4 +84,77 @@ class MurenaOidcDiscoveryTest { 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() + } } -- GitLab From 2af8f529b9a3df0ddf87a0f4ef71c32e93372ab2 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 10 Apr 2026 16:18:14 +0200 Subject: [PATCH 07/10] feat(workspace): migrate legacy Murena accounts to explicit backend identity Refs: https://gitlab.e.foundation/e/os/AccountManager/-/work_items/118 --- .../davdroid/syncadapter/AccountUtilsTest.kt | 25 +++++++++- .../davdroid/settings/AccountSettings.kt | 2 +- .../settings/AccountSettingsMigrations.kt | 41 +++++++++++++++- .../davdroid/syncadapter/AccountUtils.kt | 13 ++++- doc/workspace-backend-compatibility.md | 47 +++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 doc/workspace-backend-compatibility.md 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 bda06c6dc..82c1b86ca 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/settings/AccountSettings.kt b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt index 594af24ec..95f9f05b3 100644 --- a/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt +++ b/app/src/main/kotlin/at/bitfire/davdroid/settings/AccountSettings.kt @@ -60,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" 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 f39ff8038..2203cf481 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 9472d1f32..26117948c 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 @@ -175,7 +176,7 @@ object AccountUtils { if (workspaceDomain != null) return true // Legacy path: derive identity from URL and account type for accounts that predate - // workspace descriptor persistence. + // workspace descriptor persistence, then persist explicit backend identity. val urlData = accountManager.getUserData(account, Constants.KEY_OC_BASE_URL) ?: return false @@ -190,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/doc/workspace-backend-compatibility.md b/doc/workspace-backend-compatibility.md new file mode 100644 index 000000000..26f4526d9 --- /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. -- GitLab From dfc57bd784e98d910e2d8f35d4364def58434a38 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 10 Apr 2026 16:32:58 +0200 Subject: [PATCH 08/10] feat(workspace): remove hardcoded Murena bootstrap assumptions --- .../kotlin/at/bitfire/davdroid/Constants.kt | 5 -- .../ui/setup/DetectConfigurationFragment.kt | 12 --- .../ui/setup/EeloAuthenticatorFragment.kt | 75 +++---------------- .../davdroid/ui/setup/LoginActivity.kt | 2 - 4 files changed, 9 insertions(+), 85 deletions(-) diff --git a/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt b/app/src/main/kotlin/at/bitfire/davdroid/Constants.kt index 40f23a5a3..5a07dcaa6 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/ui/setup/DetectConfigurationFragment.kt b/app/src/main/kotlin/at/bitfire/davdroid/ui/setup/DetectConfigurationFragment.kt index 91c54f510..78ba74c8f 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 fc658a405..26e302a78 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,7 +32,6 @@ 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.databinding.FragmentEeloAuthenticatorBinding @@ -40,6 +39,7 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout @@ -68,6 +68,10 @@ class EeloAuthenticatorFragment : Fragment() { requireActivity().intent.getStringExtra(SettingsActivity.EXTRA_ACCOUNT_NAME_HINT) } + private val workspaceDescriptor by lazy { + MurenaServerConfig.getDescriptor(requireContext()) + } + private fun isNetworkAvailable(): Boolean { val connectivityManager = requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val activeNetworkInfo = connectivityManager.activeNetworkInfo @@ -147,72 +151,22 @@ 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}" + var domain = workspaceDescriptor.baseWebUrl if (!username.isNullOrBlank() && username.toString().contains("@")) { - var dns = username.toString().substringAfter("@") - if (dns == Constants.E_SYNC_URL) { - dns = Constants.EELO_SYNC_HOST - } + val dns = username.toString().substringAfter("@") domain = "https://$dns" } return domain @@ -259,14 +213,6 @@ 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 { var valid = false @@ -274,11 +220,8 @@ class EeloAuthenticatorFragment : Fragment() { if (serverUrl.isEmpty()) { serverUrl = computeDomain(userIdEditText.text.toString()) - addSupportRetryOn401IfPossible(serverUrl) } - purifyUserName(serverUrl) - fun validateUrl() { model.baseUrlError.value = null @@ -314,8 +257,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 a7dfc9976..69ab6c06e 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 -- GitLab From 31c7dd44da71fc46c7c46c83815c51bc6f32f9cf Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 10 Apr 2026 16:41:30 +0200 Subject: [PATCH 09/10] fix(auth): route to OIDC based on empty server URL --- .../at/bitfire/davdroid/ui/setup/EeloAuthenticatorFragment.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 26e302a78..0fa76db3f 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 @@ -184,7 +184,8 @@ class EeloAuthenticatorFragment : Fragment() { private fun login() { handleNoNetworkAvailable() - val handleOpenIdAuth = EeloAuthenticatorModel.ENABLE_OIDC_SUPPORT && !toggleButtonState + 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() -- GitLab From 7b58e083262b51a867e463afb2d4cc24225c5dc7 Mon Sep 17 00:00:00 2001 From: Romain Date: Fri, 10 Apr 2026 17:25:16 +0200 Subject: [PATCH 10/10] feat(workspace): add backend-aware auth routing for Murena login --- .../ui/setup/EeloAuthenticatorFragment.kt | 41 +++-- .../davdroid/workspace/MurenaAuthRouting.kt | 143 ++++++++++++++++++ .../workspace/MurenaAuthRoutingTest.kt | 68 +++++++++ 3 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRouting.kt create mode 100644 app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt 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 0fa76db3f..3a3e7dee8 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 @@ -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() { 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 000000000..fc2988674 --- /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/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt b/app/src/test/kotlin/at/bitfire/davdroid/workspace/MurenaAuthRoutingTest.kt new file mode 100644 index 000000000..127524bd0 --- /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) + } +} -- GitLab