fix(auth): drop redundant second Outlook token request on sign-in #317

Merged
JMR-dev merged 22 commits from fix-306-outlook-redundant-token into main 2026-07-05 18:10:20 +00:00
2 changed files with 19 additions and 9 deletions
@@ -106,12 +106,16 @@ class OutlookAuthManager @Inject constructor(@ApplicationContext private val con
val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
val email = emailFromIdToken(tokenResponse.idToken)
?: throw IllegalStateException("Could not read the account email from the token")
// Mint an Exchange Online token so the caller can verify the account over IMAP.
val outlook = refreshForScope(authState, OUTLOOK_SCOPE)
// The code exchange above already named the Exchange Online resource ($OUTLOOK_SCOPE), so
// this access token is an outlook.office.com token the caller can verify over IMAP directly.
// Don't re-refresh for the same scope: that second round-trip only rotates the just-issued
// refresh token and adds a needless onboarding failure point. The Graph token is a different
// resource and is minted on demand later (freshGraphToken); the durable AuthState — refresh
// token plus this token's expiry — is serialized here for those later refreshes.
return OAuthResult(
email = email,
accessToken = outlook.accessToken,
authStateJson = outlook.authStateJson,
accessToken = tokenResponse.accessToken.orEmpty(),
authStateJson = authState.jsonSerializeString(),
)
} finally {
service.dispose()
@@ -14,6 +14,7 @@ import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
@@ -30,6 +31,7 @@ import org.junit.Test
import org.libremail.BuildConfig
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Drives the Outlook/Microsoft OAuth wrapper in a pure JVM test. The AppAuth types it builds
@@ -64,18 +66,23 @@ class OutlookAuthManagerTest {
}
@Test
fun `exchangeToken mints an Exchange token and reads the account email from the id_token`() = runTest {
fun `exchangeToken builds the result from the code-exchange token without a second refresh`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse(access = "code-access", idToken = jwt("email" to "me@example.com"), refresh = "rt"),
tokenResponse(access = "outlook-access", idToken = null, refresh = "rt"),
tokenResponse(access = "code-access", idToken = jwt("email" to "me@example.com"), refresh = "durable-rt"),
)
val result = authManager().exchangeToken(mockk<Intent>())
assertEquals("me@example.com", result.email)
assertEquals("outlook-access", result.accessToken) // the Exchange-scoped token, not the code one
// The code exchange already named the Exchange Online resource, so its access token is used
// directly for IMAP verification — not a token from a second, redundant refresh round-trip.
assertEquals("code-access", result.accessToken)
// The durable AuthState — carrying the refresh token for later token refreshes — is persisted.
assertTrue(result.authStateJson.contains("durable-rt"))
// Exactly one token request (the code exchange); the redundant second refresh is gone.
verify(exactly = 1) { anyConstructed<AuthorizationService>().performTokenRequest(any(), any()) }
}
@Test
@@ -84,7 +91,6 @@ class OutlookAuthManagerTest {
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse("code-access", jwt("email" to "", "preferred_username" to "alt@example.com"), "rt"),
tokenResponse("outlook-access", null, "rt"),
)
assertEquals("alt@example.com", authManager().exchangeToken(mockk<Intent>()).email)