Android OAuth sign-in/link fails: native URL drops deep-link path → carol:/// Unmatched 404 #297

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

After completing OAuth in the system browser on Android, the app returns to a bare deep link carol:/// and Expo Router shows its built-in "Unmatched Route" 404 (see attached screenshot). Sign-in/link never completes.

Root cause

React Native's built-in global URL (react-native/Libraries/Blob/URL.js, installed by setUpXHR.js) derives host/pathname with regexes hardcoded to https?:// (lines 131, 158). No react-native-url-polyfill is installed and Expo SDK 56's winter runtime is absent, so RN's implementation is authoritative. Therefore on-device:

new URL("carol://auth/oauth/complete?init=…&code=…")
  → host="", pathname="/", searchParams OK

That single defect breaks both consumers of the return deep link:

  1. Token exchange never runs. parseOauthCompleteDeepLink / parseLinkCompleteDeepLink gate on `${parsed.host}${parsed.pathname}` (apps/client/lib/auth/oauthDeepLinkParse.ts:32-33). On-device that's "" + "/""""auth/oauth/complete" → parser returns null → the awaiting native sign-in flow never receives {init, code}, so POST /api/auth/token never happens.
  2. Expo Router 404s. expo-router builds the route from host + pathname → empty → renders Unmatched, whose label is createURL("/") = carol:/// (the screenshot).

Masked in CI because vitest runs under Node's spec-compliant URL.

Fix

  1. Add react-native-url-polyfill and install a WHATWG-compliant URL on native (not web) at the top of apps/client/app/_layout.tsx, before any deep-link code runs. Fixes the parsers + router extraction.
  2. Add apps/client/app/+native-intent.ts (redirectSystemPath) to redirect the non-routable return deep links to real screens (carol://auth/oauth/complete…/login, carol://account/identities…/account), matched on the raw URL string. The Linking listener still drains the queue and completes the exchange.

Known follow-up (separate ticket)

True Android cold-start completion (app killed during the browser handoff) still won't finish the exchange — the in-memory waiter is gone, so the queued {init, code} is never drained. The user lands cleanly on /login but must retry. Making cold-start self-healing (drain the queue from a bootstrap effect / dedicated route) is out of scope for this fix.

Verify

Fresh Android build (not a JS reload): sign in with GitHub → returns to the app, completes, lands on /notes signed in — no carol:///. Repeat for account linking → /account.

After completing OAuth in the system browser on Android, the app returns to a bare deep link **`carol:///`** and Expo Router shows its built-in "Unmatched Route" 404 (see attached screenshot). Sign-in/link never completes. ## Root cause React Native's built-in global `URL` (`react-native/Libraries/Blob/URL.js`, installed by `setUpXHR.js`) derives `host`/`pathname` with regexes hardcoded to `https?://` (lines 131, 158). No `react-native-url-polyfill` is installed and Expo SDK 56's winter runtime is absent, so RN's implementation is authoritative. Therefore on-device: ``` new URL("carol://auth/oauth/complete?init=…&code=…") → host="", pathname="/", searchParams OK ``` That single defect breaks **both** consumers of the return deep link: 1. **Token exchange never runs.** `parseOauthCompleteDeepLink` / `parseLinkCompleteDeepLink` gate on `` `${parsed.host}${parsed.pathname}` `` (`apps/client/lib/auth/oauthDeepLinkParse.ts:32-33`). On-device that's `"" + "/"` → `""` ≠ `"auth/oauth/complete"` → parser returns null → the awaiting native sign-in flow never receives `{init, code}`, so `POST /api/auth/token` never happens. 2. **Expo Router 404s.** expo-router builds the route from `host + pathname` → empty → renders `Unmatched`, whose label is `createURL("/")` = **`carol:///`** (the screenshot). Masked in CI because vitest runs under Node's spec-compliant `URL`. ## Fix 1. Add `react-native-url-polyfill` and install a WHATWG-compliant `URL` on **native** (not web) at the top of `apps/client/app/_layout.tsx`, before any deep-link code runs. Fixes the parsers + router extraction. 2. Add `apps/client/app/+native-intent.ts` (`redirectSystemPath`) to redirect the non-routable return deep links to real screens (`carol://auth/oauth/complete…` → `/login`, `carol://account/identities…` → `/account`), matched on the raw URL string. The `Linking` listener still drains the queue and completes the exchange. ## Known follow-up (separate ticket) True Android **cold-start** completion (app killed during the browser handoff) still won't finish the exchange — the in-memory waiter is gone, so the queued `{init, code}` is never drained. The user lands cleanly on `/login` but must retry. Making cold-start self-healing (drain the queue from a bootstrap effect / dedicated route) is out of scope for this fix. ## Verify Fresh Android build (not a JS reload): sign in with GitHub → returns to the app, completes, lands on `/notes` signed in — no `carol:///`. Repeat for account linking → `/account`.
james closed this issue 2026-06-26 22:40:12 +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#297
No description provided.