hotfix: OAuth login being aborted by a forced logout on Android

The app logged itself out in the middle of the OAuth code exchange,
tearing down the page before the callback request could finish.

On resume, the deep-link handler and the sync-on-resume listener fire in
the same tick. Sync ran first, hit a 401 (no session exists yet), and
ApiClient treated that as an expired session: it cleared tokens, wiped
the offline DB, and hard-navigated to /login via window.location.href.
That reload aborted the in-flight POST /auth/oauth2/callback, surfacing
as "Authentication request failed TypeError: Failed to fetch".
This commit is contained in:
Mo Tarbin
2026-07-28 20:45:29 -04:00
parent ce07e467f3
commit f92063dfe9
6 changed files with 70 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher'
import { beginOAuthExchange } from './utils/OAuthExchangeState'
// React Router navigate(), injected by <App /> once the router is mounted.
// Using client-side navigation (instead of window.location.href) avoids a full
@@ -104,6 +105,13 @@ const handleOAuthDeepLink = async url => {
return
}
// Claim the exchange window synchronously, before the first await: the
// resume-driven background sync fires in the same tick as this deep link,
// and its 401 must not be mistaken for an expired session. Set after the
// early return above so the flag is only ever claimed by the navigation
// that Authenticating.jsx will clear.
beginOAuthExchange()
// Store the OAuth params for the app to pick up
await Preferences.set({
key: 'oauth_callback',

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue'
import { offlineDB } from '../utils/OfflineDB'
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager'
@@ -90,6 +91,11 @@ export function useSyncOnReconnect() {
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
// Skip while the OAuth code exchange is in flight — there's no session
// yet, so a sync here just 401s. Note the app-resume listener fires in
// the same tick as the deep link, before the route changes, so this has
// to test the shared flag rather than the pathname.
if (isOAuthExchangeInProgress()) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {

View File

@@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { logout, RefreshToken } from './Fetcher'
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
import { offlineDB } from './OfflineDB'
import {
clearAllTokens,
@@ -9,6 +10,8 @@ import {
saveTokens,
} from './TokenStorage'
const OAUTH_EXCHANGE_IN_PROGRESS = 'OAuth exchange in progress'
class ApiClient {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
@@ -46,6 +49,13 @@ class ApiClient {
}
async refreshToken() {
// No session exists yet while an OAuth code exchange is in flight, so there
// is nothing to refresh. Callers must treat this as a non-fatal failure
// (see request()) rather than an expired session.
if (isOAuthExchangeInProgress()) {
return { success: false, error: OAUTH_EXCHANGE_IN_PROGRESS }
}
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
@@ -132,6 +142,14 @@ class ApiClient {
// Helper to avoid repeating cleanup code
async handleLogout() {
// Backstop for every forced-logout path: never tear down the session while
// an OAuth exchange is running, or we clear the tokens it just saved and
// reload the page out from under it.
if (isOAuthExchangeInProgress()) {
console.log('Skipping forced logout: OAuth exchange in progress')
return
}
await clearAllTokens()
try {
await offlineDB.clearAll()
@@ -230,6 +248,13 @@ class ApiClient {
this.handleLogout()
return null
}
} else if (refreshResult.error === OAUTH_EXCHANGE_IN_PROGRESS) {
// Expected 401: the code exchange hasn't produced tokens yet. Fail
// just this request — logging out here would wipe storage and hard
// navigate to /login, aborting the exchange fetch mid-flight.
queuedPromise.catch(() => {}) // not returned below; keep it handled
this.processQueue(new Error(refreshResult.error), null)
return response
} else if (refreshResult.error === 'Already refreshing') {
// This shouldn't happen since we check isRefreshing above, but handle it anyway
console.log('Already refreshing - waiting for refresh to complete')

View File

@@ -0,0 +1,24 @@
// Tracks whether an OAuth authorization-code exchange is currently in flight.
//
// During that window the app is legitimately unauthenticated: the deep link has
// arrived but Authenticating.jsx has not received tokens yet. Any 401 from an
// unrelated request (background sync, a resumed query) must NOT be treated as an
// expired session — the forced logout it triggers clears storage and does a hard
// `window.location.href = '/login'`, which tears down the page and aborts the
// in-flight code exchange.
let exchangeInProgress = false
export const beginOAuthExchange = () => {
exchangeInProgress = true
}
export const endOAuthExchange = () => {
exchangeInProgress = false
}
// The in-memory flag covers the native deep-link path, where the callback
// arrives while the app is already running. On web the provider redirects with a
// full page load, so nothing has run to set the flag — the pathname check covers
// that case (and doubles as a backstop if the flag is never cleared).
export const isOAuthExchangeInProgress = () =>
exchangeInProgress || window.location.pathname === '/auth/oauth2'

View File

@@ -8,6 +8,7 @@ import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { endOAuthExchange } from '../../utils/OAuthExchangeState'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
@@ -25,11 +26,14 @@ const AuthenticationLoading = () => {
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
hasCalledHandleOAuth2.current = true
handleOAuth2()
// Release the guard once the exchange settles, so a stuck flag can never
// suppress a genuine session expiry later on.
handleOAuth2().finally(endOAuthExchange)
} else if (provider !== 'oauth2') {
setMessage('Unknown Authentication Provider')
setSubMessage('Please contact support')
}
return endOAuthExchange
}, [provider])
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {