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 <noreply@anthropic.com>
This commit is contained in:
@@ -63,10 +63,15 @@
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration. -->
|
||||
(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. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
) {
|
||||
|
||||
@@ -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<Intent> = 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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user