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

@@ -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'