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

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

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.
parent 300565b6
Loading
Loading
Loading
Loading
Loading
+35 −18
Original line number Diff line number Diff line
@@ -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)
+39 −0
Original line number Diff line number Diff line
@@ -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
        )
    }
}