Android OAuth cold-start doesn't complete sign-in (in-memory waiter lost when app is killed during browser handoff) #300

Closed
opened 2026-06-26 22:41:43 +00:00 by james · 0 comments
Owner

Follow-up to #297 / PR #298.

PR #298 fixes the warm-path Android OAuth return (URL polyfill + +native-intent), so sign-in completes and the user never sees the carol:/// 404. But on a true cold start — Android kills the app while the system browser is in the foreground (e.g. low memory, or "Don't keep activities" enabled) — sign-in still doesn't finish.

Why

The native sign-in flow sets up an in-memory waiter when the user taps "Sign in" (awaitOauthDeepLink in apps/client/lib/auth/oauthNativeFlow.ts, armed from apps/client/app/login.tsx's onPress). When the OS kills the app, that promise/waiter is gone. On relaunch, the return deep link carol://auth/oauth/complete?init=…&code=… arrives via Linking.getInitialURL() and is pushed onto the queue in apps/client/lib/auth/oauthDeepLink.ts, but nothing drains it — there's no live waiter — so the POST /api/auth/token exchange never runs. The user lands cleanly on /login (thanks to #298's native-intent redirect) but is not signed in and must retry.

Suggested fix (drain the queue independently of the in-memory waiter)

Make completion survive a process restart, e.g.:

  • A bootstrap effect in apps/client/app/_layout.tsx that, on launch, checks the pending deep-link queue (oauthDeepLink.ts) and runs the token exchange if an unconsumed {init, code} payload is present — independent of whether a login.tsx waiter exists; or
  • Move completion into a dedicated route (e.g. an auth/oauth/complete screen) that reads useLocalSearchParams() and performs the exchange itself, with +native-intent redirecting the deep link there instead of /login.

Either way the init token is single-use server-side, so guard against double-consumption (warm waiter + bootstrap both firing).

Verify

Enable Android developer option "Don't keep activities", start OAuth sign-in, authenticate in the browser. On return the app should complete the exchange and land signed in on /notes — not sit unauthenticated on /login. Re-confirm the warm path (activities kept) still works and isn't double-exchanged.

Follow-up to #297 / PR #298. PR #298 fixes the **warm-path** Android OAuth return (URL polyfill + `+native-intent`), so sign-in completes and the user never sees the `carol:///` 404. But on a true **cold start** — Android kills the app while the system browser is in the foreground (e.g. low memory, or "Don't keep activities" enabled) — sign-in still doesn't finish. ## Why The native sign-in flow sets up an **in-memory** waiter when the user taps "Sign in" (`awaitOauthDeepLink` in `apps/client/lib/auth/oauthNativeFlow.ts`, armed from `apps/client/app/login.tsx`'s `onPress`). When the OS kills the app, that promise/waiter is gone. On relaunch, the return deep link `carol://auth/oauth/complete?init=…&code=…` arrives via `Linking.getInitialURL()` and is pushed onto the queue in `apps/client/lib/auth/oauthDeepLink.ts`, but **nothing drains it** — there's no live waiter — so the `POST /api/auth/token` exchange never runs. The user lands cleanly on `/login` (thanks to #298's native-intent redirect) but is not signed in and must retry. ## Suggested fix (drain the queue independently of the in-memory waiter) Make completion survive a process restart, e.g.: - A bootstrap effect in `apps/client/app/_layout.tsx` that, on launch, checks the pending deep-link queue (`oauthDeepLink.ts`) and runs the token exchange if an unconsumed `{init, code}` payload is present — independent of whether a `login.tsx` waiter exists; **or** - Move completion into a dedicated route (e.g. an `auth/oauth/complete` screen) that reads `useLocalSearchParams()` and performs the exchange itself, with `+native-intent` redirecting the deep link there instead of `/login`. Either way the `init` token is single-use server-side, so guard against double-consumption (warm waiter + bootstrap both firing). ## Verify Enable Android developer option **"Don't keep activities"**, start OAuth sign-in, authenticate in the browser. On return the app should complete the exchange and land signed in on `/notes` — not sit unauthenticated on `/login`. Re-confirm the warm path (activities kept) still works and isn't double-exchanged.
james closed this issue 2026-06-29 18:11:50 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
james/carol#300
No description provided.