diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/AuthType.java b/k9mail-library/src/main/java/com/fsck/k9/mail/AuthType.java index dec1f3b68..70c1967ff 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/AuthType.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/AuthType.java @@ -16,6 +16,13 @@ public enum AuthType { CRAM_MD5, EXTERNAL, + /** + * XOAUTH2 is an OAuth2.0 protocol designed/used by GMail. + * + * https://developers.google.com/gmail/xoauth2_protocol#the_sasl_xoauth2_mechanism + */ + XOAUTH2, + /* * The following are obsolete authentication settings that were used with * SMTP. They are no longer presented to the user as options, but they may diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/Authentication.java b/k9mail-library/src/main/java/com/fsck/k9/mail/Authentication.java index 2872b1b58..1eea2fb40 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/Authentication.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/Authentication.java @@ -1,5 +1,6 @@ package com.fsck.k9.mail; +import java.io.UnsupportedEncodingException; import java.security.MessageDigest; import com.fsck.k9.mail.filter.Base64; @@ -7,6 +8,7 @@ import com.fsck.k9.mail.filter.Hex; public class Authentication { private static final String US_ASCII = "US-ASCII"; + private static final String XOAUTH_FORMAT = "user=%1s\001auth=Bearer %2s\001\001"; /** * Computes the response for CRAM-MD5 authentication mechanism given the user credentials and @@ -82,4 +84,10 @@ public class Authentication { throw new MessagingException("Something went wrong during CRAM-MD5 computation", e); } } + + public static String computeXoauth(String username, String authToken) throws UnsupportedEncodingException { + return new String( + Base64.encodeBase64(String.format(XOAUTH_FORMAT, username, authToken).getBytes()), + US_ASCII); + } } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/Transport.java b/k9mail-library/src/main/java/com/fsck/k9/mail/Transport.java index a549de0d4..d95d7a6e8 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/Transport.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/Transport.java @@ -3,6 +3,7 @@ package com.fsck.k9.mail; import android.content.Context; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory; import com.fsck.k9.mail.store.StoreConfig; import com.fsck.k9.mail.ServerSettings.Type; @@ -19,10 +20,12 @@ public abstract class Transport { // RFC 1047 protected static final int SOCKET_READ_TIMEOUT = 300000; - public synchronized static Transport getInstance(Context context, StoreConfig storeConfig) throws MessagingException { + public synchronized static Transport getInstance(Context context, StoreConfig storeConfig, + OAuth2TokenProvider oauth2TokenProvider) throws MessagingException { String uri = storeConfig.getTransportUri(); if (uri.startsWith("smtp")) { - return new SmtpTransport(storeConfig, new DefaultTrustedSocketFactory(context)); + return new SmtpTransport(storeConfig, new DefaultTrustedSocketFactory(context), + oauth2TokenProvider); } else if (uri.startsWith("webdav")) { return new WebDavTransport(storeConfig); } else { diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/AuthorizationException.java b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/AuthorizationException.java new file mode 100644 index 000000000..d43ed2331 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/AuthorizationException.java @@ -0,0 +1,11 @@ +package com.fsck.k9.mail.oauth; + +public class AuthorizationException extends Exception { + public AuthorizationException(String detailMessage, Throwable throwable) { + super(detailMessage, throwable); + } + + public AuthorizationException(String detailMessage) { + super(detailMessage); + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java new file mode 100644 index 000000000..3d6e3673a --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.java @@ -0,0 +1,61 @@ +package com.fsck.k9.mail.oauth; + +import android.app.Activity; + +import com.fsck.k9.mail.AuthenticationFailedException; + +import java.util.List; + +public interface OAuth2TokenProvider { + + /** + * A default timeout value to use when fetching tokens. + */ + public static final int OAUTH2_TIMEOUT = 30000; + + /** + * @return Accounts suitable for OAuth 2.0 token provision. + */ + List getAccounts(); + + /** + * Provides an asynchronous response to an + * {@link OAuth2TokenProvider#authorizeAPI(String, Activity, OAuth2TokenProviderAuthCallback)} request + */ + interface OAuth2TokenProviderAuthCallback { + + void success(); + + void failure(AuthorizationException e); + } + + /** + * Request API authorization. This is a foreground action that may produce a dialog to interact with. + * @param username Username + * @param activity The responsible activity + * @param callback A callback to process the asynchronous response + */ + void authorizeAPI(String username, Activity activity, + OAuth2TokenProviderAuthCallback callback); + + /** + * Fetch a token. No guarantees are provided for validity. + * @param username Username + * @return Token string + * @throws AuthenticationFailedException + */ + String getToken(String username, long timeoutMillis) throws AuthenticationFailedException; + + /** + * 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. + * + * @param username + */ + void invalidateToken(String username); + +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java new file mode 100644 index 000000000..b0d83a3a3 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/oauth/XOAuth2ChallengeParser.java @@ -0,0 +1,35 @@ +package com.fsck.k9.mail.oauth; + +import android.util.Log; + +import com.fsck.k9.mail.filter.Base64; + +import org.json.JSONException; +import org.json.JSONObject; + +import static com.fsck.k9.mail.K9MailLib.LOG_TAG; + +/** + * Parses Google's Error/Challenge responses + * See: https://developers.google.com/gmail/xoauth2_protocol#error_response + */ +public class XOAuth2ChallengeParser { + public static final String BAD_RESPONSE = "400"; + + public static boolean shouldRetry(String response, String host) { + String decodedResponse = Base64.decode(response); + + Log.v(LOG_TAG, "Challenge response: "+ decodedResponse); + + try { + JSONObject json = new JSONObject(decodedResponse); + if(!json.getString("status").equals(BAD_RESPONSE)) { + return false; + } + } catch (JSONException jsonException) { + Log.i(LOG_TAG, "Error decoding JSON response from:" + + host + ". Response was:" + decodedResponse); + } + return true; + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/RemoteStore.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/RemoteStore.java index fd73dc15f..2738971af 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/RemoteStore.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/RemoteStore.java @@ -1,5 +1,6 @@ package com.fsck.k9.mail.store; +import android.accounts.AccountManager; import android.content.Context; import android.net.ConnectivityManager; @@ -7,6 +8,7 @@ import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mail.ServerSettings.Type; import com.fsck.k9.mail.Store; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.ssl.DefaultTrustedSocketFactory; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.imap.ImapStore; @@ -38,8 +40,9 @@ public abstract class RemoteStore extends Store { /** * Get an instance of a remote mail store. */ - public synchronized static Store getInstance(Context context, - StoreConfig storeConfig) throws MessagingException { + public synchronized static Store getInstance(Context context, StoreConfig storeConfig, + OAuth2TokenProvider oAuth2TokenProvider) + throws MessagingException { String uri = storeConfig.getStoreUri(); if (uri.startsWith("local")) { @@ -49,14 +52,19 @@ public abstract class RemoteStore extends Store { Store store = sStores.get(uri); if (store == null) { if (uri.startsWith("imap")) { - store = new ImapStore(storeConfig, - new DefaultTrustedSocketFactory(context), - (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + store = new ImapStore( + storeConfig, + new DefaultTrustedSocketFactory(context), + (ConnectivityManager) context + .getSystemService(Context.CONNECTIVITY_SERVICE), + oAuth2TokenProvider + ); } else if (uri.startsWith("pop3")) { store = new Pop3Store(storeConfig, - new DefaultTrustedSocketFactory(context)); + new DefaultTrustedSocketFactory(context)); } else if (uri.startsWith("webdav")) { - store = new WebDavStore(storeConfig, new WebDavHttpClient.WebDavHttpClientFactory()); + store = new WebDavStore(storeConfig, + new WebDavHttpClient.WebDavHttpClientFactory()); } if (store != null) { diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java index 2792c021a..57ace0b4c 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java @@ -3,6 +3,8 @@ package com.fsck.k9.mail.store.imap; class Capabilities { public static final String IDLE = "IDLE"; + public static final String SASL_IR = "SASL-IR"; + public static final String AUTH_XOAUTH2 = "AUTH=XOAUTH2"; 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"; diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Commands.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Commands.java index da3dd7933..e1a0171e1 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Commands.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/Commands.java @@ -6,6 +6,7 @@ class Commands { public static final String CAPABILITY = "CAPABILITY"; 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_CRAM_MD5 = "AUTHENTICATE CRAM-MD5"; public static final String AUTHENTICATE_PLAIN = "AUTHENTICATE PLAIN"; public static final String AUTHENTICATE_EXTERNAL = "AUTHENTICATE EXTERNAL"; diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.java index 205a01437..254efdc40 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.java @@ -39,6 +39,8 @@ import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.NetworkType; import com.fsck.k9.mail.filter.Base64; import com.fsck.k9.mail.filter.PeekableInputStream; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; +import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.jcraft.jzlib.JZlib; import com.jcraft.jzlib.ZOutputStream; @@ -61,6 +63,7 @@ class ImapConnection { private final ConnectivityManager connectivityManager; + private final OAuth2TokenProvider oauthTokenProvider; private final TrustedSocketFactory socketFactory; private final int socketConnectTimeout; private final int socketReadTimeout; @@ -74,22 +77,26 @@ class ImapConnection { private ImapSettings settings; private Exception stacktraceForClose; private boolean open = false; + private boolean retryXoauth2WithNewToken = true; public ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, - ConnectivityManager connectivityManager) { + ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider) { this.settings = settings; this.socketFactory = socketFactory; this.connectivityManager = connectivityManager; + this.oauthTokenProvider = oauthTokenProvider; this.socketConnectTimeout = SOCKET_CONNECT_TIMEOUT; this.socketReadTimeout = SOCKET_READ_TIMEOUT; } - ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, ConnectivityManager connectivityManager, + ImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, + ConnectivityManager connectivityManager, OAuth2TokenProvider oauthTokenProvider, int socketConnectTimeout, int socketReadTimeout) { this.settings = settings; this.socketFactory = socketFactory; this.connectivityManager = connectivityManager; + this.oauthTokenProvider = oauthTokenProvider; this.socketConnectTimeout = socketConnectTimeout; this.socketReadTimeout = socketReadTimeout; } @@ -322,6 +329,13 @@ class ImapConnection { @SuppressWarnings("EnumSwitchStatementWhichMissesCases") private void authenticate() throws MessagingException, IOException { switch (settings.getAuthType()) { + case XOAUTH2: + if (hasCapability(Capabilities.AUTH_XOAUTH2) && hasCapability(Capabilities.SASL_IR)) { + authXoauth2withSASLIR(); + } else { + throw new MessagingException("Server doesn't support SASL XOAUTH2."); + } + break; case CRAM_MD5: { if (hasCapability(Capabilities.AUTH_CRAM_MD5)) { authCramMD5(); @@ -356,6 +370,75 @@ class ImapConnection { } } + private void authXoauth2withSASLIR() throws IOException, MessagingException { + retryXoauth2WithNewToken = true; + try { + attemptXOAuth2(); + } catch (NegativeImapResponseException e) { + //We couldn't login with the token so invalidate it + oauthTokenProvider.invalidateToken(settings.getUsername()); + + if (!retryXoauth2WithNewToken) { + handlePermanentXoauth2Failure(e); + } else { + handleTemporaryXoauth2Failure(e); + } + } + } + + private void handlePermanentXoauth2Failure(NegativeImapResponseException e) throws AuthenticationFailedException { + Log.v(LOG_TAG, "Permanent failure during XOAUTH2", e); + throw new AuthenticationFailedException(e.getMessage(), e); + } + + private void handleTemporaryXoauth2Failure(NegativeImapResponseException e) throws IOException, MessagingException { + //We got a response indicating a retry might suceed 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 + + Log.v(LOG_TAG, "Temporary failure - retrying with new token", e); + try { + attemptXOAuth2(); + } catch (NegativeImapResponseException e2) { + //Okay, we failed on a new token. + //Invalidate the token anyway but assume it's permanent. + Log.v(LOG_TAG, "Authentication exception for new token, permanent error assumed", e); + oauthTokenProvider.invalidateToken(settings.getUsername()); + handlePermanentXoauth2Failure(e2); + } + } + + private void attemptXOAuth2() throws MessagingException, IOException { + String token = oauthTokenProvider.getToken(settings.getUsername(), + OAuth2TokenProvider.OAUTH2_TIMEOUT); + String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2, + Authentication.computeXoauth(settings.getUsername(), token), true); + + extractCapabilities( + responseParser.readStatusResponse(tag, Commands.AUTHENTICATE_XOAUTH2, getLogId(), + new UntaggedHandler() { + @Override + public void handleAsyncUntaggedResponse(ImapResponse response) throws IOException { + handleXOAuthUntaggedResponse(response); + + } + }) + ); + } + + private void handleXOAuthUntaggedResponse(ImapResponse response) throws IOException { + if (response.isString(0)) { + retryXoauth2WithNewToken = XOAuth2ChallengeParser.shouldRetry( + response.getString(0), settings.getHost()); + } + + if(response.isContinuationRequested()) { + outputStream.write("\r\n".getBytes()); + outputStream.flush(); + } + } + private void authCramMD5() throws MessagingException, IOException { String command = Commands.AUTHENTICATE_CRAM_MD5; String tag = sendCommand(command, false); @@ -635,6 +718,31 @@ class ImapConnection { return responseParser.readStatusResponse(tag, commandToLog, getLogId(), untaggedHandler); } + public String sendSaslIrCommand(String command, String initialClientResponse, boolean sensitive) + throws IOException, MessagingException { + try { + open(); + + String tag = Integer.toString(nextCommandTag++); + String commandToSend = tag + " " + command + " " + initialClientResponse+ "\r\n"; + outputStream.write(commandToSend.getBytes()); + outputStream.flush(); + + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Log.v(LOG_TAG, getLogId() + ">>> [Command Hidden, Enable Sensitive Debug Logging To Show]"); + } else { + Log.v(LOG_TAG, getLogId() + ">>> " + tag + " " + command+ " " + initialClientResponse); + } + } + + return tag; + } catch (IOException | MessagingException e) { + close(); + throw e; + } + } + public String sendCommand(String command, boolean sensitive) throws MessagingException, IOException { try { open(); diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java index a6293ab50..69243dbe1 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java @@ -109,7 +109,7 @@ class ImapResponseParser { continue; } - if (untaggedHandler != null) { + if (response.getTag() == null && untaggedHandler != null) { untaggedHandler.handleAsyncUntaggedResponse(response); } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java index bed32f88e..29bdd5787 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import android.accounts.AccountManager; import android.net.ConnectivityManager; import android.util.Log; @@ -26,6 +27,7 @@ import com.fsck.k9.mail.NetworkType; import com.fsck.k9.mail.PushReceiver; import com.fsck.k9.mail.Pusher; import com.fsck.k9.mail.ServerSettings; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.RemoteStore; import com.fsck.k9.mail.store.StoreConfig; @@ -42,6 +44,7 @@ import static com.fsck.k9.mail.K9MailLib.LOG_TAG; public class ImapStore extends RemoteStore { private Set permanentFlagsIndex = EnumSet.noneOf(Flag.class); private ConnectivityManager connectivityManager; + private OAuth2TokenProvider oauthTokenProvider; private String host; private int port; @@ -73,8 +76,10 @@ public class ImapStore extends RemoteStore { return ImapStoreUriCreator.create(server); } - public ImapStore(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory, - ConnectivityManager connectivityManager) throws MessagingException { + public ImapStore(StoreConfig storeConfig, + TrustedSocketFactory trustedSocketFactory, + ConnectivityManager connectivityManager, + OAuth2TokenProvider oauthTokenProvider) throws MessagingException { super(storeConfig, trustedSocketFactory); ImapStoreSettings settings; @@ -89,6 +94,7 @@ public class ImapStore extends RemoteStore { connectionSecurity = settings.connectionSecurity; this.connectivityManager = connectivityManager; + this.oauthTokenProvider = oauthTokenProvider; authType = settings.authenticationType; username = settings.username; @@ -340,7 +346,11 @@ public class ImapStore extends RemoteStore { } ImapConnection createImapConnection() { - return new ImapConnection(new StoreImapSettings(), mTrustedSocketFactory, connectivityManager); + return new ImapConnection( + new StoreImapSettings(), + mTrustedSocketFactory, + connectivityManager, + oauthTokenProvider); } FolderNameCodec getFolderNameCodec() { diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreUriDecoder.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreUriDecoder.java index 9014bded8..0715616c8 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreUriDecoder.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreUriDecoder.java @@ -1,6 +1,8 @@ package com.fsck.k9.mail.store.imap; +import android.text.TextUtils; + import java.net.URI; import java.net.URISyntaxException; @@ -83,14 +85,26 @@ class ImapStoreUriDecoder { String[] userInfoParts = userinfo.split(":"); if (userinfo.endsWith(":")) { - // Password is empty. This can only happen after an account was imported. - authenticationType = AuthType.valueOf(userInfoParts[0]); - username = decodeUtf8(userInfoParts[1]); + // Last field (password/certAlias) is empty. + // For imports e.g.: PLAIN:username: or username: + // Or XOAUTH2 where it's a valid config - XOAUTH:username: + if(userInfoParts.length > 1) { + authenticationType = AuthType.valueOf(userInfoParts[0]); + username = decodeUtf8(userInfoParts[1]); + } else { + authenticationType = AuthType.PLAIN; + username = decodeUtf8(userInfoParts[0]); + } } else if (userInfoParts.length == 2) { + // Old/standard style of encoding - PLAIN auth only: + // username:password authenticationType = AuthType.PLAIN; username = decodeUtf8(userInfoParts[0]); password = decodeUtf8(userInfoParts[1]); } else if (userInfoParts.length == 3) { + // Standard encoding + // PLAIN:username:password + // EXTERNAL:username:certAlias authenticationType = AuthType.valueOf(userInfoParts[0]); username = decodeUtf8(userInfoParts[1]); @@ -122,5 +136,6 @@ class ImapStoreUriDecoder { return new ImapStoreSettings(host, port, connectionSecurity, authenticationType, username, password, clientCertificateAlias, autoDetectNamespace, pathPrefix); + } } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java index d3609013e..4bce55424 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java @@ -5,10 +5,8 @@ import com.fsck.k9.mail.MessagingException; class NegativeImapResponseException extends MessagingException { private static final long serialVersionUID = 3725007182205882394L; - private final String alertText; - public NegativeImapResponseException(String message, String alertText) { super(message, true); this.alertText = alertText; diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java index 444d88c3c..7dc724499 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java @@ -1,5 +1,7 @@ package com.fsck.k9.mail.store.imap; +import java.io.IOException; + interface UntaggedHandler { - void handleAsyncUntaggedResponse(ImapResponse response); + void handleAsyncUntaggedResponse(ImapResponse response) throws IOException; } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/transport/SmtpTransport.java b/k9mail-library/src/main/java/com/fsck/k9/mail/transport/SmtpTransport.java index 8ead84bb8..239e226b0 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/transport/SmtpTransport.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/transport/SmtpTransport.java @@ -2,6 +2,7 @@ package com.fsck.k9.mail.transport; import android.support.annotation.VisibleForTesting; +import android.text.TextUtils; import android.util.Log; import com.fsck.k9.mail.*; @@ -13,6 +14,8 @@ import com.fsck.k9.mail.filter.PeekableInputStream; import com.fsck.k9.mail.filter.SmtpDataStuffing; import com.fsck.k9.mail.internet.CharsetSupport; import com.fsck.k9.mail.CertificateValidationException; +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.StoreConfig; @@ -31,7 +34,11 @@ import static com.fsck.k9.mail.K9MailLib.LOG_TAG; import static com.fsck.k9.mail.CertificateValidationException.Reason.MissingCapability; public class SmtpTransport extends Transport { + public static final int SMTP_CONTINUE_REQUEST = 334; + public static final int SMTP_AUTHENTICATION_FAILURE_ERROR_CODE = 535; + private TrustedSocketFactory mTrustedSocketFactory; + private OAuth2TokenProvider oauthTokenProvider; /** * Decodes a SmtpTransport URI. @@ -103,7 +110,7 @@ public class SmtpTransport extends Transport { username = decodeUtf8(userInfoParts[0]); password = decodeUtf8(userInfoParts[1]); } else if (userInfoParts.length == 3) { - // NOTE: In SmptTransport URIs, the authType comes last! + // NOTE: In SmtpTransport URIs, the authType comes last! authType = AuthType.valueOf(userInfoParts[2]); username = decodeUtf8(userInfoParts[0]); if (authType == AuthType.EXTERNAL) { @@ -184,8 +191,10 @@ public class SmtpTransport extends Transport { private OutputStream mOut; private boolean m8bitEncodingAllowed; private int mLargestAcceptableMessage; + private boolean retryXoauthWithNewToken; - public SmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory) + public SmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory, + OAuth2TokenProvider oauth2TokenProvider) throws MessagingException { ServerSettings settings; try { @@ -204,6 +213,7 @@ public class SmtpTransport extends Transport { mPassword = settings.password; mClientCertificateAlias = settings.clientCertificateAlias; mTrustedSocketFactory = trustedSocketFactory; + oauthTokenProvider = oauth2TokenProvider; } @Override @@ -301,18 +311,21 @@ public class SmtpTransport extends Transport { boolean authPlainSupported = false; boolean authCramMD5Supported = false; boolean authExternalSupported = false; + boolean authXoauth2Supported = false; if (extensions.containsKey("AUTH")) { List saslMech = Arrays.asList(extensions.get("AUTH").split(" ")); authLoginSupported = saslMech.contains("LOGIN"); authPlainSupported = saslMech.contains("PLAIN"); authCramMD5Supported = saslMech.contains("CRAM-MD5"); authExternalSupported = saslMech.contains("EXTERNAL"); + authXoauth2Supported = saslMech.contains("XOAUTH2"); } parseOptionalSizeValue(extensions); - if (mUsername != null - && mUsername.length() > 0 - && (mPassword != null && mPassword.length() > 0 || AuthType.EXTERNAL == mAuthType)) { + if (!TextUtils.isEmpty(mUsername) + && (!TextUtils.isEmpty(mPassword) || + AuthType.EXTERNAL == mAuthType || + AuthType.XOAUTH2 == mAuthType)) { switch (mAuthType) { @@ -340,7 +353,13 @@ public class SmtpTransport extends Transport { throw new MessagingException("Authentication method CRAM-MD5 is unavailable."); } break; - + case XOAUTH2: + if (authXoauth2Supported) { + saslXoauth2(mUsername); + } else { + throw new MessagingException("Authentication method XOAUTH2 is unavailable."); + } + break; case EXTERNAL: if (authExternalSupported) { saslAuthExternal(mUsername); @@ -499,7 +518,6 @@ public class SmtpTransport extends Transport { private void sendMessageTo(List addresses, Message message) throws MessagingException { - close(); open(); @@ -638,12 +656,23 @@ public class SmtpTransport extends Transport { throw new NegativeSmtpReplyException(replyCode, message); } - } + } + @Deprecated private List executeSimpleCommand(String command) throws IOException, MessagingException { return executeSimpleCommand(command, false); } + /** + * TODO: All responses should be checked to confirm that they start with a valid + * reply code, and that the reply code is appropriate for the command being executed. + * That means it should either be a 2xx code (generally) or a 3xx code in special cases + * (e.g., DATA & AUTH LOGIN commands). Reply codes should be made available as part of + * the returned object. + * + * This should be doing using the non-deprecated API below. + */ + @Deprecated private List executeSimpleCommand(String command, boolean sensitive) throws IOException, MessagingException { List results = new ArrayList(); @@ -651,17 +680,66 @@ public class SmtpTransport extends Transport { writeLine(command, sensitive); } - /* - * Read lines as long as the length is 4 or larger, e.g. "220-banner text here". - * Shorter lines are either errors of contain only a reply code. Those cases will - * be handled by checkLine() below. - * - * TODO: All responses should be checked to confirm that they start with a valid - * reply code, and that the reply code is appropriate for the command being executed. - * That means it should either be a 2xx code (generally) or a 3xx code in special cases - * (e.g., DATA & AUTH LOGIN commands). Reply codes should be made available as part of - * the returned object. - */ + String line = readCommandResponseLine(results); + + // Check if the reply code indicates an error. + checkLine(line); + + return results; + } + + private static class CommandResponse { + + private final int replyCode; + private final String message; + + public CommandResponse(int replyCode, String message) { + this.replyCode = replyCode; + this.message = message; + } + } + + private CommandResponse executeSimpleCommandWithResponse(String command, boolean sensitive) throws IOException, MessagingException { + List results = new ArrayList(); + if (command != null) { + writeLine(command, sensitive); + } + + String line = readCommandResponseLine(results); + + int length = line.length(); + if (length < 1) { + throw new MessagingException("SMTP response is 0 length"); + } + + int replyCode = -1; + String message = line; + if (length >= 3) { + try { + replyCode = Integer.parseInt(line.substring(0, 3)); + } catch (NumberFormatException e) { /* ignore */ } + + if (length > 4) { + message = line.substring(4); + } else { + message = ""; + } + } + + char c = line.charAt(0); + if ((c == '4') || (c == '5')) { + throw new NegativeSmtpReplyException(replyCode, message); + } + + return new CommandResponse(replyCode, message); + } + + + /* + * Read lines as long as the length is 4 or larger, e.g. "220-banner text here". + * Shorter lines are either errors of contain only a reply code. + */ + private String readCommandResponseLine(List results) throws IOException { String line = readLine(); while (line.length() >= 4) { if (line.length() > 4) { @@ -675,11 +753,7 @@ public class SmtpTransport extends Transport { } line = readLine(); } - - // Check if the reply code indicates an error. - checkLine(line); - - return results; + return line; } @@ -707,7 +781,7 @@ public class SmtpTransport extends Transport { executeSimpleCommand(Base64.encode(username), true); executeSimpleCommand(Base64.encode(password), true); } catch (NegativeSmtpReplyException exception) { - if (exception.getReplyCode() == 535) { + if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { // Authentication credentials invalid throw new AuthenticationFailedException("AUTH LOGIN failed (" + exception.getMessage() + ")"); @@ -723,7 +797,7 @@ public class SmtpTransport extends Transport { try { executeSimpleCommand("AUTH PLAIN " + data, true); } catch (NegativeSmtpReplyException exception) { - if (exception.getReplyCode() == 535) { + if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { // Authentication credentials invalid throw new AuthenticationFailedException("AUTH PLAIN failed (" + exception.getMessage() + ")"); @@ -747,7 +821,7 @@ public class SmtpTransport extends Transport { try { executeSimpleCommand(b64CRAMString, true); } catch (NegativeSmtpReplyException exception) { - if (exception.getReplyCode() == 535) { + if (exception.getReplyCode() == SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { // Authentication credentials invalid throw new AuthenticationFailedException(exception.getMessage(), exception); } else { @@ -756,6 +830,70 @@ public class SmtpTransport extends Transport { } } + private void saslXoauth2(String username) throws MessagingException, IOException { + retryXoauthWithNewToken = true; + try { + attemptXoauth2(username); + } catch (NegativeSmtpReplyException negativeResponse) { + if (negativeResponse.getReplyCode() != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { + throw negativeResponse; + } + + oauthTokenProvider.invalidateToken(username); + + if (!retryXoauthWithNewToken) { + handlePermanentFailure(negativeResponse); + } else { + handleTemporaryFailure(username, negativeResponse); + } + } + } + + private void handlePermanentFailure(NegativeSmtpReplyException negativeResponse) throws AuthenticationFailedException { + throw new AuthenticationFailedException(negativeResponse.getMessage(), negativeResponse); + } + + private void handleTemporaryFailure(String username, NegativeSmtpReplyException negativeResponseFromOldToken) + throws IOException, MessagingException { + // 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 + + Log.v(LOG_TAG, "Authentication exception, re-trying with new token", negativeResponseFromOldToken); + try { + attemptXoauth2(username); + } catch (NegativeSmtpReplyException negativeResponseFromNewToken) { + if (negativeResponseFromNewToken.getReplyCode() != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { + throw negativeResponseFromNewToken; + } + + //Okay, we failed on a new token. + //Invalidate the token anyway but assume it's permanent. + Log.v(LOG_TAG, "Authentication exception for new token, permanent error assumed", + negativeResponseFromNewToken); + + oauthTokenProvider.invalidateToken(username); + + handlePermanentFailure(negativeResponseFromNewToken); + } + } + + private void attemptXoauth2(String username) throws MessagingException, IOException { + CommandResponse response = executeSimpleCommandWithResponse("AUTH XOAUTH2 " + + Authentication.computeXoauth(username, + oauthTokenProvider.getToken(username, OAuth2TokenProvider.OAUTH2_TIMEOUT)), + true); + if(response.replyCode == SMTP_CONTINUE_REQUEST) { + retryXoauthWithNewToken = XOAuth2ChallengeParser.shouldRetry( + response.message, mHost); + + //Per Google spec, respond to challenge with empty response + executeSimpleCommandWithResponse("", false); + } + } + private void saslAuthExternal(String username) throws MessagingException, IOException { executeSimpleCommand( String.format("AUTH EXTERNAL %s", diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/XOAuth2ChallengeParserTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/XOAuth2ChallengeParserTest.java new file mode 100644 index 000000000..e1308940e --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/XOAuth2ChallengeParserTest.java @@ -0,0 +1,15 @@ +package com.fsck.k9.mail; + +import com.fsck.k9.mail.filter.Base64; + + +public class XOAuth2ChallengeParserTest { + public static final String STATUS_400_RESPONSE = Base64.encode( + "{\"status\":\"400\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}"); + public static final String STATUS_401_RESPONSE = Base64.encode( + "{\"status\":\"401\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}"); + public static final String MISSING_STATUS_RESPONSE = Base64.encode( + "{\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\"}"); + public static final String INVALID_RESPONSE = Base64.encode( + "{\"status\":\"401\",\"schemes\":\"bearer mac\",\"scope\":\"https://mail.google.com/\""); +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapConnectionTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapConnectionTest.java index 410d387da..74eb9d63c 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapConnectionTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapConnectionTest.java @@ -13,6 +13,9 @@ import com.fsck.k9.mail.CertificateValidationException.Reason; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.K9MailLib; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.XOAuth2ChallengeParserTest; +import com.fsck.k9.mail.filter.Base64; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.imap.mockserver.MockImapServer; import com.fsck.k9.mail.helpers.TestTrustedSocketFactory; @@ -21,6 +24,7 @@ import okio.ByteString; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowLog; @@ -31,7 +35,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @RunWith(RobolectricTestRunner.class) @@ -45,14 +51,17 @@ public class ImapConnectionTest { private static final int SOCKET_READ_TIMEOUT = 10000; + private TrustedSocketFactory socketFactory; private ConnectivityManager connectivityManager; + private OAuth2TokenProvider oAuth2TokenProvider; private SimpleImapSettings settings; @Before public void setUp() throws Exception { connectivityManager = mock(ConnectivityManager.class); + oAuth2TokenProvider = mock(OAuth2TokenProvider.class); socketFactory = new TestTrustedSocketFactory(); settings = new SimpleImapSettings(); @@ -182,7 +191,7 @@ public class ImapConnectionTest { fail("Expected exception"); } catch (AuthenticationFailedException e) { //FIXME: improve exception message - assertThat(e.getMessage(), containsString("response: #3# [NO, Go away]")); + assertThat(e.getMessage(), containsString("Go away")); } server.verifyConnectionClosed(); @@ -239,7 +248,7 @@ public class ImapConnectionTest { fail("Expected exception"); } catch (AuthenticationFailedException e) { //FIXME: improve exception message - assertThat(e.getMessage(), containsString("response: #2# [NO, Who are you?]")); + assertThat(e.getMessage(), containsString("Who are you?")); } server.verifyConnectionClosed(); @@ -264,6 +273,192 @@ public class ImapConnectionTest { server.verifyInteractionCompleted(); } + @Test + public void open_authXoauthWithSaslIr() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("2 OK Success"); + simplePostAuthenticationDialog(server); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + imapConnection.open(); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_authXoauthWithSaslIrThrowsExeptionOn401Response() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token").thenReturn("token2"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("+ "+ XOAuth2ChallengeParserTest.STATUS_401_RESPONSE); + server.expect(""); + server.output("2 NO SASL authentication failed"); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + try { + imapConnection.open(); + fail(); + } catch (AuthenticationFailedException e) { + assertEquals( + "Command: AUTHENTICATE XOAUTH2; response: #2# [NO, SASL authentication failed]", + e.getMessage()); + } + } + + @Test + public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOn400Response() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token").thenReturn("token2"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("+ "+XOAuth2ChallengeParserTest.STATUS_400_RESPONSE); + server.expect(""); + server.output("2 NO SASL authentication failed"); + server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token2\001\001" + ).base64()); + server.output("3 OK Success"); + simplePostAuthenticationDialog(server, "4"); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + imapConnection.open(); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + inOrder.verify(oAuth2TokenProvider).invalidateToken("user"); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + } + + @Test + public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOnInvalidJsonResponse() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token").thenReturn("token2"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("+ "+XOAuth2ChallengeParserTest.INVALID_RESPONSE); + server.expect(""); + server.output("2 NO SASL authentication failed"); + server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token2\001\001" + ).base64()); + server.output("3 OK Success"); + simplePostAuthenticationDialog(server, "4"); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + imapConnection.open(); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + inOrder.verify(oAuth2TokenProvider).invalidateToken("user"); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + } + + @Test + public void open_authXoauthWithSaslIrInvalidatesAndRetriesNewTokenOnMissingStatusJsonResponse() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token").thenReturn("token2"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("+ "+XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE); + server.expect(""); + server.output("2 NO SASL authentication failed"); + server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token2\001\001" + ).base64()); + server.output("3 OK Success"); + simplePostAuthenticationDialog(server, "4"); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + imapConnection.open(); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + inOrder.verify(oAuth2TokenProvider).invalidateToken("user"); + inOrder.verify(oAuth2TokenProvider).getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT); + } + + @Test + public void open_authXoauthWithSaslIrWithOldTokenThrowsExceptionIfRetryFails() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token").thenReturn("token2"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("+ r3j3krj3irj3oir3ojo"); + server.expect(""); + server.output("2 NO SASL authentication failed"); + server.expect("3 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token2\001\001" + ).base64()); + server.output("+ 433ba3a3a"); + server.expect(""); + server.output("3 NO SASL authentication failed"); + simplePostAuthenticationDialog(server); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + + try { + imapConnection.open(); + fail(); + } catch (AuthenticationFailedException e) { + assertEquals( + "Command: AUTHENTICATE XOAUTH2; response: #3# [NO, SASL authentication failed]", + e.getMessage()); + } + } + + @Test + public void open_authXoauthWithSaslIrParsesCapabilities() throws Exception { + settings.setAuthType(AuthType.XOAUTH2); + when(oAuth2TokenProvider.getToken("user", OAuth2TokenProvider.OAUTH2_TIMEOUT)) + .thenReturn("token"); + MockImapServer server = new MockImapServer(); + preAuthenticationDialog(server, "SASL-IR AUTH=XOAUTH AUTH=XOAUTH2"); + server.expect("2 AUTHENTICATE XOAUTH2 "+ByteString.encodeUtf8( + "user=user\001auth=Bearer token\001\001" + ).base64()); + server.output("2 OK [CAPABILITY IMAP4REV1 IDLE XM-GM-EXT-1]"); + simplePostAuthenticationDialog(server); + ImapConnection imapConnection = startServerAndCreateImapConnection(server); + imapConnection.open(); + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + assertTrue(imapConnection.hasCapability("XM-GM-EXT-1")); + } + @Test public void open_authExternal() throws Exception { settings.setAuthType(AuthType.EXTERNAL); @@ -286,7 +481,7 @@ public class ImapConnectionTest { MockImapServer server = new MockImapServer(); preAuthenticationDialog(server, "AUTH=EXTERNAL"); server.expect("2 AUTHENTICATE EXTERNAL " + ByteString.encodeUtf8(USERNAME).base64()); - server.output("2 NO"); + server.output("2 NO Bad certificate"); ImapConnection imapConnection = startServerAndCreateImapConnection(server); try { @@ -294,7 +489,7 @@ public class ImapConnectionTest { fail("Expected exception"); } catch (CertificateValidationException e) { //FIXME: improve exception message - assertThat(e.getMessage(), containsString("response: #2# [NO]")); + assertThat(e.getMessage(), containsString("Bad certificate")); } server.verifyConnectionClosed(); @@ -338,13 +533,13 @@ public class ImapConnectionTest { public void open_withConnectionError_shouldThrow() throws Exception { settings.setHost("127.1.2.3"); settings.setPort(143); - ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager); + ImapConnection imapConnection = createImapConnection( + settings, socketFactory, connectivityManager, oAuth2TokenProvider); try { imapConnection.open(); fail("Expected exception"); } catch (MessagingException e) { - //FIXME: Throw ConnectException assertEquals("Cannot connect to host", e.getMessage()); assertTrue(e.getCause() instanceof IOException); } @@ -354,7 +549,8 @@ public class ImapConnectionTest { public void open_withInvalidHostname_shouldThrow() throws Exception { settings.setHost("host name"); settings.setPort(143); - ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager); + ImapConnection imapConnection = createImapConnection( + settings, socketFactory, connectivityManager, oAuth2TokenProvider); try { imapConnection.open(); @@ -423,7 +619,7 @@ public class ImapConnectionTest { imapConnection.open(); fail("Expected exception"); } catch (NegativeImapResponseException e) { - assertThat(e.getMessage(), containsString("response: #2# [NO]")); + assertEquals(e.getMessage(), "Command: STARTTLS; response: #2# [NO]"); } server.verifyConnectionClosed(); @@ -522,7 +718,8 @@ public class ImapConnectionTest { @Test public void isConnected_withoutPreviousOpen_shouldReturnFalse() throws Exception { - ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager); + ImapConnection imapConnection = createImapConnection( + settings, socketFactory, connectivityManager, oAuth2TokenProvider); boolean result = imapConnection.isConnected(); @@ -558,7 +755,8 @@ public class ImapConnectionTest { @Test public void close_withoutOpen_shouldNotThrow() throws Exception { - ImapConnection imapConnection = createImapConnection(settings, socketFactory, connectivityManager); + ImapConnection imapConnection = createImapConnection( + settings, socketFactory, connectivityManager, oAuth2TokenProvider); imapConnection.close(); } @@ -620,16 +818,16 @@ public class ImapConnectionTest { } private ImapConnection createImapConnection(ImapSettings settings, TrustedSocketFactory socketFactory, - ConnectivityManager connectivityManager) { - return new ImapConnection(settings, socketFactory, connectivityManager, SOCKET_CONNECT_TIMEOUT, - SOCKET_READ_TIMEOUT); + ConnectivityManager connectivityManager, OAuth2TokenProvider oAuth2TokenProvider) { + return new ImapConnection(settings, socketFactory, connectivityManager, oAuth2TokenProvider, + SOCKET_CONNECT_TIMEOUT, SOCKET_READ_TIMEOUT); } private ImapConnection startServerAndCreateImapConnection(MockImapServer server) throws IOException { server.start(); settings.setHost(server.getHost()); settings.setPort(server.getPort()); - return createImapConnection(settings, socketFactory, connectivityManager); + return createImapConnection(settings, socketFactory, connectivityManager, oAuth2TokenProvider); } private ImapConnection simpleOpen(MockImapServer server) throws Exception { diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java index 114e8ec3d..a1f748599 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapResponseParserTest.java @@ -84,7 +84,8 @@ public class ImapResponseParserTest { @Test public void testReadStatusResponseWithOKResponse() throws Exception { - ImapResponseParser parser = createParser("* COMMAND BAR\tBAZ\r\nTAG OK COMMAND completed\r\n"); + ImapResponseParser parser = createParser("* COMMAND BAR\tBAZ\r\n" + + "TAG OK COMMAND completed\r\n"); List responses = parser.readStatusResponse("TAG", null, null, null); @@ -93,6 +94,19 @@ public class ImapResponseParserTest { assertEquals(asList("OK", "COMMAND completed"), responses.get(1)); } + @Test + public void testReadStatusResponseUntaggedHandlerGetsUntaggedOnly() throws Exception { + ImapResponseParser parser = createParser( + "* UNTAGGED\r\n" + + "A2 OK COMMAND completed\r\n"); + TestUntaggedHandler untaggedHandler = new TestUntaggedHandler(); + + parser.readStatusResponse("A2", null, null, untaggedHandler); + + assertEquals(1, untaggedHandler.responses.size()); + assertEquals(asList("UNTAGGED"), untaggedHandler.responses.get(0)); + } + @Test public void testReadStatusResponseSkippingWrongTag() throws Exception { ImapResponseParser parser = createParser("* UNTAGGED\r\n" + @@ -113,6 +127,23 @@ public class ImapResponseParserTest { assertEquals(responses.get(1), untaggedHandler.responses.get(2)); } + @Test + public void testReadStatusResponseUntaggedHandlerStillCalledOnNegativeReply() throws Exception { + ImapResponseParser parser = createParser( + "+ text\r\n" + + "A2 NO Bad response\r\n"); + TestUntaggedHandler untaggedHandler = new TestUntaggedHandler(); + + try { + List responses = parser.readStatusResponse("A2", null, null, untaggedHandler); + } catch (NegativeImapResponseException e) { + } + + assertEquals(1, untaggedHandler.responses.size()); + assertEquals(asList("text"), untaggedHandler.responses.get(0)); + + } + @Test(expected = NegativeImapResponseException.class) public void testReadStatusResponseWithErrorResponse() throws Exception { ImapResponseParser parser = createParser("* COMMAND BAR BAZ\r\nTAG ERROR COMMAND errored\r\n"); diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreTest.java index 4771df051..30462d392 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreTest.java @@ -14,6 +14,7 @@ import android.net.ConnectivityManager; import com.fsck.k9.mail.Folder; import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.oauth.OAuth2TokenProvider; import com.fsck.k9.mail.ssl.TrustedSocketFactory; import com.fsck.k9.mail.store.StoreConfig; import org.junit.Before; @@ -44,8 +45,9 @@ public class ImapStoreTest { storeConfig = createStoreConfig(); TrustedSocketFactory trustedSocketFactory = mock(TrustedSocketFactory.class); ConnectivityManager connectivityManager = mock(ConnectivityManager.class); + OAuth2TokenProvider oauth2TokenProvider = mock(OAuth2TokenProvider.class); - imapStore = new TestImapStore(storeConfig, trustedSocketFactory, connectivityManager); + imapStore = new TestImapStore(storeConfig, trustedSocketFactory, connectivityManager, oauth2TokenProvider); } @Test @@ -313,8 +315,8 @@ public class ImapStoreTest { private Deque imapConnections = new ArrayDeque<>(); public TestImapStore(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory, - ConnectivityManager connectivityManager) throws MessagingException { - super(storeConfig, trustedSocketFactory, connectivityManager); + ConnectivityManager connectivityManager, OAuth2TokenProvider oauth2TokenProvider) throws MessagingException { + super(storeConfig, trustedSocketFactory, connectivityManager, oauth2TokenProvider); } @Override diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreUriTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreUriTest.java index 38d9572e2..63a1c428d 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreUriTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/store/imap/ImapStoreUriTest.java @@ -16,6 +16,88 @@ import static org.junit.Assert.assertNull; public class ImapStoreUriTest { + @Test + public void testDecodeStoreUriImapNoAuth() { + String uri = "imap://user:pass@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(AuthType.PLAIN, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals("pass", settings.password); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapNoPassword() { + String uri = "imap://user:@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(AuthType.PLAIN, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals(null, settings.password); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapPlainNoPassword() { + String uri = "imap://PLAIN:user:@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(AuthType.PLAIN, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals(null, settings.password); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapExternalAuth() { + String uri = "imap://EXTERNAL:user:clientCertAlias@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(AuthType.EXTERNAL, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals(null, settings.password); + assertEquals("clientCertAlias", settings.clientCertificateAlias); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapXOAuth2() { + String uri = "imap://XOAUTH2:user:@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(AuthType.XOAUTH2, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals(null, settings.password); + assertEquals(null, settings.clientCertificateAlias); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapSSL() { + String uri = "imap+tls+://PLAIN:user:pass@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(ConnectionSecurity.STARTTLS_REQUIRED, settings.connectionSecurity); + assertEquals(AuthType.PLAIN, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals("pass", settings.password); + assertEquals("server", settings.host); + } + + @Test + public void testDecodeStoreUriImapTLS() { + String uri = "imap+ssl+://PLAIN:user:pass@server/"; + ServerSettings settings = RemoteStore.decodeStoreUri(uri); + + assertEquals(ConnectionSecurity.SSL_TLS_REQUIRED, settings.connectionSecurity); + assertEquals(AuthType.PLAIN, settings.authenticationType); + assertEquals("user", settings.username); + assertEquals("pass", settings.password); + assertEquals("server", settings.host); + } + + @Test public void testDecodeStoreUriImapAllExtras() { String uri = "imap://PLAIN:user:pass@server:143/0%7CcustomPathPrefix"; diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/transport/SmtpTransportTest.java b/k9mail-library/src/test/java/com/fsck/k9/mail/transport/SmtpTransportTest.java index 88072f146..ed6e6f3c6 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/transport/SmtpTransportTest.java +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/transport/SmtpTransportTest.java @@ -5,28 +5,38 @@ import java.io.IOException; import java.net.InetAddress; import com.fsck.k9.mail.AuthType; +import com.fsck.k9.mail.AuthenticationFailedException; import com.fsck.k9.mail.CertificateValidationException; import com.fsck.k9.mail.ConnectionSecurity; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mail.ServerSettings.Type; +import com.fsck.k9.mail.XOAuth2ChallengeParserTest; import com.fsck.k9.mail.filter.Base64; import com.fsck.k9.mail.helpers.TestMessageBuilder; import com.fsck.k9.mail.helpers.TestTrustedSocketFactory; import com.fsck.k9.mail.internet.MimeMessage; +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.StoreConfig; import com.fsck.k9.mail.transport.mockServer.MockSmtpServer; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import static junit.framework.Assert.assertTrue; import static junit.framework.Assert.fail; import static org.junit.Assert.assertEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -41,25 +51,29 @@ public class SmtpTransportTest { private TrustedSocketFactory socketFactory; + private OAuth2TokenProvider oAuth2TokenProvider; @Before - public void before() { + public void before() throws AuthenticationFailedException { socketFactory = new TestTrustedSocketFactory(); + oAuth2TokenProvider = mock(OAuth2TokenProvider.class); + when(oAuth2TokenProvider.getToken(eq(USERNAME), anyInt())) + .thenReturn("oldToken").thenReturn("newToken"); } @Test public void SmtpTransport_withValidTransportUri() throws Exception { StoreConfig storeConfig = createStoreConfigWithTransportUri("smtp://user:password:CRAM_MD5@server:123456"); - new SmtpTransport(storeConfig, socketFactory); + new SmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider); } @Test(expected = MessagingException.class) public void SmtpTransport_withInvalidTransportUri_shouldThrow() throws Exception { StoreConfig storeConfig = createStoreConfigWithTransportUri("smpt://"); - new SmtpTransport(storeConfig, socketFactory); + new SmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider); } @Test @@ -174,6 +188,205 @@ public class SmtpTransportTest { server.verifyInteractionCompleted(); } + @Test + public void open_withXoauth2Extension() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("235 2.7.0 Authentication successful"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + transport.open(); + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldThrowOn401Response() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("334 "+ XOAuth2ChallengeParserTest.STATUS_401_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + try { + transport.open(); + fail("Exception expected"); + } catch (AuthenticationFailedException e) { + assertEquals( + "Negative SMTP reply: 535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68", + e.getMessage()); + } + + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME); + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldInvalidateAndRetryOn400Response() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("334 "+ XOAuth2ChallengeParserTest.STATUS_400_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE="); + server.output("235 2.7.0 Authentication successful"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + transport.open(); + + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldInvalidateAndRetryOnInvalidJsonResponse() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("334 "+ XOAuth2ChallengeParserTest.INVALID_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE="); + server.output("235 2.7.0 Authentication successful"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + transport.open(); + + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldInvalidateAndRetryOnMissingStatusJsonResponse() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("334 "+ XOAuth2ChallengeParserTest.MISSING_STATUS_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE="); + server.output("235 2.7.0 Authentication successful"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + transport.open(); + + InOrder inOrder = inOrder(oAuth2TokenProvider); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + inOrder.verify(oAuth2TokenProvider).invalidateToken(USERNAME); + inOrder.verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyInt()); + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldThrowOnMultipleFailure() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG9sZFRva2VuAQE="); + server.output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + server.expect("AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIG5ld1Rva2VuAQE="); + server.output("334 " + XOAuth2ChallengeParserTest.STATUS_400_RESPONSE); + server.expect(""); + server.output("535-5.7.1 Username and Password not accepted. Learn more at"); + server.output("535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + try { + transport.open(); + fail("Exception expected"); + } catch (AuthenticationFailedException e) { + assertEquals( + "Negative SMTP reply: 535 5.7.1 http://support.google.com/mail/bin/answer.py?answer=14257 hx9sm5317360pbc.68", + e.getMessage()); + } + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + @Test + public void open_withXoauth2Extension_shouldThrowOnFailure_fetchingToken() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH XOAUTH2"); + when(oAuth2TokenProvider.getToken(anyString(), anyInt())).thenThrow(new AuthenticationFailedException("Failed to fetch token")); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + try { + transport.open(); + fail("Exception expected"); + } catch (AuthenticationFailedException e) { + assertEquals("Failed to fetch token", e.getMessage()); + } + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + + + @Test + public void open_withoutXoauth2Extension_shouldThrow() throws Exception { + MockSmtpServer server = new MockSmtpServer(); + server.output("220 localhost Simple Mail Transfer Service Ready"); + server.expect("EHLO localhost"); + server.output("250-localhost Hello client.localhost"); + server.output("250 AUTH PLAIN LOGIN"); + SmtpTransport transport = startServerAndCreateSmtpTransport(server, AuthType.XOAUTH2, ConnectionSecurity.NONE); + + try { + transport.open(); + fail("Exception expected"); + } catch (MessagingException e) { + assertEquals("Authentication method XOAUTH2 is unavailable.", e.getMessage()); + } + + server.verifyConnectionStillOpen(); + server.verifyInteractionCompleted(); + } + @Test public void open_withAuthExternalExtension() throws Exception { MockSmtpServer server = new MockSmtpServer(); @@ -413,7 +626,7 @@ public class SmtpTransportTest { String uri = SmtpTransport.createUri(serverSettings); StoreConfig storeConfig = createStoreConfigWithTransportUri(uri); - return new TestSmtpTransport(storeConfig, socketFactory); + return new TestSmtpTransport(storeConfig, socketFactory, oAuth2TokenProvider); } private StoreConfig createStoreConfigWithTransportUri(String value) { @@ -452,9 +665,9 @@ public class SmtpTransportTest { static class TestSmtpTransport extends SmtpTransport { - TestSmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory) + TestSmtpTransport(StoreConfig storeConfig, TrustedSocketFactory trustedSocketFactory, OAuth2TokenProvider oAuth2TokenProvider) throws MessagingException { - super(storeConfig, trustedSocketFactory); + super(storeConfig, trustedSocketFactory, oAuth2TokenProvider); } @Override