diff --git a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt index eccbf45aaf5..f95419d0855 100644 --- a/app-thunderbird/src/debug/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt +++ b/app-thunderbird/src/debug/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt @@ -65,6 +65,7 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory { ) to OAuthConfiguration( clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5", scopes = listOf( + "profile", "openid", "email", "https://outlook.office.com/IMAP.AccessAsUser.All", diff --git a/legacy/common/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt b/legacy/common/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt index 10f56497450..6921746cdcc 100644 --- a/legacy/common/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt +++ b/legacy/common/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt @@ -21,13 +21,33 @@ class RealOAuth2TokenProvider( private val authService = AuthorizationService(context) private var requestFreshToken = false - override val primaryEmail: String? + override val usernames: Set get() { - return parseAuthState() - .parsedIdToken - ?.additionalClaims - ?.get("email") - ?.toString() + val idTokenClaims = parseAuthState().parsedIdToken?.additionalClaims.orEmpty() + return buildSet { + // https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#payload-claims + // https://docs.azure.cn/en-us/entra/identity-platform/optional-claims-reference + // requires profile scope + idTokenClaims["preferred_username"]?.let { add(it.toString()) } + // requires email scope + idTokenClaims["email"]?.let { add(it.toString()) } + // only present for v1.0 tokens + idTokenClaims["unique_name"]?.let { add(it.toString()) } + // requires profile scope + idTokenClaims["upn"]?.let { add(it.toString()) } + idTokenClaims["verified_primary_email"]?.let { verifiedPrimaryEmail -> + when (verifiedPrimaryEmail) { + is List<*> -> addAll(verifiedPrimaryEmail.map { it.toString() }) + else -> add(verifiedPrimaryEmail.toString()) + } + } + idTokenClaims["verified_secondary_email"]?.let { verifiedSecondaryEmail -> + when (verifiedSecondaryEmail) { + is List<*> -> addAll(verifiedSecondaryEmail.map { it.toString() }) + else -> add(verifiedSecondaryEmail.toString()) + } + } + } } @Suppress("TooGenericExceptionCaught") diff --git a/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.kt b/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.kt index 076f5912486..3ec99e5b7f6 100644 --- a/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.kt +++ b/mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.kt @@ -11,15 +11,14 @@ interface OAuth2TokenProvider { } /** - * Fetch the primary email found in the id_token additional claims, - * if it is available. + * A set of usernames fetched from the `id_token`. * * > Some providers, like Microsoft, require this as they need the primary account email to be the username, - * not the email the user entered + * > not the email the user entered for SMTP authentication. * - * @return the primary email present in the id_token, otherwise null. + * @throws AuthenticationFailedException If no AuthState is available. */ - val primaryEmail: String? + val usernames: Set @Throws(AuthenticationFailedException::class) get diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapServerSettingsValidatorTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapServerSettingsValidatorTest.kt index dcd55a9826a..b0ff10e5672 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapServerSettingsValidatorTest.kt +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapServerSettingsValidatorTest.kt @@ -351,7 +351,7 @@ class ImapServerSettingsValidatorTest { } } -class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider { +class FakeOAuth2TokenProvider(override val usernames: Set = emptySet()) : OAuth2TokenProvider { override fun getToken(timeoutMillis: Long): String { return AUTHORIZATION_TOKEN } diff --git a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt index c34fdd6c2f4..27b3c133b63 100644 --- a/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt +++ b/mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt @@ -1273,7 +1273,7 @@ class RealImapConnectionTest { } } -class TestTokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider { +class TestTokenProvider(override val usernames: Set = emptySet()) : OAuth2TokenProvider { private var invalidationCount = 0 override fun getToken(timeoutMillis: Long): String { diff --git a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt index f7cfa634e66..2314e5588f5 100644 --- a/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt +++ b/mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt @@ -155,6 +155,7 @@ class SmtpTransport( AuthType.NONE -> { // The outgoing server is configured to not use any authentication. So do nothing. } + AuthType.PLAIN -> { // try saslAuthPlain first, because it supports UTF-8 explicitly if (authPlainSupported) { @@ -165,6 +166,7 @@ class SmtpTransport( throw MissingCapabilityException("AUTH PLAIN") } } + AuthType.CRAM_MD5 -> { if (authCramMD5Supported) { saslAuthCramMD5() @@ -172,6 +174,7 @@ class SmtpTransport( throw MissingCapabilityException("AUTH CRAM-MD5") } } + AuthType.XOAUTH2 -> { if (oauthTokenProvider == null) { throw MessagingException("No OAuth2TokenProvider available.") @@ -183,6 +186,7 @@ class SmtpTransport( throw MissingCapabilityException("AUTH OAUTHBEARER") } } + AuthType.EXTERNAL -> { if (authExternalSupported) { saslAuthExternal() @@ -190,6 +194,7 @@ class SmtpTransport( throw MissingCapabilityException("AUTH EXTERNAL") } } + else -> { throw MessagingException("Unhandled authentication method found in server settings (bug).") } @@ -551,26 +556,68 @@ class SmtpTransport( private fun saslOAuth(method: OAuthMethod) { Log.d("saslOAuth() called with: method = $method") retryOAuthWithNewToken = true + checkNotNull(oauthTokenProvider) { "No OAuth2TokenProvider available." } - val primaryEmail = oauthTokenProvider?.primaryEmail - val primaryUsername = primaryEmail ?: username + val users = buildSet { + // add the given username to the list of users + add(username) + // add all the usernames we can fetch from the id_token to be used in case + // the given username fails to authenticate. + addAll(oauthTokenProvider.usernames) + }.toMutableSet() - try { - attempOAuth(method, primaryUsername) - } catch (negativeResponse: NegativeSmtpReplyException) { - Log.w(negativeResponse, "saslOAuth: failed to authenticate.") - if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { - throw negativeResponse - } + val negativeResponses = authenticateUsers(users, method) - oauthTokenProvider!!.invalidateToken() + if (negativeResponses.isNotEmpty()) { + Log.w("failed to authenticate with all discovered users.") + val (user, negativeResponse) = negativeResponses[username]?.let { username to it } + ?: negativeResponses.entries.first().toPair() + + logger.log("invalidating current token") + oauthTokenProvider.invalidateToken() if (!retryOAuthWithNewToken) { handlePermanentOAuthFailure(method, negativeResponse) } else { - handleTemporaryOAuthFailure(method, primaryUsername, negativeResponse) + handleTemporaryOAuthFailure( + method = method, + username = user, + negativeResponseFromOldToken = negativeResponse, + ) + } + } + } + + private fun authenticateUsers( + users: MutableSet, + method: OAuthMethod, + ): MutableMap { + val negativeResponses = mutableMapOf() + val sensitiveLog = "*sensitive*" + logger.log("users = ${users.takeIf { K9MailLib.isDebugSensitive() } ?: "[${users.size} emails found]"}") + + val iterator = users.iterator() + while (iterator.hasNext()) { + val user = iterator.next() + try { + logger.log( + "trying to authenticate with user '${ + user.takeIf { K9MailLib.isDebugSensitive() } ?: sensitiveLog + }'", + ) + attempOAuth(method, user) + negativeResponses.clear() + break + } catch (negativeResponse: NegativeSmtpReplyException) { + Log.w(negativeResponse, "saslOAuth: failed to authenticate.") + if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) { + throw negativeResponse + } + iterator.remove() + negativeResponses += user to negativeResponse } } + return negativeResponses } private fun handlePermanentOAuthFailure( diff --git a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpServerSettingsValidatorTest.kt b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpServerSettingsValidatorTest.kt index ad880c998a1..4865eb30925 100644 --- a/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpServerSettingsValidatorTest.kt +++ b/mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpServerSettingsValidatorTest.kt @@ -124,7 +124,7 @@ class SmtpServerSettingsValidatorTest { trustedSocketFactory = trustedSocketFactory, oAuth2TokenProviderFactory = { authStateStorage -> assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE) - FakeOAuth2TokenProvider(primaryEmail = expectedUser) + FakeOAuth2TokenProvider(usernames = setOf(expectedUser)) }, ) @@ -136,7 +136,14 @@ class SmtpServerSettingsValidatorTest { output("250-AUTH PLAIN LOGIN XOAUTH2") output("250 HELP") - val ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001" + var ouathBearer = "user=${USERNAME}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001" + .encodeUtf8() + .base64() + + expect("AUTH XOAUTH2 $ouathBearer") + output("535 5.7.3 Authentication unsuccessful") + + ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001" .encodeUtf8() .base64() @@ -397,7 +404,7 @@ class SmtpServerSettingsValidatorTest { } } -class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider { +class FakeOAuth2TokenProvider(override val usernames: Set = emptySet()) : OAuth2TokenProvider { override fun getToken(timeoutMillis: Long): String { return AUTHORIZATION_TOKEN }