Merge pull request #6082 from k9mail/oauth_setup_flow

Add support for OAuth 2.0 (Gmail)
This commit is contained in:
cketti 2022-05-28 15:44:22 +02:00 committed by GitHub
commit 5065afef88
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 651 additions and 95 deletions

View file

@ -104,7 +104,14 @@ class ProvidersXmlDiscovery(
uri.port
}
return DiscoveredServerSettings(Protocols.IMAP, host, port, security, AuthType.PLAIN, username)
// TODO: Remove this hack
val authType = if (host == "imap.gmail.com" || host == "imap.googlemail.com") {
AuthType.XOAUTH2
} else {
AuthType.PLAIN
}
return DiscoveredServerSettings(Protocols.IMAP, host, port, security, authType, username)
}
private fun Provider.toOutgoingServerSettings(email: String): DiscoveredServerSettings? {
@ -127,7 +134,14 @@ class ProvidersXmlDiscovery(
uri.port
}
return DiscoveredServerSettings(Protocols.SMTP, host, port, security, AuthType.PLAIN, username)
// TODO: Remove this hack
val authType = if (host == "smtp.gmail.com" || host == "smtp.googlemail.com") {
AuthType.XOAUTH2
} else {
AuthType.PLAIN
}
return DiscoveredServerSettings(Protocols.SMTP, host, port, security, authType, username)
}
private fun String.fillInUsernameTemplate(email: String, user: String, domain: String): String {

View file

@ -20,13 +20,13 @@ class ProvidersXmlDiscoveryTest : RobolectricTest() {
with(connectionSettings!!.incoming.first()) {
assertThat(host).isEqualTo("imap.gmail.com")
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
assertThat(authType).isEqualTo(AuthType.PLAIN)
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
assertThat(username).isEqualTo("user@gmail.com")
}
with(connectionSettings.outgoing.first()) {
assertThat(host).isEqualTo("smtp.gmail.com")
assertThat(security).isEqualTo(ConnectionSecurity.SSL_TLS_REQUIRED)
assertThat(authType).isEqualTo(AuthType.PLAIN)
assertThat(authType).isEqualTo(AuthType.XOAUTH2)
assertThat(username).isEqualTo("user@gmail.com")
}
}

View file

@ -34,6 +34,10 @@ class Account(override val uuid: String) : BaseAccount {
internalOutgoingServerSettings = value
}
@get:Synchronized
@set:Synchronized
var oAuthState: String? = null
/**
* Storage provider ID, used to locate and manage the underlying DB/file storage.
*/

View file

@ -36,6 +36,7 @@ class AccountPreferenceSerializer(
outgoingServerSettings = serverSettingsSerializer.deserialize(
storage.getString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", "")
)
oAuthState = storage.getString("$accountUuid.oAuthState", null)
localStorageProviderId = storage.getString("$accountUuid.localStorageProvider", storageManager.defaultProviderId)
name = storage.getString("$accountUuid.description", null)
alwaysBcc = storage.getString("$accountUuid.alwaysBcc", alwaysBcc)
@ -240,6 +241,7 @@ class AccountPreferenceSerializer(
with(account) {
editor.putString("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(incomingServerSettings))
editor.putString("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY", serverSettingsSerializer.serialize(outgoingServerSettings))
editor.putString("$accountUuid.oAuthState", oAuthState)
editor.putString("$accountUuid.localStorageProvider", localStorageProviderId)
editor.putString("$accountUuid.description", name)
editor.putString("$accountUuid.alwaysBcc", alwaysBcc)
@ -359,6 +361,7 @@ class AccountPreferenceSerializer(
editor.remove("$accountUuid.$INCOMING_SERVER_SETTINGS_KEY")
editor.remove("$accountUuid.$OUTGOING_SERVER_SETTINGS_KEY")
editor.remove("$accountUuid.oAuthState")
editor.remove("$accountUuid.description")
editor.remove("$accountUuid.name")
editor.remove("$accountUuid.email")

View file

@ -201,7 +201,7 @@ class Preferences internal constructor(
val defaultAccount: Account?
get() = accounts.firstOrNull()
fun saveAccount(account: Account) {
override fun saveAccount(account: Account) {
ensureAssignedAccountNumber(account)
processChangedValues(account)

View file

@ -13,4 +13,5 @@ interface AccountManager {
fun moveAccount(account: Account, newPosition: Int)
fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener)
fun saveAccount(account: Account)
}

View file

@ -65,6 +65,12 @@ android {
signingConfigs {
release
debug {
keyAlias = "androiddebugkey"
keyPassword = "android"
storeFile = file("$rootProject.projectDir/debug.keystore")
storePassword = "android"
}
}
buildTypes {
@ -76,16 +82,22 @@ android {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "boolean", "DEVELOPER_MODE", "false"
buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-hhmh92rhklkg2k1tjil69epo0o9a12jm.apps.googleusercontent.com\""
manifestPlaceholders = ['appAuthRedirectScheme': 'com.fsck.k9']
}
debug {
applicationIdSuffix ".debug"
testCoverageEnabled rootProject.testCoverage
signingConfig signingConfigs.debug
minifyEnabled false
buildConfigField "boolean", "DEVELOPER_MODE", "true"
buildConfigField "String", "OAUTH_GMAIL_CLIENT_ID", "\"262622259280-5qb3vtj68d5dtudmaif4g9vd3cpar8r3.apps.googleusercontent.com\""
manifestPlaceholders = ['appAuthRedirectScheme': 'com.fsck.k9.debug']
}
}

View file

@ -1,5 +1,7 @@
package com.fsck.k9
import com.fsck.k9.activity.setup.OAuthCredentials
import com.fsck.k9.auth.K9OAuthCredentials
import com.fsck.k9.backends.backendsModule
import com.fsck.k9.controller.ControllerExtension
import com.fsck.k9.crypto.EncryptionExtractor
@ -29,6 +31,7 @@ private val mainAppModule = module {
single(named("controllerExtensions")) { emptyList<ControllerExtension>() }
single<EncryptionExtractor> { OpenPgpEncryptionExtractor.newInstance() }
single<StoragePersister> { K9StoragePersister(get()) }
factory<OAuthCredentials> { K9OAuthCredentials() }
}
val appModules = listOf(

View file

@ -0,0 +1,9 @@
package com.fsck.k9.auth
import com.fsck.k9.BuildConfig
import com.fsck.k9.activity.setup.OAuthCredentials
class K9OAuthCredentials : OAuthCredentials {
override val gmailClientId: String
get() = BuildConfig.OAUTH_GMAIL_CLIENT_ID
}

View file

@ -1,11 +1,12 @@
package com.fsck.k9.backends
import android.content.Context
import com.fsck.k9.Account
import com.fsck.k9.backend.BackendFactory
import com.fsck.k9.backend.api.Backend
import com.fsck.k9.backend.imap.ImapBackend
import com.fsck.k9.backend.imap.ImapPushConfigProvider
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.power.PowerManager
import com.fsck.k9.mail.ssl.TrustedSocketFactory
import com.fsck.k9.mail.store.imap.IdleRefreshManager
@ -23,7 +24,8 @@ class ImapBackendFactory(
private val powerManager: PowerManager,
private val idleRefreshManager: IdleRefreshManager,
private val backendStorageFactory: K9BackendStorageFactory,
private val trustedSocketFactory: TrustedSocketFactory
private val trustedSocketFactory: TrustedSocketFactory,
private val context: Context
) : BackendFactory {
override fun createBackend(account: Account): Backend {
val accountName = account.displayName
@ -44,7 +46,12 @@ class ImapBackendFactory(
}
private fun createImapStore(account: Account): ImapStore {
val oAuth2TokenProvider: OAuth2TokenProvider? = null
val oAuth2TokenProvider = if (account.incomingServerSettings.authenticationType == AuthType.XOAUTH2) {
RealOAuth2TokenProvider(context, accountManager, account)
} else {
null
}
val config = createImapStoreConfig(account)
return ImapStore.create(
account.incomingServerSettings,
@ -67,7 +74,12 @@ class ImapBackendFactory(
private fun createSmtpTransport(account: Account): SmtpTransport {
val serverSettings = account.outgoingServerSettings
val oauth2TokenProvider: OAuth2TokenProvider? = null
val oauth2TokenProvider = if (serverSettings.authenticationType == AuthType.XOAUTH2) {
RealOAuth2TokenProvider(context, accountManager, account)
} else {
null
}
return SmtpTransport(serverSettings, trustedSocketFactory, oauth2TokenProvider)
}

View file

@ -26,7 +26,8 @@ val backendsModule = module {
powerManager = get(),
idleRefreshManager = get(),
backendStorageFactory = get(),
trustedSocketFactory = get()
trustedSocketFactory = get(),
context = get()
)
}
single<SystemAlarmManager> { AndroidAlarmManager(context = get(), alarmManager = get()) }

View file

@ -0,0 +1,65 @@
package com.fsck.k9.backends
import android.content.Context
import com.fsck.k9.Account
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
import com.fsck.k9.preferences.AccountManager
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationService
class RealOAuth2TokenProvider(
context: Context,
private val accountManager: AccountManager,
private val account: Account
) : OAuth2TokenProvider {
private val authService = AuthorizationService(context)
private var requestFreshToken = false
override fun getToken(timeoutMillis: Long): String {
val latch = CountDownLatch(1)
var token: String? = null
var exception: AuthorizationException? = null
val authState = account.oAuthState?.let { AuthState.jsonDeserialize(it) }
?: throw AuthenticationFailedException("Login required")
if (requestFreshToken) {
authState.needsTokenRefresh = true
}
val oldAccessToken = authState.accessToken
authState.performActionWithFreshTokens(authService) { accessToken: String?, _, authException: AuthorizationException? ->
token = accessToken
exception = authException
latch.countDown()
}
latch.await(timeoutMillis, TimeUnit.MILLISECONDS)
if (exception != null || token != oldAccessToken) {
requestFreshToken = false
account.oAuthState = authState.jsonSerializeString()
accountManager.saveAccount(account)
}
exception?.let { authException ->
throw AuthenticationFailedException(
message = "Failed to fetch an access token",
throwable = authException,
messageFromServer = authException.error
)
}
return token ?: throw AuthenticationFailedException("Failed to fetch an access token")
}
override fun invalidateToken() {
requestFreshToken = true
}
}

View file

@ -40,6 +40,7 @@ dependencies {
implementation "com.mikepenz:fastadapter-extensions-drag:${versions.fastAdapter}"
implementation "com.mikepenz:fastadapter-extensions-utils:${versions.fastAdapter}"
implementation 'de.hdodenhof:circleimageview:3.1.0'
api 'net.openid:appauth:0.11.1'
implementation "commons-io:commons-io:${versions.commonsIo}"
implementation "androidx.core:core-ktx:${versions.androidxCore}"

View file

@ -1,7 +1,10 @@
package com.fsck.k9.activity
import com.fsck.k9.activity.setup.AuthViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val activityModule = module {
single { MessageLoaderHelperFactory(get(), get()) }
single { MessageLoaderHelperFactory(messageViewInfoExtractorFactory = get(), htmlSettingsProvider = get()) }
viewModel { AuthViewModel(application = get(), accountManager = get(), oauthCredentials = get()) }
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.CheckBox
@ -25,6 +26,8 @@ import com.fsck.k9.mailstore.SpecialLocalFoldersCreator
import com.fsck.k9.ui.ConnectionSettings
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import com.fsck.k9.ui.getEnum
import com.fsck.k9.ui.putEnum
import com.fsck.k9.ui.settings.ExtraAccountDiscovery
import com.fsck.k9.view.ClientCertificateSpinner
import com.google.android.material.textfield.TextInputEditText
@ -46,12 +49,15 @@ class AccountSetupBasics : K9Activity() {
private lateinit var emailView: TextInputEditText
private lateinit var passwordView: TextInputEditText
private lateinit var passwordLayout: View
private lateinit var clientCertificateCheckBox: CheckBox
private lateinit var clientCertificateSpinner: ClientCertificateSpinner
private lateinit var advancedOptionsContainer: View
private lateinit var nextButton: Button
private lateinit var manualSetupButton: Button
private lateinit var allowClientCertificateView: ViewGroup
private var uiState = UiState.EMAIL_ADDRESS_ONLY
private var account: Account? = null
private var checkedIncoming = false
@ -62,14 +68,15 @@ class AccountSetupBasics : K9Activity() {
emailView = findViewById(R.id.account_email)
passwordView = findViewById(R.id.account_password)
passwordLayout = findViewById(R.id.account_password_layout)
clientCertificateCheckBox = findViewById(R.id.account_client_certificate)
clientCertificateSpinner = findViewById(R.id.account_client_certificate_spinner)
allowClientCertificateView = findViewById(R.id.account_allow_client_certificate)
advancedOptionsContainer = findViewById(R.id.foldable_advanced_options)
nextButton = findViewById(R.id.next)
manualSetupButton = findViewById(R.id.manual_setup)
manualSetupButton.setOnClickListener { onManualSetup() }
nextButton.setOnClickListener { onNext() }
}
override fun onPostCreate(savedInstanceState: Bundle?) {
@ -82,12 +89,15 @@ class AccountSetupBasics : K9Activity() {
*/
initializeViewListeners()
validateFields()
updateUi()
}
private fun initializeViewListeners() {
val textWatcher = object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
validateFields()
val checkPassword = uiState == UiState.PASSWORD_FLOW
validateFields(checkPassword)
}
}
@ -109,9 +119,25 @@ class AccountSetupBasics : K9Activity() {
}
}
private fun updateUi() {
when (uiState) {
UiState.EMAIL_ADDRESS_ONLY -> {
passwordLayout.isVisible = false
advancedOptionsContainer.isVisible = false
nextButton.setOnClickListener { attemptAutoSetupUsingOnlyEmailAddress() }
}
UiState.PASSWORD_FLOW -> {
passwordLayout.isVisible = true
advancedOptionsContainer.isVisible = true
nextButton.setOnClickListener { attemptAutoSetup() }
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putEnum(STATE_KEY_UI_STATE, uiState)
outState.putString(EXTRA_ACCOUNT, account?.uuid)
outState.putBoolean(STATE_KEY_CHECKED_INCOMING, checkedIncoming)
}
@ -119,6 +145,8 @@ class AccountSetupBasics : K9Activity() {
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
uiState = savedInstanceState.getEnum(STATE_KEY_UI_STATE, UiState.EMAIL_ADDRESS_ONLY)
val accountUuid = savedInstanceState.getString(EXTRA_ACCOUNT)
if (accountUuid != null) {
account = preferences.getAccount(accountUuid)
@ -132,9 +160,10 @@ class AccountSetupBasics : K9Activity() {
allowClientCertificateView.isVisible = usingCertificates
}
private fun validateFields() {
private fun validateFields(checkPassword: Boolean = true) {
val email = emailView.text?.toString().orEmpty()
val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) && isPasswordFieldValid()
val valid = requiredFieldValid(emailView) && emailValidator.isValidAddressOnly(email) &&
(!checkPassword || isPasswordFieldValid())
nextButton.isEnabled = valid
nextButton.isFocusable = valid
@ -149,7 +178,37 @@ class AccountSetupBasics : K9Activity() {
clientCertificateChecked && clientCertificateAlias != null
}
private fun onNext() {
private fun attemptAutoSetupUsingOnlyEmailAddress() {
val email = emailView.text?.toString() ?: error("Email missing")
val extraConnectionSettings = ExtraAccountDiscovery.discover(email)
if (extraConnectionSettings != null) {
finishAutoSetup(extraConnectionSettings)
return
}
val connectionSettings = providersXmlDiscoveryDiscover(email)
if (connectionSettings != null &&
connectionSettings.incoming.authenticationType == AuthType.XOAUTH2 &&
connectionSettings.outgoing.authenticationType == AuthType.XOAUTH2
) {
finishAutoSetup(connectionSettings)
} else {
startPasswordFlow()
}
}
private fun startPasswordFlow() {
uiState = UiState.PASSWORD_FLOW
updateUi()
validateFields()
passwordView.requestFocus()
}
private fun attemptAutoSetup() {
if (clientCertificateCheckBox.isChecked) {
// Auto-setup doesn't support client certificates.
onManualSetup()
@ -272,8 +331,14 @@ class AccountSetupBasics : K9Activity() {
}
}
private enum class UiState {
EMAIL_ADDRESS_ONLY,
PASSWORD_FLOW
}
companion object {
private const val EXTRA_ACCOUNT = "com.fsck.k9.AccountSetupBasics.account"
private const val STATE_KEY_UI_STATE = "com.fsck.k9.AccountSetupBasics.uiState"
private const val STATE_KEY_CHECKED_INCOMING = "com.fsck.k9.AccountSetupBasics.checkedIncoming"
@JvmStatic

View file

@ -18,6 +18,7 @@ import com.fsck.k9.Preferences
import com.fsck.k9.controller.MessagingController
import com.fsck.k9.fragment.ConfirmationDialogFragment
import com.fsck.k9.fragment.ConfirmationDialogFragment.ConfirmationDialogFragmentListener
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.AuthenticationFailedException
import com.fsck.k9.mail.CertificateValidationException
import com.fsck.k9.mail.MailServerDirection
@ -25,13 +26,16 @@ import com.fsck.k9.mail.filter.Hex
import com.fsck.k9.preferences.Protocols
import com.fsck.k9.ui.R
import com.fsck.k9.ui.base.K9Activity
import com.fsck.k9.ui.observe
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.Locale
import java.util.concurrent.Executors
import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import timber.log.Timber
/**
@ -41,6 +45,8 @@ import timber.log.Timber
* while its thread is running.
*/
class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListener {
private val authViewModel: AuthViewModel by viewModel()
private val messagingController: MessagingController by inject()
private val preferences: Preferences by inject()
private val localKeyStoreManager: LocalKeyStoreManager by inject()
@ -63,6 +69,33 @@ class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListen
super.onCreate(savedInstanceState)
setLayout(R.layout.account_setup_check_settings)
authViewModel.init(activityResultRegistry, lifecycle)
authViewModel.uiState.observe(this) { state ->
when (state) {
AuthFlowState.Idle -> {
return@observe
}
AuthFlowState.Success -> {
startCheckServerSettings()
}
AuthFlowState.Canceled -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_canceled)
}
is AuthFlowState.Failed -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_flow_failed, state)
}
AuthFlowState.NotSupported -> {
showErrorDialog(R.string.account_setup_failed_dlg_oauth_not_supported)
}
AuthFlowState.BrowserNotFound -> {
showErrorDialog(R.string.account_setup_failed_dlg_browser_not_found)
}
}
authViewModel.authResultConsumed()
}
messageView = findViewById(R.id.message)
progressBar = findViewById(R.id.progress)
findViewById<View>(R.id.cancel).setOnClickListener { onCancel() }
@ -75,7 +108,26 @@ class AccountSetupCheckSettings : K9Activity(), ConfirmationDialogFragmentListen
direction = intent.getSerializableExtra(EXTRA_CHECK_DIRECTION) as CheckDirection?
?: error("Missing CheckDirection")
CheckAccountTask(account).execute(direction)
if (savedInstanceState == null) {
if (needsAuthorization()) {
setMessage(R.string.account_setup_check_settings_authenticate)
authViewModel.login(account)
} else {
startCheckServerSettings()
}
}
}
private fun needsAuthorization(): Boolean {
return (
account.incomingServerSettings.authenticationType == AuthType.XOAUTH2 ||
account.outgoingServerSettings.authenticationType == AuthType.XOAUTH2
) &&
!authViewModel.isAuthorized(account)
}
private fun startCheckServerSettings() {
CheckAccountTask(account).executeOnExecutor(Executors.newSingleThreadExecutor(), direction)
}
private fun handleCertificateValidationException(exception: CertificateValidationException) {

View file

@ -152,9 +152,6 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
}
});
mAuthTypeAdapter = AuthTypeAdapter.get(this);
mAuthTypeView.setAdapter(mAuthTypeAdapter);
/*
* Only allow digits in the port field.
*/
@ -173,6 +170,10 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
mAccount = Preferences.getPreferences(this).getAccount(accountUuid);
}
boolean oAuthSupported = mAccount.getIncomingServerSettings().type.equals(Protocols.IMAP);
mAuthTypeAdapter = AuthTypeAdapter.get(this, oAuthSupported);
mAuthTypeView.setAdapter(mAuthTypeAdapter);
boolean editSettings = Intent.ACTION_EDIT.equals(getIntent().getAction());
if (editSettings) {
TextInputLayoutHelper.configureAuthenticatedPasswordToggle(
@ -412,17 +413,14 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
* Shows/hides password field and client certificate spinner
*/
private void updateViewFromAuthType() {
AuthType authType = getSelectedAuthType();
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
if (isAuthTypeExternal) {
// hide password fields, show client certificate fields
mPasswordLayoutView.setVisibility(View.GONE);
} else {
// show password fields, hide client certificate fields
mPasswordLayoutView.setVisibility(View.VISIBLE);
switch (getSelectedAuthType()) {
case EXTERNAL:
case XOAUTH2:
mPasswordLayoutView.setVisibility(View.GONE);
break;
default:
mPasswordLayoutView.setVisibility(View.VISIBLE);
break;
}
}
@ -433,8 +431,9 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
private void updateViewFromSecurity() {
ConnectionSecurity security = getSelectedSecurity();
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
if (isUsingTLS) {
if (isUsingTLS && !isUsingOAuth) {
mAllowClientCertificateView.setVisibility(View.VISIBLE);
} else {
mAllowClientCertificateView.setVisibility(View.GONE);
@ -505,9 +504,13 @@ public class AccountSetupIncoming extends K9Activity implements OnClickListener
&& hasConnectionSecurity
&& hasValidCertificateAlias;
boolean hasValidOAuthSettings = hasValidUserName
&& hasConnectionSecurity
&& authType == AuthType.XOAUTH2;
mNextButton.setEnabled(Utility.domainFieldValid(mServerView)
&& Utility.requiredFieldValid(mPortView)
&& (hasValidPasswordSettings || hasValidExternalAuthSettings));
&& (hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
}

View file

@ -128,7 +128,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
mSecurityTypeView.setAdapter(ConnectionSecurityAdapter.get(this));
mAuthTypeAdapter = AuthTypeAdapter.get(this);
mAuthTypeAdapter = AuthTypeAdapter.get(this, true);
mAuthTypeView.setAdapter(mAuthTypeAdapter);
/*
@ -167,8 +167,6 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
try {
ServerSettings settings = mAccount.getOutgoingServerSettings();
updateAuthPlainTextFromSecurityType(settings.connectionSecurity);
updateViewFromSecurity(settings.connectionSecurity);
if (savedInstanceState == null) {
// The first item is selected if settings.authenticationType is null or is not in mAuthTypeAdapter
mCurrentAuthTypeViewPosition = mAuthTypeAdapter.getAuthPosition(settings.authenticationType);
@ -195,6 +193,9 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
}
mSecurityTypeView.setSelection(mCurrentSecurityTypeViewPosition, false);
updateAuthPlainTextFromSecurityType(getSelectedSecurity());
updateViewFromSecurity();
if (!settings.username.isEmpty()) {
mUsernameView.setText(settings.username);
mRequireLoginView.setChecked(true);
@ -256,7 +257,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
*/
if (mCurrentSecurityTypeViewPosition != position) {
updatePortFromSecurityType();
updateViewFromSecurity(getSelectedSecurity());
updateViewFromSecurity();
boolean isInsecure = (ConnectionSecurity.NONE == getSelectedSecurity());
boolean isAuthExternal = (AuthType.EXTERNAL == getSelectedAuthType());
boolean loginNotRequired = !mRequireLoginView.isChecked();
@ -297,6 +298,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
}
updateViewFromAuthType();
updateViewFromSecurity();
validateFields();
AuthType selection = getSelectedAuthType();
@ -367,25 +369,26 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
* Shows/hides password field
*/
private void updateViewFromAuthType() {
AuthType authType = getSelectedAuthType();
boolean isAuthTypeExternal = (AuthType.EXTERNAL == authType);
if (isAuthTypeExternal) {
// hide password fields
mPasswordLayoutView.setVisibility(View.GONE);
} else {
// show password fields
mPasswordLayoutView.setVisibility(View.VISIBLE);
switch (getSelectedAuthType()) {
case EXTERNAL:
case XOAUTH2:
mPasswordLayoutView.setVisibility(View.GONE);
break;
default:
mPasswordLayoutView.setVisibility(View.VISIBLE);
break;
}
}
/**
* Shows/hides client certificate spinner
*/
private void updateViewFromSecurity(ConnectionSecurity security) {
private void updateViewFromSecurity() {
ConnectionSecurity security = getSelectedSecurity();
boolean isUsingTLS = ((ConnectionSecurity.SSL_TLS_REQUIRED == security) || (ConnectionSecurity.STARTTLS_REQUIRED == security));
boolean isUsingOAuth = getSelectedAuthType() == AuthType.XOAUTH2;
if (isUsingTLS) {
if (isUsingTLS && !isUsingOAuth) {
mAllowClientCertificateView.setVisibility(View.VISIBLE);
} else {
mAllowClientCertificateView.setVisibility(View.GONE);
@ -421,7 +424,7 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
mAuthTypeView.setSelection(mCurrentAuthTypeViewPosition, false);
mAuthTypeView.setOnItemSelectedListener(onItemSelectedListener);
updateViewFromAuthType();
updateViewFromSecurity(getSelectedSecurity());
updateViewFromSecurity();
onItemSelectedListener = mSecurityTypeView.getOnItemSelectedListener();
mSecurityTypeView.setOnItemSelectedListener(null);
@ -456,11 +459,15 @@ public class AccountSetupOutgoing extends K9Activity implements OnClickListener,
&& hasConnectionSecurity
&& hasValidCertificateAlias;
boolean hasValidOAuthSettings = hasValidUserName
&& hasConnectionSecurity
&& authType == AuthType.XOAUTH2;
mNextButton
.setEnabled(Utility.domainFieldValid(mServerView)
&& Utility.requiredFieldValid(mPortView)
&& (!mRequireLoginView.isChecked()
|| hasValidPasswordSettings || hasValidExternalAuthSettings));
|| hasValidPasswordSettings || hasValidExternalAuthSettings || hasValidOAuthSettings));
Utility.setCompoundDrawablesAlpha(mNextButton, mNextButton.isEnabled() ? 255 : 128);
}

View file

@ -11,8 +11,14 @@ class AuthTypeAdapter extends ArrayAdapter<AuthTypeHolder> {
super(context, resource, holders);
}
public static AuthTypeAdapter get(Context context) {
AuthType[] authTypes = new AuthType[]{AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL};
public static AuthTypeAdapter get(Context context, boolean oAuthSupported) {
AuthType[] authTypes;
if (oAuthSupported) {
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL, AuthType.XOAUTH2 };
} else {
authTypes = new AuthType[] { AuthType.PLAIN, AuthType.CRAM_MD5, AuthType.EXTERNAL };
}
AuthTypeHolder[] holders = new AuthTypeHolder[authTypes.length];
for (int i = 0; i < authTypes.length; i++) {
holders[i] = new AuthTypeHolder(authTypes[i], context.getResources());

View file

@ -41,7 +41,8 @@ class AuthTypeHolder {
return R.string.account_setup_auth_type_encrypted_password;
case EXTERNAL:
return R.string.account_setup_auth_type_tls_client_certificate;
case XOAUTH2:
return R.string.account_setup_auth_type_oauth2;
case AUTOMATIC:
case LOGIN:
default:

View file

@ -0,0 +1,269 @@
package com.fsck.k9.activity.setup
import android.app.Activity
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.contract.ActivityResultContract
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.viewModelScope
import com.fsck.k9.Account
import com.fsck.k9.preferences.AccountManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import timber.log.Timber
private const val KEY_AUTHORIZATION = "app.k9mail_auth"
class AuthViewModel(
application: Application,
private val accountManager: AccountManager,
private val oauthCredentials: OAuthCredentials
) : AndroidViewModel(application) {
private var authService: AuthorizationService? = null
private val authState = AuthState()
private var account: Account? = null
private lateinit var resultObserver: AppAuthResultObserver
private val _uiState = MutableStateFlow<AuthFlowState>(AuthFlowState.Idle)
val uiState: StateFlow<AuthFlowState> = _uiState.asStateFlow()
@Synchronized
private fun getAuthService(): AuthorizationService {
return authService ?: AuthorizationService(getApplication<Application>()).also { authService = it }
}
fun init(activityResultRegistry: ActivityResultRegistry, lifecycle: Lifecycle) {
resultObserver = AppAuthResultObserver(activityResultRegistry)
lifecycle.addObserver(resultObserver)
}
fun authResultConsumed() {
_uiState.update { AuthFlowState.Idle }
}
fun isAuthorized(account: Account): Boolean {
val authState = getOrCreateAuthState(account)
return authState.isAuthorized
}
private fun getOrCreateAuthState(account: Account): AuthState {
return try {
account.oAuthState?.let { AuthState.jsonDeserialize(it) } ?: AuthState()
} catch (e: Exception) {
Timber.e(e, "Error deserializing AuthState")
AuthState()
}
}
fun login(account: Account) {
this.account = account
viewModelScope.launch {
val config = findOAuthConfiguration(account)
if (config == null) {
_uiState.update { AuthFlowState.NotSupported }
return@launch
}
try {
startLogin(account, config)
} catch (e: ActivityNotFoundException) {
_uiState.update { AuthFlowState.BrowserNotFound }
}
}
}
private suspend fun startLogin(account: Account, config: OAuthConfiguration) {
val authRequestIntent = withContext(Dispatchers.IO) {
createAuthorizationRequestIntent(account.email, config)
}
resultObserver.login(authRequestIntent)
}
private fun createAuthorizationRequestIntent(email: String, config: OAuthConfiguration): Intent {
val serviceConfig = AuthorizationServiceConfiguration(
config.authorizationEndpoint.toUri(),
config.tokenEndpoint.toUri()
)
val applicationId = getApplication<Application>().packageName
val redirectUri = Uri.parse("$applicationId:/oauth2redirect")
val authRequestBuilder = AuthorizationRequest.Builder(
serviceConfig,
config.clientId,
ResponseTypeValues.CODE,
redirectUri
)
val scopeString = config.scopes.joinToString(separator = " ")
val authRequest = authRequestBuilder
.setScope(scopeString)
.setLoginHint(email)
.build()
val authService = getAuthService()
return authService.getAuthorizationRequestIntent(authRequest)
}
private fun findOAuthConfiguration(account: Account): OAuthConfiguration? {
return when (account.incomingServerSettings.host) {
"imap.gmail.com", "imap.googlemail.com" -> {
OAuthConfiguration(
clientId = oauthCredentials.gmailClientId,
scopes = listOf("https://mail.google.com/"),
authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint = "https://oauth2.googleapis.com/token"
)
}
else -> null
}
}
private fun onLoginResult(authorizationResult: AuthorizationResult?) {
if (authorizationResult == null) {
_uiState.update { AuthFlowState.Canceled }
return
}
authorizationResult.response?.let { response ->
authState.update(authorizationResult.response, authorizationResult.exception)
exchangeToken(response)
}
authorizationResult.exception?.let { authorizationException ->
_uiState.update {
AuthFlowState.Failed(
errorCode = authorizationException.error,
errorMessage = authorizationException.errorDescription
)
}
}
}
private fun exchangeToken(response: AuthorizationResponse) {
viewModelScope.launch(Dispatchers.IO) {
val authService = getAuthService()
val tokenRequest = response.createTokenExchangeRequest()
authService.performTokenRequest(tokenRequest) { tokenResponse, authorizationException ->
authState.update(tokenResponse, authorizationException)
val account = account!!
account.oAuthState = authState.jsonSerializeString()
viewModelScope.launch(Dispatchers.IO) {
accountManager.saveAccount(account)
}
if (authorizationException != null) {
_uiState.update {
AuthFlowState.Failed(
errorCode = authorizationException.error,
errorMessage = authorizationException.errorDescription
)
}
} else {
_uiState.update { AuthFlowState.Success }
}
}
}
}
@Synchronized
override fun onCleared() {
authService?.dispose()
authService = null
}
inner class AppAuthResultObserver(private val registry: ActivityResultRegistry) : DefaultLifecycleObserver {
private var authorizationLauncher: ActivityResultLauncher<Intent>? = null
private var authRequestIntent: Intent? = null
override fun onCreate(owner: LifecycleOwner) {
authorizationLauncher = registry.register(KEY_AUTHORIZATION, AuthorizationContract(), ::onLoginResult)
authRequestIntent?.let { intent ->
authRequestIntent = null
login(intent)
}
}
override fun onDestroy(owner: LifecycleOwner) {
authorizationLauncher = null
}
fun login(authRequestIntent: Intent) {
val launcher = authorizationLauncher
if (launcher != null) {
launcher.launch(authRequestIntent)
} else {
this.authRequestIntent = authRequestIntent
}
}
}
}
private class AuthorizationContract : ActivityResultContract<Intent, AuthorizationResult?>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}
override fun parseResult(resultCode: Int, intent: Intent?): AuthorizationResult? {
return if (resultCode == Activity.RESULT_OK && intent != null) {
AuthorizationResult(
response = AuthorizationResponse.fromIntent(intent),
exception = AuthorizationException.fromIntent(intent)
)
} else {
null
}
}
}
private data class AuthorizationResult(
val response: AuthorizationResponse?,
val exception: AuthorizationException?
)
sealed interface AuthFlowState {
object Idle : AuthFlowState
object Success : AuthFlowState
object NotSupported : AuthFlowState
object BrowserNotFound : AuthFlowState
object Canceled : AuthFlowState
data class Failed(val errorCode: String?, val errorMessage: String?) : AuthFlowState {
override fun toString(): String {
return listOfNotNull(errorCode, errorMessage).joinToString(separator = " - ")
}
}
}

View file

@ -0,0 +1,8 @@
package com.fsck.k9.activity.setup
data class OAuthConfiguration(
val clientId: String,
val scopes: List<String>,
val authorizationEndpoint: String,
val tokenEndpoint: String
)

View file

@ -0,0 +1,5 @@
package com.fsck.k9.activity.setup
interface OAuthCredentials {
val gmailClientId: String
}

View file

@ -37,6 +37,7 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/account_password_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:passwordToggleEnabled="true">

View file

@ -356,6 +356,7 @@ Please submit bug reports, contribute new features and ask questions at
<string name="account_setup_auth_type_insecure_password">Password, transmitted insecurely</string>
<string name="account_setup_auth_type_encrypted_password">Encrypted password</string>
<string name="account_setup_auth_type_tls_client_certificate">Client certificate</string>
<string name="account_setup_auth_type_oauth2">OAuth 2.0</string>
<string name="account_setup_incoming_title">Incoming server settings</string>
<string name="account_setup_incoming_username_label">Username</string>
@ -457,6 +458,10 @@ Please submit bug reports, contribute new features and ask questions at
<string name="account_setup_failed_dlg_auth_message_fmt">Username or password incorrect.\n(<xliff:g id="error">%s</xliff:g>)</string>
<string name="account_setup_failed_dlg_certificate_message_fmt">The server presented an invalid SSL certificate. Sometimes, this is because of a server misconfiguration. Sometimes it is because someone is trying to attack you or your mail server. If you\'re not sure what\'s up, click Reject and contact the folks who manage your mail server.\n\n(<xliff:g id="error">%s</xliff:g>)</string>
<string name="account_setup_failed_dlg_server_message_fmt">Cannot connect to server.\n(<xliff:g id="error">%s</xliff:g>)</string>
<string name="account_setup_failed_dlg_oauth_flow_canceled">Authorization canceled</string>
<string name="account_setup_failed_dlg_oauth_flow_failed">Authorization failed with the following error: <xliff:g id="error">%s</xliff:g></string>
<string name="account_setup_failed_dlg_oauth_not_supported">OAuth 2.0 is currently not supported with this provider.</string>
<string name="account_setup_failed_dlg_browser_not_found">The app couldn\'t find a browser to use for granting access to your account.</string>
<string name="account_setup_failed_dlg_edit_details_action">Edit details</string>
<string name="account_setup_failed_dlg_continue_action">Continue</string>

View file

@ -0,0 +1,21 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!-- We're replacing this activity so we can run tests without the build tools complaining about a
missing manifest placeholder -->
<activity
android:name="net.openid.appauth.RedirectUriReceiverActivity"
android:exported="true"
tools:node="replace">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dummy" />
</intent-filter>
</activity>
</application>
</manifest>

BIN
debug.keystore Normal file

Binary file not shown.

View file

@ -1,8 +1,6 @@
package com.fsck.k9.mail.oauth;
import java.util.List;
import com.fsck.k9.mail.AuthenticationFailedException;
@ -12,16 +10,11 @@ public interface OAuth2TokenProvider {
*/
int OAUTH2_TIMEOUT = 30000;
/**
* @return Accounts suitable for OAuth 2.0 token provision.
*/
List<String> getAccounts();
/**
* Fetch a token. No guarantees are provided for validity.
*/
String getToken(String username, long timeoutMillis) throws AuthenticationFailedException;
String getToken(long timeoutMillis) throws AuthenticationFailedException;
/**
* Invalidate the token for this username.
@ -32,5 +25,5 @@ public interface OAuth2TokenProvider {
* <p>
* Invalidating a token and then failure with a new token should be treated as a permanent failure.
*/
void invalidateToken(String username);
void invalidateToken();
}

View file

@ -399,7 +399,7 @@ class RealImapConnection implements ImapConnection {
return attemptXOAuth2();
} catch (NegativeImapResponseException e) {
//TODO: Check response code so we don't needlessly invalidate the token.
oauthTokenProvider.invalidateToken(settings.getUsername());
oauthTokenProvider.invalidateToken();
if (!retryXoauth2WithNewToken) {
throw handlePermanentXoauth2Failure(e);
@ -427,13 +427,13 @@ class RealImapConnection implements ImapConnection {
//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(settings.getUsername());
oauthTokenProvider.invalidateToken();
throw handlePermanentXoauth2Failure(e2);
}
}
private List<ImapResponse> attemptXOAuth2() throws MessagingException, IOException {
String token = oauthTokenProvider.getToken(settings.getUsername(), OAuth2TokenProvider.OAUTH2_TIMEOUT);
String token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT);
String authString = Authentication.computeXoauth(settings.getUsername(), token);
String tag = sendSaslIrCommand(Commands.AUTHENTICATE_XOAUTH2, authString, true);

View file

@ -1045,8 +1045,7 @@ class RealImapConnectionTest {
class TestTokenProvider : OAuth2TokenProvider {
private var invalidationCount = 0
override fun getToken(username: String, timeoutMillis: Long): String {
assertThat(username).isEqualTo(USERNAME)
override fun getToken(timeoutMillis: Long): String {
assertThat(timeoutMillis).isEqualTo(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
return when (invalidationCount) {
@ -1060,14 +1059,9 @@ class TestTokenProvider : OAuth2TokenProvider {
}
}
override fun invalidateToken(username: String) {
assertThat(username).isEqualTo(USERNAME)
override fun invalidateToken() {
invalidationCount++
}
override fun getAccounts(): List<String> {
throw UnsupportedOperationException()
}
}
private fun String.base64() = this.encodeUtf8().base64()

View file

@ -559,7 +559,7 @@ class SmtpTransport(
throw negativeResponse
}
oauthTokenProvider!!.invalidateToken(username)
oauthTokenProvider!!.invalidateToken()
if (!retryXoauthWithNewToken) {
handlePermanentFailure(negativeResponse)
@ -588,13 +588,13 @@ class SmtpTransport(
// Okay, we failed on a new token. Invalidate the token anyway but assume it's permanent.
Timber.v(negativeResponseFromNewToken, "Authentication exception for new token, permanent error assumed")
oauthTokenProvider!!.invalidateToken(username)
oauthTokenProvider!!.invalidateToken()
handlePermanentFailure(negativeResponseFromNewToken)
}
}
private fun attemptXoauth2(username: String) {
val token = oauthTokenProvider!!.getToken(username, OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
val token = oauthTokenProvider!!.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong())
val authString = Authentication.computeXoauth(username, token)
val response = executeSensitiveCommand("AUTH XOAUTH2 %s", authString)

View file

@ -18,10 +18,8 @@ import com.google.common.truth.Truth.assertThat
import org.junit.Assert.fail
import org.junit.Test
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.anyString
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.doThrow
import org.mockito.kotlin.eq
import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.stubbing
@ -218,8 +216,8 @@ class SmtpTransportTest {
}
inOrder(oAuth2TokenProvider) {
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
verify(oAuth2TokenProvider).getToken(anyLong())
verify(oAuth2TokenProvider).invalidateToken()
}
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
@ -245,9 +243,9 @@ class SmtpTransportTest {
transport.open()
inOrder(oAuth2TokenProvider) {
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).getToken(anyLong())
verify(oAuth2TokenProvider).invalidateToken()
verify(oAuth2TokenProvider).getToken(anyLong())
}
server.verifyConnectionStillOpen()
server.verifyInteractionCompleted()
@ -273,9 +271,9 @@ class SmtpTransportTest {
transport.open()
inOrder(oAuth2TokenProvider) {
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).getToken(anyLong())
verify(oAuth2TokenProvider).invalidateToken()
verify(oAuth2TokenProvider).getToken(anyLong())
}
server.verifyConnectionStillOpen()
server.verifyInteractionCompleted()
@ -301,9 +299,9 @@ class SmtpTransportTest {
transport.open()
inOrder(oAuth2TokenProvider) {
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).getToken(anyLong())
verify(oAuth2TokenProvider).invalidateToken()
verify(oAuth2TokenProvider).getToken(anyLong())
}
server.verifyConnectionStillOpen()
server.verifyInteractionCompleted()
@ -357,7 +355,7 @@ class SmtpTransportTest {
output("221 BYE")
}
stubbing(oAuth2TokenProvider) {
on { getToken(anyString(), anyLong()) } doThrow AuthenticationFailedException("Failed to fetch token")
on { getToken(anyLong()) } doThrow AuthenticationFailedException("Failed to fetch token")
}
val transport = startServerAndCreateSmtpTransport(server, authenticationType = AuthType.XOAUTH2)
@ -533,8 +531,8 @@ class SmtpTransportTest {
}
inOrder(oAuth2TokenProvider) {
verify(oAuth2TokenProvider).getToken(eq(USERNAME), anyLong())
verify(oAuth2TokenProvider).invalidateToken(USERNAME)
verify(oAuth2TokenProvider).getToken(anyLong())
verify(oAuth2TokenProvider).invalidateToken()
}
server.verifyConnectionClosed()
server.verifyInteractionCompleted()
@ -950,7 +948,7 @@ class SmtpTransportTest {
private fun createMockOAuth2TokenProvider(): OAuth2TokenProvider {
return mock {
on { getToken(eq(USERNAME), anyLong()) } doReturn "oldToken" doReturn "newToken"
on { getToken(anyLong()) } doReturn "oldToken" doReturn "newToken"
}
}
}