From 317ce24054ed855257f4ee096c5f5b2f8cc1f7a1 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 29 Jun 2026 23:15:14 -0500 Subject: [PATCH] Fix Outlook sign-in crash and complete the OAuth login flow "Sign in with Microsoft" crashed on the redirect back from the browser and never completed a login. Verified end-to-end on a real Outlook account after three fixes, in flow order: - Redirect crash (the reported symptom): AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs a Theme.AppCompat theme. This app is pure Compose (framework Theme.Material), and AppAuth declares that activity with no theme of its own, so it inherited the Material app theme and threw "You need to use a Theme.AppCompat theme" the instant Android launched it to deliver the redirect. Give it the translucent AppCompat theme AppAuth itself applies to AuthorizationManagementActivity. (Not an R8 issue.) - Token exchange rejected with AADSTS70011 ("must include a 'scope' input parameter"): one consent spans two Microsoft resources (Graph for send, Exchange Online for IMAP), so Microsoft mints one token per resource and the code-to-token exchange must name a single resource. AppAuth's createTokenExchangeRequest() sends no scope; build the request explicitly with a single-resource scope. - "Invalid ID Token" / nonce mismatch: the hand-built exchange request must replicate every field createTokenExchangeRequest() sets, including the nonce AppAuth validates the id_token against (and the PKCE code verifier). Also harden the account-setup screen: guard the previously unguarded createAuthIntent() launch (AppAuth throws ActivityNotFoundException when no browser is available) so it surfaces an error instead of crashing, dispose the AuthorizationService that createAuthIntent() leaked, and log sign-in failures via Log.d (stripped from release builds by the existing ProGuard rule). Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 7 +++- .../org/libremail/auth/OutlookAuthManager.kt | 35 +++++++++++++++++-- .../ui/accountsetup/AccountSetupScreen.kt | 10 +++++- .../ui/accountsetup/AccountSetupViewModel.kt | 30 ++++++++++++++-- 4 files changed, 76 insertions(+), 6 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28a6672..68e1c18 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -63,10 +63,15 @@ + (mobile/desktop) redirect in the Azure app registration. + AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an + AppCompat theme; without this it inherits the app's Theme.Material shell and crashes + ("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the + translucent theme AppAuth itself applies to AuthorizationManagementActivity. --> diff --git a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt index ed2cafd..e4c1297 100644 --- a/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt +++ b/app/src/main/kotlin/org/libremail/auth/OutlookAuthManager.kt @@ -45,6 +45,12 @@ class OutlookAuthManager @Inject constructor( /** Outlook is always available: the Microsoft client id ships with the build (it is not a secret). */ val isConfigured: Boolean get() = BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank() + /** + * Builds the browser/Custom-Tab intent that starts the Microsoft sign-in. + * + * Throws [android.content.ActivityNotFoundException] when no usable browser is installed; + * callers must guard the launch and surface that as an error rather than crashing. + */ fun createAuthIntent(): Intent { val request = AuthorizationRequest.Builder( serviceConfig, @@ -55,18 +61,43 @@ class OutlookAuthManager @Inject constructor( // One consent covering both resources; per-resource access tokens are minted later. .setScope("openid email $OFFLINE $GRAPH_SCOPE $OUTLOOK_SCOPE") .build() - return AuthorizationService(context).getAuthorizationRequestIntent(request) + // The returned intent is self-contained, so dispose the service (and its Custom-Tabs + // warmup binding) immediately instead of leaking one per button tap. + val service = AuthorizationService(context) + return try { + service.getAuthorizationRequestIntent(request) + } finally { + service.dispose() + } } suspend fun exchangeToken(responseIntent: Intent): OAuthResult { val response = AuthorizationResponse.fromIntent(responseIntent) val exception = AuthorizationException.fromIntent(responseIntent) if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled") + val authCode = response.authorizationCode + ?: throw exception ?: IllegalStateException("No authorization code was returned") + + // Microsoft issues one access token per resource, so the authorization-code exchange must + // name a single resource. AppAuth's createTokenExchangeRequest() sends no scope, which makes + // Microsoft reject our multi-resource consent code with AADSTS70011 ("must include a 'scope' + // input parameter"). Request the OIDC scopes plus the Exchange Online resource so we still get + // an id_token (for the email) and a refresh token to mint the Graph token from later. + // This mirrors every field createTokenExchangeRequest() sets — including the nonce, which + // AppAuth checks against the id_token's nonce claim (omitting it fails id_token validation). + val exchangeRequest = TokenRequest.Builder(serviceConfig, BuildConfig.OUTLOOK_OAUTH_CLIENT_ID) + .setGrantType(GrantTypeValues.AUTHORIZATION_CODE) + .setAuthorizationCode(authCode) + .setRedirectUri(Uri.parse(BuildConfig.OUTLOOK_OAUTH_REDIRECT_URI)) + .setCodeVerifier(response.request.codeVerifier) + .setNonce(response.request.nonce) + .setScope("openid email $OFFLINE $OUTLOOK_SCOPE") + .build() val service = AuthorizationService(context) try { val tokenResponse = suspendCancellableCoroutine { continuation -> - service.performTokenRequest(response.createTokenExchangeRequest()) { token, error -> + service.performTokenRequest(exchangeRequest) { token, error -> if (token != null) { continuation.resume(token) } else { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt index b76d5d4..59a4751 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt @@ -101,7 +101,15 @@ fun AccountSetupScreen( ) Spacer(Modifier.height(24.dp)) Button( - onClick = { outlookLauncher.launch(viewModel.outlookAuthIntent()) }, + onClick = { + viewModel.outlookAuthIntent().fold( + onSuccess = { intent -> + runCatching { outlookLauncher.launch(intent) } + .onFailure { viewModel.onOutlookLaunchFailed(it) } + }, + onFailure = { viewModel.onOutlookLaunchFailed(it) }, + ) + }, enabled = !busy, modifier = Modifier.fillMaxWidth(), ) { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index 787e5ea..7c25643 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.accountsetup +import android.content.ActivityNotFoundException import android.content.Intent +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,7 +35,22 @@ class AccountSetupViewModel @Inject constructor( val isOutlookConfigured: Boolean get() = outlookAuthManager.isConfigured - fun outlookAuthIntent(): Intent = outlookAuthManager.createAuthIntent() + /** + * Builds the Microsoft sign-in intent. Wrapped in [Result] because AppAuth throws + * (e.g. [ActivityNotFoundException] when no browser is available) while building it; the + * screen surfaces a failure as an error instead of letting it crash the app. + */ + fun outlookAuthIntent(): Result = runCatching { outlookAuthManager.createAuthIntent() } + + /** Reports a failure to build or launch the sign-in intent through the error snackbar. */ + fun onOutlookLaunchFailed(error: Throwable) { + val message = if (error is ActivityNotFoundException) { + "No web browser is available for Microsoft sign-in" + } else { + error.message ?: "Couldn't start Microsoft sign-in" + } + _state.update { it.copy(status = SetupStatus.IDLE, error = message) } + } fun onOutlookResult(data: Intent?) { if (data == null) { @@ -47,10 +64,19 @@ class AccountSetupViewModel @Inject constructor( accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() }.fold( onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, - onFailure = { e -> _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } }, + onFailure = { e -> + // Stripped from release builds by the Log.d ProGuard rule (keeps any account + // address / token detail out of shipped logs); visible in debug for diagnosis. + Log.d(TAG, "Outlook sign-in failed after redirect", e) + _state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") } + }, ) } } fun consumeError() = _state.update { it.copy(error = null) } + + private companion object { + const val TAG = "AccountSetupVM" + } } -- 2.47.3