diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index 9ed38fd824..c6cb0ffa1d 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -24,6 +24,7 @@ import com.fsck.k9.mail.oauth.OAuth2TokenProvider import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser import com.fsck.k9.mail.ssl.TrustedSocketFactory import com.fsck.k9.mail.transport.smtp.SmtpHelloResponse.Hello +import com.fsck.k9.sasl.buildOAuthBearerInitialClientResponse import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.IOException @@ -61,7 +62,7 @@ class SmtpTransport( private var is8bitEncodingAllowed = false private var isEnhancedStatusCodesProvided = false private var largestAcceptableMessage = 0 - private var retryXoauthWithNewToken = false + private var retryOAuthWithNewToken = false private var isPipeliningSupported = false private val logger: SmtpLogger = object : SmtpLogger { @@ -157,6 +158,7 @@ class SmtpTransport( var authCramMD5Supported = false var authExternalSupported = false var authXoauth2Supported = false + var authOAuthBearerSupported = false val saslMechanisms = extensions["AUTH"] if (saslMechanisms != null) { authLoginSupported = saslMechanisms.contains("LOGIN") @@ -164,6 +166,7 @@ class SmtpTransport( authCramMD5Supported = saslMechanisms.contains("CRAM-MD5") authExternalSupported = saslMechanisms.contains("EXTERNAL") authXoauth2Supported = saslMechanisms.contains("XOAUTH2") + authOAuthBearerSupported = saslMechanisms.contains("OAUTHBEARER") } parseOptionalSizeValue(extensions["SIZE"]) @@ -190,10 +193,14 @@ class SmtpTransport( } } AuthType.XOAUTH2 -> { - if (authXoauth2Supported && oauthTokenProvider != null) { - saslXoauth2() + if (oauthTokenProvider == null) { + throw MessagingException("No OAuth2TokenProvider available.") + } else if (authOAuthBearerSupported) { + saslOAuth(OAuthMethod.OAUTHBEARER) + } else if (authXoauth2Supported) { + saslOAuth(OAuthMethod.XOAUTH2) } else { - throw MessagingException("Authentication method XOAUTH2 is unavailable.") + throw MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.") } } AuthType.EXTERNAL -> { @@ -550,10 +557,10 @@ class SmtpTransport( } } - private fun saslXoauth2() { - retryXoauthWithNewToken = true + private fun saslOAuth(method: OAuthMethod) { + retryOAuthWithNewToken = true try { - attemptXoauth2(username) + attempOAuth(method, username) } catch (negativeResponse: NegativeSmtpReplyException) { if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { throw negativeResponse @@ -561,10 +568,10 @@ class SmtpTransport( oauthTokenProvider!!.invalidateToken() - if (!retryXoauthWithNewToken) { + if (!retryOAuthWithNewToken) { handlePermanentFailure(negativeResponse) } else { - handleTemporaryFailure(username, negativeResponse) + handleTemporaryFailure(method, username, negativeResponse) } } } @@ -573,13 +580,17 @@ class SmtpTransport( throw AuthenticationFailedException(negativeResponse.message!!, negativeResponse) } - private fun handleTemporaryFailure(username: String, negativeResponseFromOldToken: NegativeSmtpReplyException) { + private fun handleTemporaryFailure( + method: OAuthMethod, + username: String, + negativeResponseFromOldToken: NegativeSmtpReplyException + ) { // Token was invalid. We could avoid this double check if we had a reasonable chance of knowing if a token was // invalid before use (e.g. due to expiry). But we don't. This is the intended behaviour per AccountManager. Timber.v(negativeResponseFromOldToken, "Authentication exception, re-trying with new token") try { - attemptXoauth2(username) + attempOAuth(method, username) } catch (negativeResponseFromNewToken: NegativeSmtpReplyException) { if (negativeResponseFromNewToken.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { throw negativeResponseFromNewToken @@ -593,14 +604,14 @@ class SmtpTransport( } } - private fun attemptXoauth2(username: String) { + private fun attempOAuth(method: OAuthMethod, username: String) { val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong()) - val authString = Authentication.computeXoauth(username, token) + val authString = method.buildInitialClientResponse(username, token) - val response = executeSensitiveCommand("AUTH XOAUTH2 %s", authString) + val response = executeSensitiveCommand("%s %s", method.command, authString) if (response.replyCode == SMTP_CONTINUE_REQUEST) { val replyText = response.joinedText - retryXoauthWithNewToken = XOAuth2ChallengeParser.shouldRetry(replyText, host) + retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(replyText, host) // Per Google spec, respond to challenge with empty response executeCommand("") @@ -622,3 +633,23 @@ class SmtpTransport( } } } + +private enum class OAuthMethod { + XOAUTH2 { + override val command = "AUTH XOAUTH2" + + override fun buildInitialClientResponse(username: String, token: String): String { + return Authentication.computeXoauth(username, token) + } + }, + OAUTHBEARER { + override val command = "AUTH OAUTHBEARER" + + override fun buildInitialClientResponse(username: String, token: String): String { + return buildOAuthBearerInitialClientResponse(username, token) + } + }; + + abstract val command: String + abstract fun buildInitialClientResponse(username: String, token: String): String +} diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt index 7819737d98..a0c135f6ff 100644 --- a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpTransportTest.kt @@ -170,6 +170,42 @@ class SmtpTransportTest { server.verifyInteractionCompleted() } + @Test + fun `open() with OAUTHBEARER method`() { + 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 AUTH OAUTHBEARER") + expect("AUTH OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + + @Test + fun `open() with OAUTHBEARER method when XOAUTH2 method is also available`() { + 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 AUTH XOAUTH2 OAUTHBEARER") + expect("AUTH OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE=") + output("235 2.7.0 Authentication successful") + } + val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2) + + transport.open() + + server.verifyConnectionStillOpen() + server.verifyInteractionCompleted() + } + @Test fun `open() with XOAUTH2 extension`() { val server = MockSmtpServer().apply { @@ -386,7 +422,7 @@ class SmtpTransportTest { transport.open() fail("Exception expected") } catch (e: MessagingException) { - assertThat(e).hasMessageThat().isEqualTo("Authentication method XOAUTH2 is unavailable.") + assertThat(e).hasMessageThat().isEqualTo("Server doesn't support SASL OAUTHBEARER or XOAUTH2.") } server.verifyConnectionClosed() @@ -546,7 +582,7 @@ class SmtpTransportTest { output("250-smtp.gmail.com at your service, [86.147.34.216]") output("250-SIZE 35882577") output("250-8BITMIME") - output("250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH") + output("250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN XOAUTH") output("250-ENHANCEDSTATUSCODES") output("250-PIPELINING") output("250-CHUNKING")