Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,33 @@ class RealOAuth2TokenProvider(
private val authService = AuthorizationService(context)
private var requestFreshToken = false

override val primaryEmail: String?
override val usernames: Set<String>
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
@Throws(AuthenticationFailedException::class)
get

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ class ImapServerSettingsValidatorTest {
}
}

class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
class FakeOAuth2TokenProvider(override val usernames: Set<String> = emptySet()) : OAuth2TokenProvider {
override fun getToken(timeoutMillis: Long): String {
return AUTHORIZATION_TOKEN
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1273,7 +1273,7 @@ class RealImapConnectionTest {
}
}

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

override fun getToken(timeoutMillis: Long): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -165,13 +166,15 @@ class SmtpTransport(
throw MissingCapabilityException("AUTH PLAIN")
}
}

AuthType.CRAM_MD5 -> {
if (authCramMD5Supported) {
saslAuthCramMD5()
} else {
throw MissingCapabilityException("AUTH CRAM-MD5")
}
}

AuthType.XOAUTH2 -> {
if (oauthTokenProvider == null) {
throw MessagingException("No OAuth2TokenProvider available.")
Expand All @@ -183,13 +186,15 @@ class SmtpTransport(
throw MissingCapabilityException("AUTH OAUTHBEARER")
}
}

AuthType.EXTERNAL -> {
if (authExternalSupported) {
saslAuthExternal()
} else {
throw MissingCapabilityException("AUTH EXTERNAL")
}
}

else -> {
throw MessagingException("Unhandled authentication method found in server settings (bug).")
}
Expand Down Expand Up @@ -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<String>,
method: OAuthMethod,
): MutableMap<String, NegativeSmtpReplyException> {
val negativeResponses = mutableMapOf<String, NegativeSmtpReplyException>()
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class SmtpServerSettingsValidatorTest {
trustedSocketFactory = trustedSocketFactory,
oAuth2TokenProviderFactory = { authStateStorage ->
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
FakeOAuth2TokenProvider(primaryEmail = expectedUser)
FakeOAuth2TokenProvider(usernames = setOf(expectedUser))
},
)

Expand All @@ -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()

Expand Down Expand Up @@ -397,7 +404,7 @@ class SmtpServerSettingsValidatorTest {
}
}

class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
class FakeOAuth2TokenProvider(override val usernames: Set<String> = emptySet()) : OAuth2TokenProvider {
override fun getToken(timeoutMillis: Long): String {
return AUTHORIZATION_TOKEN
}
Expand Down
Loading