Merge pull request #6098 from k9mail/sasl_oauthbearer

Add support for the `OAUTHBEARER` SASL method
This commit is contained in:
cketti 2022-06-06 22:30:29 +02:00 committed by GitHub
commit 94c61a7999
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 226 additions and 45 deletions

View file

@ -0,0 +1,31 @@
package com.fsck.k9.sasl
import com.google.common.truth.Truth.assertThat
import okio.ByteString.Companion.decodeBase64
import org.junit.Test
class OAuthBearerTest {
@Test
fun `username that does not need encoding`() {
val username = "user@domain.example"
val token = "token"
val result = buildOAuthBearerInitialClientResponse(username, token)
assertThat(result).isEqualTo("bixhPXVzZXJAZG9tYWluLmV4YW1wbGUsAWF1dGg9QmVhcmVyIHRva2VuAQE=")
assertThat(result.decodeBase64()?.utf8())
.isEqualTo("n,a=user@domain.example,\u0001auth=Bearer token\u0001\u0001")
}
@Test
fun `username contains equal sign that needs to be encoded`() {
val username = "user=name@domain.example"
val token = "token"
val result = buildOAuthBearerInitialClientResponse(username, token)
assertThat(result).isEqualTo("bixhPXVzZXI9M0RuYW1lQGRvbWFpbi5leGFtcGxlLAFhdXRoPUJlYXJlciB0b2tlbgEB")
assertThat(result.decodeBase64()?.utf8())
.isEqualTo("n,a=user=3Dname@domain.example,\u0001auth=Bearer token\u0001\u0001")
}
}

View file

@ -5,6 +5,8 @@ import java.security.MessageDigest;
import com.fsck.k9.mail.filter.Base64;
import com.fsck.k9.mail.filter.Hex;
import okio.ByteString;
public class Authentication {
private static final String US_ASCII = "US-ASCII";
@ -84,11 +86,8 @@ public class Authentication {
}
}
public static String computeXoauth(String username, String authToken) throws UnsupportedEncodingException {
public static String computeXoauth(String username, String authToken) {
String formattedAuthenticationString = String.format(XOAUTH_FORMAT, username, authToken);
byte[] base64encodedAuthenticationString =
Base64.encodeBase64(formattedAuthenticationString.getBytes());
return new String(base64encodedAuthenticationString, US_ASCII);
return ByteString.encodeUtf8(formattedAuthenticationString).base64();
}
}

View file

@ -0,0 +1,14 @@
@file:JvmName("OAuthBearer")
package com.fsck.k9.sasl
import okio.ByteString.Companion.encodeUtf8
/**
* Builds an initial client response for the SASL `OAUTHBEARER` mechanism.
*
* See [RFC 7628](https://datatracker.ietf.org/doc/html/rfc7628).
*/
fun buildOAuthBearerInitialClientResponse(username: String, token: String): String {
val saslName = username.replace(",", "=2C").replace("=", "=3D")
return "n,a=$saslName,\u0001auth=Bearer $token\u0001\u0001".encodeUtf8().base64()
}

View file

@ -6,6 +6,7 @@ class Capabilities {
public static final String CONDSTORE = "CONDSTORE";
public static final String SASL_IR = "SASL-IR";
public static final String AUTH_XOAUTH2 = "AUTH=XOAUTH2";
public static final String AUTH_OAUTHBEARER = "AUTH=OAUTHBEARER";
public static final String AUTH_CRAM_MD5 = "AUTH=CRAM-MD5";
public static final String AUTH_PLAIN = "AUTH=PLAIN";
public static final String AUTH_EXTERNAL = "AUTH=EXTERNAL";

View file

@ -8,6 +8,7 @@ class Commands {
public static final String COMPRESS_DEFLATE = "COMPRESS DEFLATE";
public static final String STARTTLS = "STARTTLS";
public static final String AUTHENTICATE_XOAUTH2 = "AUTHENTICATE XOAUTH2";
public static final String AUTHENTICATE_OAUTHBEARER = "AUTHENTICATE OAUTHBEARER";
public static final String AUTHENTICATE_CRAM_MD5 = "AUTHENTICATE CRAM-MD5";
public static final String AUTHENTICATE_PLAIN = "AUTHENTICATE PLAIN";
public static final String AUTHENTICATE_EXTERNAL = "AUTHENTICATE EXTERNAL";

View file

@ -40,6 +40,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.store.imap.IdGrouper.GroupedIds;
import com.fsck.k9.sasl.OAuthBearer;
import com.jcraft.jzlib.JZlib;
import com.jcraft.jzlib.ZOutputStream;
import javax.net.ssl.SSLException;
@ -88,7 +89,7 @@ class RealImapConnection implements ImapConnection {
private ImapSettings settings;
private Exception stacktraceForClose;
private boolean open = false;
private boolean retryXoauth2WithNewToken = true;
private boolean retryOAuthWithNewToken = true;
public RealImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory,
@ -357,10 +358,14 @@ class RealImapConnection implements ImapConnection {
case XOAUTH2:
if (oauthTokenProvider == null) {
throw new MessagingException("No OAuthToken Provider available.");
} else if (hasCapability(Capabilities.AUTH_XOAUTH2) && hasCapability(Capabilities.SASL_IR)) {
return authXoauth2withSASLIR();
} else if (!hasCapability(Capabilities.SASL_IR)) {
throw new MessagingException("SASL-IR capability is missing.");
} else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) {
return authWithOAuthToken(OAuthMethod.OAUTHBEARER);
} else if (hasCapability(Capabilities.AUTH_XOAUTH2)) {
return authWithOAuthToken(OAuthMethod.XOAUTH2);
} else {
throw new MessagingException("Server doesn't support SASL XOAUTH2.");
throw new MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.");
}
case CRAM_MD5: {
if (hasCapability(Capabilities.AUTH_CRAM_MD5)) {
@ -393,66 +398,67 @@ class RealImapConnection implements ImapConnection {
}
}
private List<ImapResponse> authXoauth2withSASLIR() throws IOException, MessagingException {
retryXoauth2WithNewToken = true;
private List<ImapResponse> authWithOAuthToken(OAuthMethod method) throws IOException, MessagingException {
retryOAuthWithNewToken = true;
try {
return attemptXOAuth2();
return attemptOAuth(method);
} catch (NegativeImapResponseException e) {
//TODO: Check response code so we don't needlessly invalidate the token.
oauthTokenProvider.invalidateToken();
if (!retryXoauth2WithNewToken) {
throw handlePermanentXoauth2Failure(e);
if (!retryOAuthWithNewToken) {
throw handlePermanentOAuthFailure(e);
} else {
return handleTemporaryXoauth2Failure(e);
return handleTemporaryOAuthFailure(method, e);
}
}
}
private AuthenticationFailedException handlePermanentXoauth2Failure(NegativeImapResponseException e) {
Timber.v(e, "Permanent failure during XOAUTH2");
private AuthenticationFailedException handlePermanentOAuthFailure(NegativeImapResponseException e) {
Timber.v(e, "Permanent failure during authentication using OAuth token");
return new AuthenticationFailedException(e.getMessage(), e, e.getAlertText());
}
private List<ImapResponse> handleTemporaryXoauth2Failure(NegativeImapResponseException e) throws IOException, MessagingException {
//We got a response indicating a retry might suceed after token refresh
private List<ImapResponse> handleTemporaryOAuthFailure(OAuthMethod method, NegativeImapResponseException e)
throws IOException, MessagingException {
//We got a response indicating a retry might succeed after token refresh
//We could avoid this 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(e, "Temporary failure - retrying with new token");
try {
return attemptXOAuth2();
return attemptOAuth(method);
} catch (NegativeImapResponseException e2) {
//Okay, we failed on a new token.
//Invalidate the token anyway but assume it's permanent.
Timber.v(e, "Authentication exception for new token, permanent error assumed");
oauthTokenProvider.invalidateToken();
throw handlePermanentXoauth2Failure(e2);
throw handlePermanentOAuthFailure(e2);
}
}
private List<ImapResponse> attemptXOAuth2() throws MessagingException, IOException {
private List<ImapResponse> attemptOAuth(OAuthMethod method) throws MessagingException, IOException {
String token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT);
String authString = Authentication.computeXoauth(settings.getUsername(), token);
String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2, authString, true);
String authString = method.buildInitialClientResponse(settings.getUsername(), token);
String tag = sendSaslIrCommand(method.getCommand(), authString, true);
return responseParser.readStatusResponse(tag, Commands.AUTHENTICATE_XOAUTH2, getLogId(),
return responseParser.readStatusResponse(tag, method.getCommand(), getLogId(),
new UntaggedHandler() {
@Override
public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException {
handleXOAuthUntaggedResponse(response);
handleOAuthUntaggedResponse(response);
}
});
}
private void handleXOAuthUntaggedResponse(ImapResponse response) throws IOException {
private void handleOAuthUntaggedResponse(ImapResponse response) throws IOException {
if (!response.isContinuationRequested()) {
return;
}
if (response.isString(0)) {
retryXoauth2WithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.getHost());
retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.getHost());
}
outputStream.write("\r\n".getBytes());
@ -891,4 +897,33 @@ class RealImapConnection implements ImapConnection {
public int getConnectionGeneration() {
return connectionGeneration;
}
private enum OAuthMethod {
XOAUTH2 {
@Override
String getCommand() {
return Commands.AUTHENTICATE_XOAUTH2;
}
@Override
String buildInitialClientResponse(String username, String token) {
return Authentication.computeXoauth(username, token);
}
},
OAUTHBEARER {
@Override
String getCommand() {
return Commands.AUTHENTICATE_OAUTHBEARER;
}
@Override
String buildInitialClientResponse(String username, String token) {
return OAuthBearer.buildOAuthBearerInitialClientResponse(username, token);
}
};
abstract String getCommand();
abstract String buildInitialClientResponse(String username, String token);
}
}

View file

@ -33,6 +33,7 @@ private const val XOAUTH_TOKEN = "token"
private const val XOAUTH_TOKEN_2 = "token2"
private val XOAUTH_STRING = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64()
private val XOAUTH_STRING_RETRY = "user=$USERNAME\u0001auth=Bearer $XOAUTH_TOKEN_2\u0001\u0001".base64()
private val OAUTHBEARER_STRING = "n,a=$USERNAME,\u0001auth=Bearer $XOAUTH_TOKEN\u0001\u0001".base64()
class RealImapConnectionTest {
private var socketFactory = TestTrustedSocketFactory.newInstance()
@ -317,6 +318,38 @@ class RealImapConnectionTest {
server.verifyInteractionCompleted()
}
@Test
fun `open() AUTH OAUTHBEARER`() {
val server = MockImapServer().apply {
preAuthenticationDialog(capabilities = "SASL-IR AUTH=OAUTHBEARER")
expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING")
output("2 OK Success")
postAuthenticationDialogRequestingCapabilities()
}
val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
imapConnection.open()
server.verifyConnectionStillOpen()
server.verifyInteractionCompleted()
}
@Test
fun `open() AUTH OAUTHBEARER when AUTH=XOAUTH2 and AUTH=OAUTHBEARER capabilities are present`() {
val server = MockImapServer().apply {
preAuthenticationDialog(capabilities = "SASL-IR AUTH=XOAUTH2 AUTH=OAUTHBEARER")
expect("2 AUTHENTICATE OAUTHBEARER $OAUTHBEARER_STRING")
output("2 OK Success")
postAuthenticationDialogRequestingCapabilities()
}
val imapConnection = startServerAndCreateImapConnection(server, authType = AuthType.XOAUTH2)
imapConnection.open()
server.verifyConnectionStillOpen()
server.verifyInteractionCompleted()
}
@Test
fun `open() AUTH XOAUTH2 with SASL-IR`() {
val server = MockImapServer().apply {

View file

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

View file

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