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

Unverified Commit f0672ddd authored by Wolf-Martell Montwé's avatar Wolf-Martell Montwé Committed by Wolf-Martell Montwé
Browse files

Add OAuth check to GetAutoDiscovery

parent 23b0e79a
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ package app.k9mail.feature.account.setup

import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.service.RealAutoDiscoveryService
import app.k9mail.core.common.coreCommonModule
import app.k9mail.feature.account.setup.domain.DomainContract
import app.k9mail.feature.account.setup.domain.usecase.CheckIncomingServerConfig
import app.k9mail.feature.account.setup.domain.usecase.CheckOutgoingServerConfig
@@ -29,6 +30,7 @@ import org.koin.core.module.Module
import org.koin.dsl.module

val featureAccountSetupModule: Module = module {
    includes(coreCommonModule)

    single<OkHttpClient> {
        OkHttpClient()
@@ -43,6 +45,7 @@ val featureAccountSetupModule: Module = module {
    single<DomainContract.UseCase.GetAutoDiscovery> {
        GetAutoDiscovery(
            service = get(),
            oauthProvider = get(),
        )
    }

+82 −0
Original line number Diff line number Diff line
package app.k9mail.feature.account.setup.data

import app.k9mail.autodiscovery.api.AuthenticationType
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.api.ConnectionSecurity
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import app.k9mail.core.common.mail.EmailAddress
import app.k9mail.core.common.net.toHostname
import app.k9mail.core.common.net.toPort
import java.io.IOException
import java.lang.UnsupportedOperationException
import kotlin.random.Random
import kotlinx.coroutines.delay

class FakeAutoDiscoveryService : AutoDiscoveryService {
    override suspend fun discover(email: EmailAddress): AutoDiscoveryResult {
        val result: AutoDiscoveryResult? = handleFakeResponse(email)
        return if (result != null) {
            provideWithDelay(result)
        } else {
            AutoDiscoveryResult.UnexpectedException(
                UnsupportedOperationException("No fake response for $email"),
            )
        }
    }

    @Suppress("MagicNumber")
    private suspend fun provideWithDelay(autoDiscoveryResult: AutoDiscoveryResult): AutoDiscoveryResult {
        delay(Random(0).nextLong(500, 2000))
        return autoDiscoveryResult
    }

    private fun handleFakeResponse(emailAddress: EmailAddress): AutoDiscoveryResult? {
        return if (emailAddress.localPart.contains("empty")) {
            AutoDiscoveryResult.NoUsableSettingsFound
        } else if (emailAddress.localPart.contains("test")) {
            getFakeAutoDiscovery(emailAddress)
        } else if (emailAddress.localPart.contains("error")) {
            AutoDiscoveryResult.NetworkError(IOException("Failed to load config"))
        } else if (emailAddress.localPart.contains("unexpected")) {
            AutoDiscoveryResult.UnexpectedException(Exception("Unexpected exception"))
        } else {
            null
        }
    }

    @Suppress("MagicNumber")
    private fun getFakeAutoDiscovery(emailAddress: EmailAddress): AutoDiscoveryResult.Settings {
        val hasIncomingOauth = emailAddress.localPart.contains("in")
        val hasOutgoingOauth = emailAddress.localPart.contains("out")
        val isTrusted = emailAddress.localPart.contains("trust")

        return AutoDiscoveryResult.Settings(
            incomingServerSettings = ImapServerSettings(
                hostname = "imap.${emailAddress.domain}".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = if (hasIncomingOauth) {
                    AuthenticationType.OAuth2
                } else {
                    AuthenticationType.PasswordEncrypted
                },
                username = "username",
            ),
            outgoingServerSettings = SmtpServerSettings(
                hostname = "smtp.${emailAddress.domain}".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = if (hasOutgoingOauth) {
                    AuthenticationType.OAuth2
                } else {
                    AuthenticationType.PasswordEncrypted
                },
                username = "username",
            ),
            isTrusted = isTrusted,
            source = "fake",
        )
    }
}
+50 −54
Original line number Diff line number Diff line
@@ -3,79 +3,75 @@ package app.k9mail.feature.account.setup.domain.usecase
import app.k9mail.autodiscovery.api.AuthenticationType
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.api.ConnectionSecurity
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import app.k9mail.core.common.mail.toUserEmailAddress
import app.k9mail.core.common.net.toHostname
import app.k9mail.core.common.net.toPort
import app.k9mail.core.common.oauth.OAuthConfigurationProvider
import app.k9mail.feature.account.setup.data.FakeAutoDiscoveryService
import app.k9mail.feature.account.setup.domain.DomainContract
import java.io.IOException
import kotlin.random.Random
import kotlinx.coroutines.delay

internal class GetAutoDiscovery(
    private val service: AutoDiscoveryService,
    private val oauthProvider: OAuthConfigurationProvider,
    private val fakeService: FakeAutoDiscoveryService = FakeAutoDiscoveryService(),
) : DomainContract.UseCase.GetAutoDiscovery {
    override suspend fun execute(emailAddress: String): AutoDiscoveryResult {
        val fakeResult: AutoDiscoveryResult? = if (emailAddress.contains("empty")) {
            AutoDiscoveryResult.NoUsableSettingsFound
        } else if (emailAddress.contains("test")) {
            getFakeAutoDiscovery(emailAddress)
        } else if (emailAddress.contains("error")) {
            AutoDiscoveryResult.NetworkError(IOException("Failed to load config"))
        } else if (emailAddress.contains("unexpected")) {
            AutoDiscoveryResult.UnexpectedException(Exception("Unexpected exception"))
        } else {
            null
        val email = emailAddress.toUserEmailAddress()
        val fakeResult = fakeService.discover(email)
        if (fakeResult !is AutoDiscoveryResult.UnexpectedException) {
            return fakeResult
        }

        if (fakeResult != null) {
            return provideWithDelay(fakeResult)
        }
        val result = service.discover(email)

        return service.discover(emailAddress.toUserEmailAddress())
        return if (result is AutoDiscoveryResult.Settings) {
            validateOAuthSupport(result)
        } else {
            result
        }
    }

    @Suppress("MagicNumber")
    private suspend fun provideWithDelay(autoDiscoveryResult: AutoDiscoveryResult): AutoDiscoveryResult {
        delay(Random(0).nextLong(500, 2000))
        return autoDiscoveryResult
    private fun validateOAuthSupport(settings: AutoDiscoveryResult.Settings): AutoDiscoveryResult {
        if (settings.incomingServerSettings !is ImapServerSettings) {
            return AutoDiscoveryResult.NoUsableSettingsFound
        }

    @Suppress("MagicNumber")
    private fun getFakeAutoDiscovery(emailAddress: String): AutoDiscoveryResult.Settings {
        val hasIncomingOauth = emailAddress.contains("in")
        val hasOutgoingOauth = emailAddress.contains("out")
        val isTrusted = emailAddress.contains("trust")
        val incomingServerSettings = settings.incomingServerSettings as ImapServerSettings
        val outgoingServerSettings = settings.outgoingServerSettings as SmtpServerSettings

        return AutoDiscoveryResult.Settings(
            incomingServerSettings = ImapServerSettings(
                hostname = "imap.${getHost(emailAddress)}".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = if (hasIncomingOauth) {
                    AuthenticationType.OAuth2
                } else {
                    AuthenticationType.PasswordEncrypted
                },
                username = "username",
        val incomingAuthenticationType = updateAuthenticationType(
            authenticationType = incomingServerSettings.authenticationType,
            hostname = incomingServerSettings.hostname.value,
        )
        val outgoingAuthenticationType = updateAuthenticationType(
            authenticationType = outgoingServerSettings.authenticationType,
            hostname = outgoingServerSettings.hostname.value,
        )

        return settings.copy(
            incomingServerSettings = incomingServerSettings.copy(
                authenticationType = incomingAuthenticationType,
            ),
            outgoingServerSettings = SmtpServerSettings(
                hostname = "smtp.${getHost(emailAddress)}".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = if (hasOutgoingOauth) {
                    AuthenticationType.OAuth2
                } else {
                    AuthenticationType.PasswordEncrypted
                },
                username = "username",
            outgoingServerSettings = outgoingServerSettings.copy(
                authenticationType = outgoingAuthenticationType,
            ),
            isTrusted = isTrusted,
            source = "fake",
        )
    }

    private fun getHost(emailAddress: String) = emailAddress.split("@").last()
    private fun updateAuthenticationType(
        authenticationType: AuthenticationType,
        hostname: String,
    ): AuthenticationType {
        return if (authenticationType == AuthenticationType.OAuth2 && !isOAuthSupportedFor(hostname)) {
            // OAuth2 is not supported for this hostname, downgrade to password cleartext
            // TODO replace with next supported authentication type, once populated by autodiscovery
            AuthenticationType.PasswordCleartext
        } else {
            authenticationType
        }
    }

    private fun isOAuthSupportedFor(hostname: String): Boolean {
        return oauthProvider.getConfiguration(hostname) != null
    }
}
+2 −0
Original line number Diff line number Diff line
package app.k9mail.feature.account.setup

import app.k9mail.core.common.oauth.OAuthConfigurationFactory
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator
import app.k9mail.feature.account.setup.AccountSetupExternalContract.AccountCreator.AccountCreatorResult
import app.k9mail.feature.account.setup.ui.AccountSetupContract
@@ -34,6 +35,7 @@ class AccountSetupModuleKtTest : KoinTest {
        single<AccountCreator> {
            AccountCreator { _ -> AccountCreatorResult.Success("accountUuid") }
        }
        single<OAuthConfigurationFactory> { OAuthConfigurationFactory { emptyMap() } }
    }

    @Test
+189 −0
Original line number Diff line number Diff line
package app.k9mail.feature.account.setup.domain.usecase

import app.k9mail.autodiscovery.api.AuthenticationType
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryService
import app.k9mail.autodiscovery.api.ConnectionSecurity
import app.k9mail.autodiscovery.api.ImapServerSettings
import app.k9mail.autodiscovery.api.IncomingServerSettings
import app.k9mail.autodiscovery.api.SmtpServerSettings
import app.k9mail.core.common.mail.EmailAddress
import app.k9mail.core.common.net.toHostname
import app.k9mail.core.common.net.toPort
import app.k9mail.core.common.oauth.OAuthConfiguration
import app.k9mail.core.common.oauth.OAuthConfigurationProvider
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import kotlinx.coroutines.test.runTest
import org.junit.Test

class GetAutoDiscoveryTest {

    @Test
    fun `should return a valid result`() = runTest {
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(SETTINGS_WITH_PASSWORD),
            oauthProvider = FakeOAuthConfigurationProvider(OAUTH_CONFIGURATION),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.Settings::class)
            .isEqualTo(SETTINGS_WITH_PASSWORD)
    }

    @Test
    fun `should return NoUsableSettingsFound result`() = runTest {
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(AutoDiscoveryResult.NoUsableSettingsFound),
            oauthProvider = FakeOAuthConfigurationProvider(),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.NoUsableSettingsFound::class)
    }

    @Test
    fun `should return NoUsableSettingsFound result when server settings not supported`() = runTest {
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(SETTINGS_WITH_UNSUPPORTED_SERVER),
            oauthProvider = FakeOAuthConfigurationProvider(),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.NoUsableSettingsFound::class)
    }

    @Test
    fun `should return UnexpectedException result`() = runTest {
        val autoDiscoveryResult = AutoDiscoveryResult.UnexpectedException(Exception("unexpected exception"))
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(autoDiscoveryResult),
            oauthProvider = FakeOAuthConfigurationProvider(),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.UnexpectedException::class)
            .isEqualTo(autoDiscoveryResult)
    }

    @Test
    fun `should check for oauth support and return when supported`() = runTest {
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(SETTINGS_WITH_OAUTH),
            oauthProvider = FakeOAuthConfigurationProvider(OAUTH_CONFIGURATION),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.Settings::class)
            .isEqualTo(SETTINGS_WITH_OAUTH)
    }

    @Test
    fun `should check for oauth support and change auth type to password when not supported`() = runTest {
        val useCase = GetAutoDiscovery(
            service = FakeAutoDiscoveryService(SETTINGS_WITH_OAUTH),
            oauthProvider = FakeOAuthConfigurationProvider(),
        )

        val result = useCase.execute("user@example.com")

        assertThat(result)
            .isInstanceOf(AutoDiscoveryResult.Settings::class)
            .isEqualTo(
                SETTINGS_WITH_OAUTH.copy(
                    incomingServerSettings = (SETTINGS_WITH_OAUTH.incomingServerSettings as ImapServerSettings).copy(
                        authenticationType = AuthenticationType.PasswordCleartext,
                    ),
                    outgoingServerSettings = (SETTINGS_WITH_OAUTH.outgoingServerSettings as SmtpServerSettings).copy(
                        authenticationType = AuthenticationType.PasswordCleartext,
                    ),
                ),
            )
    }

    private class FakeAutoDiscoveryService(
        private val answer: AutoDiscoveryResult = AutoDiscoveryResult.NoUsableSettingsFound,
    ) : AutoDiscoveryService {
        override suspend fun discover(email: EmailAddress): AutoDiscoveryResult = answer
    }

    private class FakeOAuthConfigurationProvider(
        private val answer: OAuthConfiguration? = null,
    ) : OAuthConfigurationProvider {
        override fun getConfiguration(hostname: String): OAuthConfiguration? = answer
    }

    private class UnsupportedServerSettings : IncomingServerSettings

    private companion object {
        private val SETTINGS_WITH_OAUTH = AutoDiscoveryResult.Settings(
            incomingServerSettings = ImapServerSettings(
                hostname = "imap.example.com".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = AuthenticationType.OAuth2,
                username = "user",
            ),
            outgoingServerSettings = SmtpServerSettings(
                hostname = "smtp.example.com".toHostname(),
                port = 465.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = AuthenticationType.OAuth2,
                username = "user",
            ),
            isTrusted = true,
            source = "source",
        )

        private val SETTINGS_WITH_UNSUPPORTED_SERVER = AutoDiscoveryResult.Settings(
            incomingServerSettings = UnsupportedServerSettings(),
            outgoingServerSettings = SmtpServerSettings(
                hostname = "smtp.example.com".toHostname(),
                port = 465.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = AuthenticationType.OAuth2,
                username = "user",
            ),
            isTrusted = true,
            source = "source",
        )

        private val SETTINGS_WITH_PASSWORD = AutoDiscoveryResult.Settings(
            incomingServerSettings = ImapServerSettings(
                hostname = "imap.example.com".toHostname(),
                port = 993.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = AuthenticationType.PasswordCleartext,
                username = "user",
            ),
            outgoingServerSettings = SmtpServerSettings(
                hostname = "smtp.example.com".toHostname(),
                port = 465.toPort(),
                connectionSecurity = ConnectionSecurity.TLS,
                authenticationType = AuthenticationType.PasswordCleartext,
                username = "user",
            ),
            isTrusted = true,
            source = "source",
        )

        private val OAUTH_CONFIGURATION = OAuthConfiguration(
            clientId = "clientId",
            scopes = listOf("scopes"),
            authorizationEndpoint = "authorizationEndpoint",
            tokenEndpoint = "tokenEndpoint",
            redirectUri = "redirectUri",
        )
    }
}