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

Verified Commit 300565b6 authored by Romain Hunault's avatar Romain Hunault 🚴🏻
Browse files

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
parent 71ada9fd
Loading
Loading
Loading
Loading
Loading
+13 −0
Original line number Diff line number Diff line
@@ -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)
    }
}
+100 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package at.bitfire.davdroid.workspace

import 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)
    }
}
+75 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2026 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package at.bitfire.davdroid.workspace

/**
 * 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
    )
}
+39 −0
Original line number Diff line number Diff line
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(""))
    }
}
+105 −0
Original line number Diff line number Diff line
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)
    }
}