Skip to content

Fixes Outlook outgoing server autoconfig failure #9089

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class K9OAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e647013a-ada4-4114-b419-e43d250f99c5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class K9OAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e647013a-ada4-4114-b419-e43d250f99c5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory {
) to OAuthConfiguration(
clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5",
scopes = listOf(
"openid",
"email",
"https://outlook.office.com/IMAP.AccessAsUser.All",
"https://outlook.office.com/SMTP.Send",
"offline_access",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class AccountEditModuleKtTest : KoinTest {
single<OAuth2TokenProviderFactory> {
OAuth2TokenProviderFactory { _ ->
object : OAuth2TokenProvider {
override val primaryEmail: String? get() = TODO()
override fun getToken(timeoutMillis: Long) = TODO()
override fun invalidateToken() = TODO()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ServerValidationModuleKtTest : KoinTest {
single<OAuth2TokenProviderFactory> {
OAuth2TokenProviderFactory { _ ->
object : OAuth2TokenProvider {
override val primaryEmail: String? get() = TODO()
override fun getToken(timeoutMillis: Long) = TODO()
override fun invalidateToken() = TODO()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class AccountSetupModuleKtTest : KoinTest {
single<OAuth2TokenProviderFactory> {
OAuth2TokenProviderFactory { _ ->
object : OAuth2TokenProvider {
override val primaryEmail: String? get() = TODO()
override fun getToken(timeoutMillis: Long) = TODO()
override fun invalidateToken() = TODO()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,26 @@ import timber.log.Timber
class RealOAuth2TokenProvider(
context: Context,
private val authStateStorage: AuthStateStorage,

) : OAuth2TokenProvider {
private val authService = AuthorizationService(context)
private var requestFreshToken = false
private val authState
get() = authStateStorage.getAuthorizationState()
?.let { AuthState.jsonDeserialize(it) }
?: throw AuthenticationFailedException("Login required")

override val primaryEmail: String?
get() = authState.parsedIdToken
?.additionalClaims
?.get("email")
?.toString()

@Suppress("TooGenericExceptionCaught")
override fun getToken(timeoutMillis: Long): String {
val latch = CountDownLatch(1)
var token: String? = null
var exception: AuthorizationException? = null

val authState = authStateStorage.getAuthorizationState()?.let { AuthState.jsonDeserialize(it) }
?: throw AuthenticationFailedException("Login required")

if (requestFreshToken) {
authState.needsTokenRefresh = true
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.fsck.k9.mail.oauth

import com.fsck.k9.mail.AuthenticationFailedException

interface OAuth2TokenProvider {
companion object {
/**
* A default timeout value to use when fetching tokens.
*/
const val OAUTH2_TIMEOUT = 30000L
}

/**
* Fetch the primary email found in the id_token additional claims,
* if it is available.
*
* > Some providers, like Microsoft, require this as they need the primary account email to be the username,
* not the email the user entered
*
* @return the primary email present in the id_token, otherwise null.
*/
val primaryEmail: String?
@Throws(AuthenticationFailedException::class)
get

/**
* Fetch a token. No guarantees are provided for validity.
*/
@Throws(AuthenticationFailedException::class)
fun getToken(timeoutMillis: Long): String

/**
* Invalidate the token for this username.
*
* Note that the token should always be invalidated on credential failure. However invalidating a token every
* single time is not recommended.
*
* Invalidating a token and then failure with a new token should be treated as a permanent failure.
*/
fun invalidateToken()
}
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ internal class RealImapConnection(
val oauthTokenProvider = checkNotNull(oauthTokenProvider)
val responseParser = checkNotNull(responseParser)

val token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
val token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT)

val authString = method.buildInitialClientResponse(settings.username, token)
val tag = sendSaslIrCommand(method.command, authString, true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ class ImapServerSettingsValidatorTest {
}
}

class FakeOAuth2TokenProvider : OAuth2TokenProvider {
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
override fun getToken(timeoutMillis: Long): String {
return AUTHORIZATION_TOKEN
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1233,7 +1233,7 @@ class RealImapConnectionTest {
}
}

class TestTokenProvider : OAuth2TokenProvider {
class TestTokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
private var invalidationCount = 0

override fun getToken(timeoutMillis: Long): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,20 +551,27 @@ class SmtpTransport(
}

private fun saslOAuth(method: OAuthMethod) {
checkNotNull(oauthTokenProvider) {
"Can't perform saslOAuth with a null OAuthTokenProvider."
}
retryOAuthWithNewToken = true

val primaryEmail = oauthTokenProvider.primaryEmail
val primaryUsername = primaryEmail ?: username

try {
attempOAuth(method, username)
attempOAuth(method, primaryUsername)
} catch (negativeResponse: NegativeSmtpReplyException) {
if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
throw negativeResponse
}

oauthTokenProvider!!.invalidateToken()
oauthTokenProvider.invalidateToken()

if (!retryOAuthWithNewToken) {
handlePermanentOAuthFailure(method, negativeResponse)
} else {
handleTemporaryOAuthFailure(method, username, negativeResponse)
handleTemporaryOAuthFailure(method, primaryUsername, negativeResponse)
}
}
}
Expand Down Expand Up @@ -605,7 +612,7 @@ class SmtpTransport(
}

private fun attempOAuth(method: OAuthMethod, username: String) {
val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT)
val authString = method.buildInitialClientResponse(username, token)

val response = executeSensitiveCommand("%s %s", method.command, authString)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.fsck.k9.mail.testing.security.SimpleTrustedSocketFactory
import com.fsck.k9.mail.transport.mockServer.MockSmtpServer
import java.net.UnknownHostException
import kotlin.test.Test
import okio.ByteString.Companion.encodeUtf8

private const val USERNAME = "user"
private const val PASSWORD = "password"
Expand Down Expand Up @@ -107,6 +108,53 @@ class SmtpServerSettingsValidatorTest {
server.verifyInteractionCompleted()
}

@Test
fun `valid server settings with primary email different from username on OAuth should return Success`() {
val expectedUser = "expected@email.com"
val serverSettingsValidator = SmtpServerSettingsValidator(
trustedSocketFactory = trustedSocketFactory,
oAuth2TokenProviderFactory = { authStateStorage ->
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
FakeOAuth2TokenProvider(primaryEmail = expectedUser)
},
)
val server = MockSmtpServer().apply {
output("220 localhost Simple Mail Transfer Service Ready")
expect("EHLO [127.0.0.1]")
output("250-localhost Hello client.localhost")
output("250-ENHANCEDSTATUSCODES")
output("250-AUTH PLAIN LOGIN XOAUTH2")
output("250 HELP")
val ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001"
.encodeUtf8()
.base64()
expect("AUTH XOAUTH2 $ouathBearer")
output("235 2.7.0 Authentication successful")
expect("QUIT")
closeConnection()
}
server.start()
val serverSettings = ServerSettings(
type = "smtp",
host = server.host,
port = server.port,
connectionSecurity = ConnectionSecurity.NONE,
authenticationType = AuthType.XOAUTH2,
username = USERNAME,
password = null,
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
)
val authStateStorage = FakeAuthStateStorage(
authorizationState = AUTHORIZATION_STATE,
)

val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage)

assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
}

@Test
fun `authentication error should return AuthenticationError`() {
val server = MockSmtpServer().apply {
Expand Down Expand Up @@ -334,7 +382,7 @@ class SmtpServerSettingsValidatorTest {
}
}

class FakeOAuth2TokenProvider : OAuth2TokenProvider {
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
override fun getToken(timeoutMillis: Long): String {
return AUTHORIZATION_TOKEN
}
Expand Down