From f0672ddd53949a961c13bf500903814a1d2a97e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Wed, 21 Jun 2023 10:48:51 +0200 Subject: [PATCH] Add OAuth check to GetAutoDiscovery --- .../account/setup/AccountSetupModule.kt | 3 + .../setup/data/FakeAutoDiscoveryService.kt | 82 ++++++++ .../setup/domain/usecase/GetAutoDiscovery.kt | 106 +++++----- .../account/setup/AccountSetupModuleKtTest.kt | 2 + .../domain/usecase/GetAutoDiscoveryTest.kt | 189 ++++++++++++++++++ 5 files changed, 327 insertions(+), 55 deletions(-) create mode 100644 feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/data/FakeAutoDiscoveryService.kt create mode 100644 feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt index 38a3fd087..5bc3067c7 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/AccountSetupModule.kt @@ -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() @@ -43,6 +45,7 @@ val featureAccountSetupModule: Module = module { single { GetAutoDiscovery( service = get(), + oauthProvider = get(), ) } diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/data/FakeAutoDiscoveryService.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/data/FakeAutoDiscoveryService.kt new file mode 100644 index 000000000..bf32ca0e3 --- /dev/null +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/data/FakeAutoDiscoveryService.kt @@ -0,0 +1,82 @@ +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", + ) + } +} diff --git a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt index b8a21bfbb..509d6d335 100644 --- a/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt +++ b/feature/account/setup/src/main/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscovery.kt @@ -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")) + val email = emailAddress.toUserEmailAddress() + val fakeResult = fakeService.discover(email) + if (fakeResult !is AutoDiscoveryResult.UnexpectedException) { + return fakeResult + } + + val result = service.discover(email) + + return if (result is AutoDiscoveryResult.Settings) { + validateOAuthSupport(result) } else { - null + result } - - if (fakeResult != null) { - return provideWithDelay(fakeResult) - } - - return service.discover(emailAddress.toUserEmailAddress()) } - @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 + } } diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt index 2911e1c57..9e22ed7a4 100644 --- a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/AccountSetupModuleKtTest.kt @@ -1,5 +1,6 @@ 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 { _ -> AccountCreatorResult.Success("accountUuid") } } + single { OAuthConfigurationFactory { emptyMap() } } } @Test diff --git a/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt new file mode 100644 index 000000000..a09b8eaf6 --- /dev/null +++ b/feature/account/setup/src/test/kotlin/app/k9mail/feature/account/setup/domain/usecase/GetAutoDiscoveryTest.kt @@ -0,0 +1,189 @@ +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", + ) + } +}