ADR: fix-mobile-auth-callback-route
ADR: fix-mobile-auth-callback-route
Section titled “ADR: fix-mobile-auth-callback-route”Context
Section titled “Context”OAuth deep-links into gym_app hit a GoException: no routes for location: com.fitspace.client.app://auth/callback?code=<uuid> because the router has no
matching route and no errorBuilder. The guard short-circuit on
/auth/callback never fires (GoRouter’s redirect only runs for matched
routes). Root cause and confirmation are in the spec — see
specs/fix-mobile-auth-callback-route.md.
Decision
Section titled “Decision”- Add a
GoRoute(path: '/auth/callback', builder: …)toapps/gym_app/lib/router/app_router.dartwhose builder renders only a centeredCircularProgressIndicatoron aScaffoldand emits oneLogger().info('PKCE callback received')line. The builder does NOT touch the Supabase SDK and does NOT callcontext.go(...). - Add an
errorBuilder:on the sameGoRouterinstance. In release builds it redirects (viaWidgetsBinding.instance.addPostFrameCallback→context.go(...)) to/auth/welcomeifauth.isAuthenticated == false, else/home/main. In non-release builds (!kReleaseMode) it falls back to the default GoException widget so genuine routing bugs stay visible during development. - Remove the
/auth/callbackshort-circuit atpackages/auth/lib/src/auth_guard.dart:19— the new matched route makes it dead code. The remaining guard logic (empty path / root → home or welcome by auth state) is kept untouched.
Considered alternatives
Section titled “Considered alternatives”A. Call supabase.auth.exchangeCodeForSession(code) from the route builder
Section titled “A. Call supabase.auth.exchangeCodeForSession(code) from the route builder”Rejected. supabase_flutter 2.12.4 is initialised with
detectSessionInUrl: true (default), which wires an app_links listener that
consumes the URI and performs the PKCE exchange itself. A second manual call
races the SDK and throws "code already exchanged" (or worse, succeeds first
and leaves the SDK’s internal listener in a half-initialised state). The route
builder must be a passive landing pad.
B. Navigate explicitly via context.go('/home/main') from the callback builder
Section titled “B. Navigate explicitly via context.go('/home/main') from the callback builder”Rejected. Navigation post-exchange is already driven by
onAuthStateChange → AuthChangeEvent.signedIn at auth_service.dart:69,
which calls notifyListeners() and triggers the refreshListenable: auth on
the GoRouter (app_router.dart:65). The guard then redirects based on
isAuthenticated. An explicit context.go fires before the session is fully
written to secure storage and competes with the listener — flaky in practice,
intermittently lands users back on /auth/welcome.
C. Keep the guard short-circuit and just add the route
Section titled “C. Keep the guard short-circuit and just add the route”Rejected. Once a real route exists, the short-circuit becomes unreachable code
that future maintainers will misread as load-bearing. The redirect chain runs
through the normal if (!isAuth && !isOnAuthPage) branch — /auth/callback
already starts with /auth, so unauthenticated users won’t be bounced
mid-exchange.
D. Rewrite the guard short-circuit to handle the route explicitly
Section titled “D. Rewrite the guard short-circuit to handle the route explicitly”Rejected for the same reason as C plus added complexity. The new code path is strictly simpler — fewer branches, fewer special cases.
Surface impact
Section titled “Surface impact”N/A — spec frontmatter has surfaces: []; no contract change.
API design (per surface)
Section titled “API design (per surface)”N/A — spec frontmatter has surfaces: []; no contract or schema change.
Data model
Section titled “Data model”N/A — spec frontmatter has surfaces: []; no contract or schema change.
Backend module placement
Section titled “Backend module placement”N/A — spec frontmatter has surfaces: []; no backend change.
Frontend implications (per app)
Section titled “Frontend implications (per app)”tktspace-business: no change.tktspace-web: no change.tktspace-landing: no change.
Mobile implications
Section titled “Mobile implications”- App affected:
apps/gym_apponly.tickets_appdoes not use Supabase OAuth and has no/auth/callbackdeep link. - Shared packages affected:
packages/auth(one-line removal inauth_guard.dart). - Touch list:
apps/gym_app/lib/router/app_router.dart— addGoRoute('/auth/callback')errorBuilder:on theGoRouter.
packages/auth/lib/src/auth_guard.dart:19— drop theloc.startsWith('/auth/callback')term.apps/gym_app/test/router/auth_callback_route_test.dart(new) — Phase B widget test (AC-6).
- No native / Podfile / Info.plist changes. The
com.fitspace.client.app://URI scheme is already registered for the existing app_links pipeline. - No new package deps.
go_router,provider,supabase_flutter,tktspace_core(Logger) are already present ingym_app. - Deep-linking: unchanged at the OS layer. This ADR only fixes the
in-router landing once
app_linkshands the URI off to GoRouter. - Offline considerations: N/A — OAuth callback requires network by definition.
Interaction with sibling ticket 869dkrxau
Section titled “Interaction with sibling ticket 869dkrxau”Sibling ticket 869dkrxau (fix-mobile-supabase-session-restore-race) plans
to introduce an explicit hydration signal so the router waits for Supabase to
restore the session before evaluating the guard. The interaction:
- This ticket does not depend on 869dkrxau. The callback flow works
correctly today regardless of whether
AuthService.notifyListeners()fires during cold start, becausesupabase_flutterconsumes the PKCE code via its ownapp_linkslistener and emitssignedInonce the exchange finishes — the listener then notifies ourAuthServicewhich refreshes the router. - Once 869dkrxau lands, the
/auth/callbackloading scaffold will benefit for free: the hydration signal makes the guard’s “should I redirect away?” decision more deterministic, removing the (rare) flash of/auth/welcomeifsignedInfires before the in-flight redirect resolves. - Ordering: these tickets can land in either order. If 869dkrxau lands first, no change to this ADR. If this lands first, 869dkrxau’s hydration flag layers on top without revisiting the route or the guard.
Risks & mitigations
Section titled “Risks & mitigations”errorBuilderswallows legitimate routing bugs in dev. Mitigation: gate the redirect-on-unmatched behaviour behindkReleaseMode. Dev and profile builds fall through to GoRouter’s default GoException widget so typos and missing routes still surface loudly. Release builds redirect quietly to/auth/welcome(unauthed) or/home/main(authed).- PKCE exchange takes longer than expected, loader visible >1s on slow
networks. Mitigation: none — accepted by the spec. AC-5’s
Logger().info('PKCE callback received')line gives us a production timing signal to measure exchange latency and decide if a future iteration needs better UX. - Guard removal regression. Mitigation: the new matched route covers the
exact location the removed short-circuit targeted; the rest of the guard
still handles
loc.isEmpty || loc == '/'. Widget test in AC-6 + a one-time manual OAuth round-trip cover the regression surface. errorBuilderredirect runs duringbuild. Mitigation: schedule thecontext.go(...)insideWidgetsBinding.instance.addPostFrameCallbackto avoid “navigated during build” assertions. Render a transient emptyScaffoldwhile the post-frame callback fires.
Rollout plan
Section titled “Rollout plan”- Feature flag: none. Bug fix to a broken happy-path; gating would just keep users on the broken path.
- Phased: no. Single-PR fix in
tktspace-mobile-apptargeted atgym_app, shipped to TestFlight / internal Play track first per the repo’s standard mobile release cadence. - Backfill: N/A.
- Removal of AC-5 log line: the
Logger().info('PKCE callback received')call is intentionally temporary. Open a follow-up ticket to drop it once one release confirms the fix in prod (per AC-5 wording).
Test approach
Section titled “Test approach”- Unit / widget (Phase B): single
testWidgets()inapps/gym_app/test/router/auth_callback_route_test.dartthat:- Builds the
GoRouterfrombuildRouter(FakeAuthService())wrapped in aMaterialApp.router. - Calls
router.go('/auth/callback?code=fake'). - Pumps and asserts: no
GoExceptionwidget, exactly oneCircularProgressIndicator, and no thrown exceptions. No simulator and no real Supabase client —FakeAuthServicejust extendsChangeNotifierand returnsfalseforisAuthenticated.
- Builds the
- Manual: post-merge, one OAuth round-trip on a real device per provider (Google + Apple) to confirm AC-1 / AC-2 against the dev backend.
- No backend, contract, or DB tests — none of those surfaces change.
STATUS: READY_FOR_REVIEW