Skip to content

Commit 31f4c4e

Browse files
committed
fix(smtp): outlook smtp not authenticating when enterprise or educational accounts
1 parent 2b8ebd4 commit 31f4c4e

File tree

7 files changed

+101
-27
lines changed

7 files changed

+101
-27
lines changed

app-thunderbird/src/debug/kotlin/net/thunderbird/android/auth/TbOAuthConfigurationFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class TbOAuthConfigurationFactory : OAuthConfigurationFactory {
6565
) to OAuthConfiguration(
6666
clientId = "e6f8716e-299d-4ed9-bbf3-453f192f44e5",
6767
scopes = listOf(
68+
"profile",
6869
"openid",
6970
"email",
7071
"https://outlook.office.com/IMAP.AccessAsUser.All",

legacy/common/src/main/java/com/fsck/k9/backends/RealOAuth2TokenProvider.kt

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,33 @@ class RealOAuth2TokenProvider(
2121
private val authService = AuthorizationService(context)
2222
private var requestFreshToken = false
2323

24-
override val primaryEmail: String?
24+
override val usernames: Set<String>
2525
get() {
26-
return parseAuthState()
27-
.parsedIdToken
28-
?.additionalClaims
29-
?.get("email")
30-
?.toString()
26+
val idTokenClaims = parseAuthState().parsedIdToken?.additionalClaims.orEmpty()
27+
return buildSet {
28+
// https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference#payload-claims
29+
// https://docs.azure.cn/en-us/entra/identity-platform/optional-claims-reference
30+
// requires profile scope
31+
idTokenClaims["preferred_username"]?.let { add(it.toString()) }
32+
// requires email scope
33+
idTokenClaims["email"]?.let { add(it.toString()) }
34+
// only present for v1.0 tokens
35+
idTokenClaims["unique_name"]?.let { add(it.toString()) }
36+
// requires profile scope
37+
idTokenClaims["upn"]?.let { add(it.toString()) }
38+
idTokenClaims["verified_primary_email"]?.let { verifiedPrimaryEmail ->
39+
when (verifiedPrimaryEmail) {
40+
is List<*> -> addAll(verifiedPrimaryEmail.map { it.toString() })
41+
else -> add(verifiedPrimaryEmail.toString())
42+
}
43+
}
44+
idTokenClaims["verified_secondary_email"]?.let { verifiedSecondaryEmail ->
45+
when (verifiedSecondaryEmail) {
46+
is List<*> -> addAll(verifiedSecondaryEmail.map { it.toString() })
47+
else -> add(verifiedSecondaryEmail.toString())
48+
}
49+
}
50+
}
3151
}
3252

3353
@Suppress("TooGenericExceptionCaught")

mail/common/src/main/java/com/fsck/k9/mail/oauth/OAuth2TokenProvider.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,14 @@ interface OAuth2TokenProvider {
1111
}
1212

1313
/**
14-
* Fetch the primary email found in the id_token additional claims,
15-
* if it is available.
14+
* A set of usernames fetched from the `id_token`.
1615
*
1716
* > Some providers, like Microsoft, require this as they need the primary account email to be the username,
18-
* not the email the user entered
17+
* > not the email the user entered for SMTP authentication.
1918
*
20-
* @return the primary email present in the id_token, otherwise null.
19+
* @throws AuthenticationFailedException If no AuthState is available.
2120
*/
22-
val primaryEmail: String?
21+
val usernames: Set<String>
2322
@Throws(AuthenticationFailedException::class)
2423
get
2524

mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapServerSettingsValidatorTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ class ImapServerSettingsValidatorTest {
351351
}
352352
}
353353

354-
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
354+
class FakeOAuth2TokenProvider(override val usernames: Set<String> = emptySet()) : OAuth2TokenProvider {
355355
override fun getToken(timeoutMillis: Long): String {
356356
return AUTHORIZATION_TOKEN
357357
}

mail/protocols/imap/src/test/java/com/fsck/k9/mail/store/imap/RealImapConnectionTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1273,7 +1273,7 @@ class RealImapConnectionTest {
12731273
}
12741274
}
12751275

1276-
class TestTokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
1276+
class TestTokenProvider(override val usernames: Set<String> = emptySet()) : OAuth2TokenProvider {
12771277
private var invalidationCount = 0
12781278

12791279
override fun getToken(timeoutMillis: Long): String {

mail/protocols/smtp/src/main/java/com/fsck/k9/mail/transport/smtp/SmtpTransport.kt

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ class SmtpTransport(
155155
AuthType.NONE -> {
156156
// The outgoing server is configured to not use any authentication. So do nothing.
157157
}
158+
158159
AuthType.PLAIN -> {
159160
// try saslAuthPlain first, because it supports UTF-8 explicitly
160161
if (authPlainSupported) {
@@ -165,13 +166,15 @@ class SmtpTransport(
165166
throw MissingCapabilityException("AUTH PLAIN")
166167
}
167168
}
169+
168170
AuthType.CRAM_MD5 -> {
169171
if (authCramMD5Supported) {
170172
saslAuthCramMD5()
171173
} else {
172174
throw MissingCapabilityException("AUTH CRAM-MD5")
173175
}
174176
}
177+
175178
AuthType.XOAUTH2 -> {
176179
if (oauthTokenProvider == null) {
177180
throw MessagingException("No OAuth2TokenProvider available.")
@@ -183,13 +186,15 @@ class SmtpTransport(
183186
throw MissingCapabilityException("AUTH OAUTHBEARER")
184187
}
185188
}
189+
186190
AuthType.EXTERNAL -> {
187191
if (authExternalSupported) {
188192
saslAuthExternal()
189193
} else {
190194
throw MissingCapabilityException("AUTH EXTERNAL")
191195
}
192196
}
197+
193198
else -> {
194199
throw MessagingException("Unhandled authentication method found in server settings (bug).")
195200
}
@@ -551,26 +556,68 @@ class SmtpTransport(
551556
private fun saslOAuth(method: OAuthMethod) {
552557
Log.d("saslOAuth() called with: method = $method")
553558
retryOAuthWithNewToken = true
559+
checkNotNull(oauthTokenProvider) { "No OAuth2TokenProvider available." }
554560

555-
val primaryEmail = oauthTokenProvider?.primaryEmail
556-
val primaryUsername = primaryEmail ?: username
561+
val users = buildSet {
562+
// add the given username to the list of users
563+
add(username)
564+
// add all the usernames we can fetch from the id_token to be used in case
565+
// the given username fails to authenticate.
566+
addAll(oauthTokenProvider.usernames)
567+
}.toMutableSet()
557568

558-
try {
559-
attempOAuth(method, primaryUsername)
560-
} catch (negativeResponse: NegativeSmtpReplyException) {
561-
Log.w(negativeResponse, "saslOAuth: failed to authenticate.")
562-
if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
563-
throw negativeResponse
564-
}
569+
val negativeResponses = authenticateUsers(users, method)
565570

566-
oauthTokenProvider!!.invalidateToken()
571+
if (negativeResponses.isNotEmpty()) {
572+
Log.w("failed to authenticate with all discovered users.")
573+
val (user, negativeResponse) = negativeResponses[username]?.let { username to it }
574+
?: negativeResponses.entries.first().toPair()
575+
576+
logger.log("invalidating current token")
577+
oauthTokenProvider.invalidateToken()
567578

568579
if (!retryOAuthWithNewToken) {
569580
handlePermanentOAuthFailure(method, negativeResponse)
570581
} else {
571-
handleTemporaryOAuthFailure(method, primaryUsername, negativeResponse)
582+
handleTemporaryOAuthFailure(
583+
method = method,
584+
username = user,
585+
negativeResponseFromOldToken = negativeResponse,
586+
)
587+
}
588+
}
589+
}
590+
591+
private fun authenticateUsers(
592+
users: MutableSet<String>,
593+
method: OAuthMethod,
594+
): MutableMap<String, NegativeSmtpReplyException> {
595+
val negativeResponses = mutableMapOf<String, NegativeSmtpReplyException>()
596+
val sensitiveLog = "*sensitive*"
597+
logger.log("users = ${users.takeIf { K9MailLib.isDebugSensitive() } ?: "[${users.size} emails found]"}")
598+
599+
val iterator = users.iterator()
600+
while (iterator.hasNext()) {
601+
val user = iterator.next()
602+
try {
603+
logger.log(
604+
"trying to authenticate with user '${
605+
user.takeIf { K9MailLib.isDebugSensitive() } ?: sensitiveLog
606+
}'",
607+
)
608+
attempOAuth(method, user)
609+
negativeResponses.clear()
610+
break
611+
} catch (negativeResponse: NegativeSmtpReplyException) {
612+
Log.w(negativeResponse, "saslOAuth: failed to authenticate.")
613+
if (negativeResponse.replyCode != SMTP_AUTHENTICATION_FAILURE_ERROR_CODE) {
614+
throw negativeResponse
615+
}
616+
iterator.remove()
617+
negativeResponses += user to negativeResponse
572618
}
573619
}
620+
return negativeResponses
574621
}
575622

576623
private fun handlePermanentOAuthFailure(

mail/protocols/smtp/src/test/java/com/fsck/k9/mail/transport/smtp/SmtpServerSettingsValidatorTest.kt

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ class SmtpServerSettingsValidatorTest {
124124
trustedSocketFactory = trustedSocketFactory,
125125
oAuth2TokenProviderFactory = { authStateStorage ->
126126
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
127-
FakeOAuth2TokenProvider(primaryEmail = expectedUser)
127+
FakeOAuth2TokenProvider(usernames = setOf(expectedUser))
128128
},
129129
)
130130

@@ -136,7 +136,14 @@ class SmtpServerSettingsValidatorTest {
136136
output("250-AUTH PLAIN LOGIN XOAUTH2")
137137
output("250 HELP")
138138

139-
val ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001"
139+
var ouathBearer = "user=${USERNAME}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001"
140+
.encodeUtf8()
141+
.base64()
142+
143+
expect("AUTH XOAUTH2 $ouathBearer")
144+
output("535 5.7.3 Authentication unsuccessful")
145+
146+
ouathBearer = "user=${expectedUser}\u0001auth=Bearer ${AUTHORIZATION_TOKEN}\u0001\u0001"
140147
.encodeUtf8()
141148
.base64()
142149

@@ -397,7 +404,7 @@ class SmtpServerSettingsValidatorTest {
397404
}
398405
}
399406

400-
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
407+
class FakeOAuth2TokenProvider(override val usernames: Set<String> = emptySet()) : OAuth2TokenProvider {
401408
override fun getToken(timeoutMillis: Long): String {
402409
return AUTHORIZATION_TOKEN
403410
}

0 commit comments

Comments
 (0)