From 1cdc98226948f28586f715c556c122e49736fd2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 21:58:26 +0000 Subject: [PATCH] Silently drop redirects from abandoned auth attempts, guard double-tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real triggers for "authentication request mismatch", both now fixed: - Continue had no debounce: two rapid taps could fire authenticate() twice before the first tap's state change recomposed the button away, so the second call's POST /authentication overwrote the pending request the first call's Custom Tab was about to redirect back to. LoginScreen now disables Continue synchronously on click via a local guard, independent of the coroutine's state update. - Tapping "Try again" while an earlier Custom Tab is still alive is a legitimate, intentional way to restart — but that old tab can still redirect back afterwards with its now-superseded authenticationRequestId. authenticate() already logged this abandonment; completeAuthenticationRequest() now checks the redirect's ID against it and drops a recognized stale redirect quietly (info log, no _authStatus change, no user-facing error) instead of throwing "Authentication request mismatch". An unrecognized mismatch still throws — that's still a genuine problem worth surfacing. --- .../email/data/auth/AuthressLoginClient.kt | 35 +++++++++++++++---- .../email/presentation/auth/LoginScreen.kt | 24 ++++++++++--- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt index fc95ede..dccec80 100644 --- a/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt +++ b/app/src/main/java/ch/rhosys/email/data/auth/AuthressLoginClient.kt @@ -105,6 +105,16 @@ class AuthressLoginClient( private val _authError = MutableStateFlow(null) val authError: StateFlow = _authError.asStateFlow() + /** + * Set by [authenticate] when it starts a new attempt while a previous one was + * still in flight (e.g. "Try again" tapped while the first Custom Tab is still + * alive). If that first tab still redirects back afterwards, its + * authenticationRequestId won't match the new pending one — expected, not a + * bug — so [completeAuthenticationRequest] drops it quietly instead of + * surfacing "Authentication request mismatch" to the user. + */ + private var abandonedAuthenticationRequestId: String? = null + init { // The SDK restores cookies from encrypted storage in its constructor, // before anything reads a token. @@ -146,15 +156,16 @@ class AuthressLoginClient( if (previousStatus != AuthStatus.Idle) { // Most often: the user waited past the slow-hint and tapped "Try again" // while the first Custom Tab is still alive. That tab can still redirect - // back with the OLD authenticationRequestId after we've moved on to a - // new one — completeAuthenticationRequest() will log that as a mismatch - // against the new pending request. Logged here so the two log lines can - // be correlated instead of the mismatch looking unexplained. + // back with the OLD authenticationRequestId after we've moved on to a new + // one; recording it here lets completeAuthenticationRequest() recognize + // and quietly drop that stale redirect instead of surfacing it as an + // "Authentication request mismatch" error. logger.warn( "Authress", "authenticate() re-entered while previous attempt was $previousStatus" + (abandonedPending?.let { " — abandoning authenticationRequestId=${it.authenticationRequestId}" } ?: ""), ) + abandonedAuthenticationRequestId = abandonedPending?.authenticationRequestId } logger.info("Authress", "authenticate() started (connectionId=${options.connectionId})") @@ -236,17 +247,27 @@ class AuthressLoginClient( "completeAuthenticationRequest() started (redirect received, authenticationRequestId=$authenticationRequestId, " + "code=${if (code.isEmpty()) "missing" else "present"})", ) + + if (authenticationRequestId.isNotEmpty() && authenticationRequestId == abandonedAuthenticationRequestId) { + // A stale Custom Tab from an attempt we already moved on from (see + // authenticate()) finally redirected back. Expected, not an error — drop + // it without touching _authStatus, which belongs to whatever attempt is + // actually current. + logger.info("Authress", "ignoring redirect for abandoned authenticationRequestId=$authenticationRequestId") + return@runCatching + } + _authStatus.value = AuthStatus.VerifyingRedirect val pending = storage.getAuthenticationRequest() ?: throw AuthressException("No authentication request in progress (redirect carried authenticationRequestId=$authenticationRequestId)") if (pending.authenticationRequestId != authenticationRequestId) { - // The likely cause is logged by authenticate() when it abandons a - // still-live attempt; these two lines are meant to be read together. + // Not a recognized abandonment (checked above) and doesn't match the + // current pending request either — a genuinely unexpected mismatch. logger.warn( "Authress", "authentication request mismatch: pending=${pending.authenticationRequestId}, redirect=$authenticationRequestId " + - "— this redirect is probably from an earlier Custom Tab that was still open when a new attempt started", + "— not a known-abandoned request either; redirect may be stale from before the app was killed/reinstalled", ) throw AuthressException("Authentication request mismatch") } diff --git a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt index 3da8b40..82472c4 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/auth/LoginScreen.kt @@ -71,6 +71,16 @@ fun LoginScreen(onSignedIn: () -> Unit) { var mailboxError by remember { mutableStateOf(null) } var slowHint by remember { mutableStateOf(false) } + // Guards the Continue button against a double-tap firing authenticate() twice + // before the first tap's status change has recomposed the button away — Compose + // doesn't debounce onClick, and two authenticate() calls in flight is exactly + // the "authentication request mismatch" trigger. Reset once authStatus actually + // reflects a real attempt (or fails back to Idle), not on a timer. + var signInStarting by remember { mutableStateOf(false) } + LaunchedEffect(authStatus) { + if (authStatus != AuthressLoginClient.AuthStatus.Idle) signInStarting = false + } + val currentStep = when { mailboxLoading -> LoginStep.LoadingMailbox authStatus == AuthressLoginClient.AuthStatus.RequestingAuthenticationUrl -> LoginStep.RequestingAuthenticationUrl @@ -125,11 +135,15 @@ fun LoginScreen(onSignedIn: () -> Unit) { ) if (currentStep == null) { - Button(onClick = { - container.appLogger.info("Login", "\"Continue\" tapped") - mailboxError = null - scope.launch { container.authManager.authenticate() } - }) { + Button( + enabled = !signInStarting, + onClick = { + signInStarting = true + container.appLogger.info("Login", "\"Continue\" tapped") + mailboxError = null + scope.launch { container.authManager.authenticate() } + }, + ) { Text("Continue") } } else {