Add OAuth check to GetAutoDiscovery

This commit is contained in:
Wolf-Martell Montwé 2023-06-21 10:48:51 +02:00 committed by Wolf-Martell Montwé
parent 23b0e79a0e
commit f0672ddd53
No known key found for this signature in database
GPG key ID: 6D45B21512ACBF72
5 changed files with 327 additions and 55 deletions

View file

@ -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(),
)
}

View file

@ -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",
)
}
}

View file

@ -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
}
}

View file

@ -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> {
AccountCreator { _ -> AccountCreatorResult.Success("accountUuid") }
}
single<OAuthConfigurationFactory> { OAuthConfigurationFactory { emptyMap() } }
}
@Test

View file

@ -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",
)
}
}