Skip to content

Commit 3f3e340

Browse files
schwaaampclaude
andcommitted
Fix Fitbit auth failure: stale JWT, token desync, and silent error swallowing
Three bugs caused 'Authentication required' errors when connecting/reconnecting Fitbit: 1. useTrackerAuth read session.access_token from React state set once at init — after the Supabase JWT expired (~1hr), the stale token was still sent. Now calls getAccessToken() which auto-refreshes via SecureStore/GoAuth. 2. getAccessToken() now syncs refreshed tokens back to React state, Zustand store, and the Supabase JS client when it detects the token changed — preventing desync between the three auth layers. 3. fitbit-auth-start returned JSON 401 on auth errors, but expo-web-browser cannot parse JSON responses. Now extracts redirect_uri before auth check and redirects errors back to the app as ?error=...&success=false query params. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd82205 commit 3f3e340

File tree

6 files changed

+437
-318
lines changed

6 files changed

+437
-318
lines changed

mobile/src/utils/auth/__tests__/useAuth.test.tsx

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { renderHook, waitFor, act } from '@testing-library/react-native';
22
import { useAuth, resetSessionInit } from '../useAuth';
33
import * as SupabaseAuth from '@/utils/supabaseAuth';
44
import { signInWithGoogleWebBrowser } from '../googleAuth';
5+
import { supabase } from '@/utils/supabaseClient';
6+
import { useAuthStore } from '../store';
57
import { Platform } from 'react-native';
68

79
// Mock dependencies
@@ -544,4 +546,129 @@ describe('useAuth Hook', () => {
544546
});
545547
});
546548
});
549+
550+
describe('Token refresh sync (getAccessToken)', () => {
551+
const initialSession = {
552+
access_token: 'initial-token',
553+
refresh_token: 'refresh-token',
554+
expires_at: Date.now() / 1000 + 3600,
555+
};
556+
557+
const refreshedSession = {
558+
access_token: 'refreshed-token',
559+
refresh_token: 'new-refresh-token',
560+
expires_at: Date.now() / 1000 + 7200,
561+
};
562+
563+
it('should update React session state when getAccessToken detects a refreshed token', async () => {
564+
// Start with initial session
565+
SupabaseAuth.getSession.mockResolvedValue(initialSession);
566+
567+
const { result } = renderHook(() => useAuth());
568+
569+
await waitFor(() => {
570+
expect(result.current.isReady).toBe(true);
571+
expect(result.current.session?.access_token).toBe('initial-token');
572+
});
573+
574+
// Now getSession returns refreshed token (as if SupabaseAuth.refreshSession ran)
575+
SupabaseAuth.getSession.mockResolvedValue(refreshedSession);
576+
577+
await act(async () => {
578+
const token = await result.current.getAccessToken();
579+
expect(token).toBe('refreshed-token');
580+
});
581+
582+
// The hook's session state should now reflect the refreshed token
583+
expect(result.current.session?.access_token).toBe('refreshed-token');
584+
});
585+
586+
it('should sync Zustand auth store when token is refreshed', async () => {
587+
SupabaseAuth.getSession.mockResolvedValue(initialSession);
588+
589+
const { result } = renderHook(() => useAuth());
590+
591+
await waitFor(() => {
592+
expect(result.current.isReady).toBe(true);
593+
});
594+
595+
// Clear mock call history from initialization
596+
const setAuthMock = useAuthStore.getState().setAuth as jest.Mock;
597+
setAuthMock.mockClear();
598+
599+
// Return refreshed session
600+
SupabaseAuth.getSession.mockResolvedValue(refreshedSession);
601+
602+
await act(async () => {
603+
await result.current.getAccessToken();
604+
});
605+
606+
// Zustand store should be updated with the new token
607+
expect(setAuthMock).toHaveBeenCalledWith({ token: 'refreshed-token' });
608+
});
609+
610+
it('should update Supabase client session when token is refreshed', async () => {
611+
SupabaseAuth.getSession.mockResolvedValue(initialSession);
612+
613+
const { result } = renderHook(() => useAuth());
614+
615+
await waitFor(() => {
616+
expect(result.current.isReady).toBe(true);
617+
});
618+
619+
// Spy on supabase.auth.setSession
620+
const setSessionSpy = jest.spyOn(supabase.auth, 'setSession').mockResolvedValue({
621+
data: { session: null, user: null },
622+
error: null,
623+
});
624+
625+
// Return refreshed session
626+
SupabaseAuth.getSession.mockResolvedValue(refreshedSession);
627+
628+
await act(async () => {
629+
await result.current.getAccessToken();
630+
});
631+
632+
// Supabase client should be updated with the new tokens
633+
expect(setSessionSpy).toHaveBeenCalledWith({
634+
access_token: 'refreshed-token',
635+
refresh_token: 'new-refresh-token',
636+
});
637+
638+
setSessionSpy.mockRestore();
639+
});
640+
641+
it('should NOT sync state when token has not changed', async () => {
642+
SupabaseAuth.getSession.mockResolvedValue(initialSession);
643+
644+
const setSessionSpy = jest.spyOn(supabase.auth, 'setSession').mockResolvedValue({
645+
data: { session: null, user: null },
646+
error: null,
647+
});
648+
649+
const { result } = renderHook(() => useAuth());
650+
651+
await waitFor(() => {
652+
expect(result.current.isReady).toBe(true);
653+
});
654+
655+
// Clear mock calls from initialization
656+
const setAuthMock = useAuthStore.getState().setAuth as jest.Mock;
657+
setAuthMock.mockClear();
658+
setSessionSpy.mockClear();
659+
660+
// Return the SAME session (no refresh happened)
661+
SupabaseAuth.getSession.mockResolvedValue(initialSession);
662+
663+
await act(async () => {
664+
await result.current.getAccessToken();
665+
});
666+
667+
// Neither Zustand store nor Supabase client should be updated
668+
expect(setAuthMock).not.toHaveBeenCalled();
669+
expect(setSessionSpy).not.toHaveBeenCalled();
670+
671+
setSessionSpy.mockRestore();
672+
});
673+
});
547674
});

mobile/src/utils/auth/useAuth.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,24 @@ export const useAuth = (): UseAuthReturn => {
411411

412412
const getAccessToken = useCallback(async (): Promise<string | undefined> => {
413413
const currentSession = await SupabaseAuth.getSession();
414-
return currentSession?.access_token;
415-
}, []);
414+
if (!currentSession?.access_token) return undefined;
415+
416+
// If the token was refreshed (SupabaseAuth.getSession auto-refreshes expired
417+
// tokens via GoAuth), sync the new token to all auth state holders so other
418+
// consumers don't operate with a stale JWT.
419+
if (currentSession.access_token !== session?.access_token) {
420+
setSession(currentSession);
421+
useAuthStore.getState().setAuth({ token: currentSession.access_token });
422+
423+
// Keep the Supabase JS client in sync for RLS queries
424+
supabase.auth.setSession({
425+
access_token: currentSession.access_token,
426+
refresh_token: currentSession.refresh_token || '',
427+
}).catch(() => {});
428+
}
429+
430+
return currentSession.access_token;
431+
}, [session?.access_token]);
416432

417433
// Legacy: Keep for WebView compatibility on web platform
418434
const handleAuthMessage = useCallback(async (data: AuthMessageData): Promise<void> => {

mobile/src/utils/fitnessTrackers/__tests__/useTrackerAuth.test.tsx

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ import { CONNECTED_TRACKERS_QUERY_KEY } from '../useConnectedTrackers';
1818
// Mocks
1919
// ---------------------------------------------------------------------------
2020

21-
// useAuth mock — default: valid session
21+
// useAuth mock — default: valid session with getAccessToken
2222
const mockSession = { access_token: 'test-jwt-token' };
23+
const mockGetAccessToken = jest.fn<() => Promise<string | undefined>>().mockResolvedValue('fresh-jwt-token');
2324
jest.mock('@/utils/auth/useAuth', () => ({
2425
__esModule: true,
25-
default: () => ({ session: mockSession }),
26+
default: () => ({ session: mockSession, getAccessToken: mockGetAccessToken }),
2627
}));
2728

2829
// Typed references to jest.setup.js mocks
@@ -72,6 +73,7 @@ beforeEach(() => {
7273
queryClient = createTestQueryClient();
7374
process.env.EXPO_PUBLIC_SUPABASE_URL = SUPABASE_URL;
7475
mockSession.access_token = 'test-jwt-token';
76+
mockGetAccessToken.mockResolvedValue('fresh-jwt-token');
7577
mockCreateURL.mockImplementation((path: string) => `healthdecoder://${path}`);
7678
});
7779

@@ -101,7 +103,7 @@ describe('useTrackerAuth', () => {
101103
});
102104

103105
it('clearError resets error after a failed auth attempt', async () => {
104-
mockSession.access_token = '';
106+
mockGetAccessToken.mockResolvedValue(undefined);
105107

106108
const { result } = renderHook(() => useTrackerAuth(), {
107109
wrapper: createWrapper(queryClient),
@@ -126,8 +128,8 @@ describe('useTrackerAuth', () => {
126128
// -------------------------------------------------------------------------
127129

128130
describe('guard clauses', () => {
129-
it('returns error when session has no access_token', async () => {
130-
mockSession.access_token = '';
131+
it('returns error when getAccessToken returns undefined (no session)', async () => {
132+
mockGetAccessToken.mockResolvedValue(undefined);
131133

132134
const { result } = renderHook(() => useTrackerAuth(), {
133135
wrapper: createWrapper(queryClient),
@@ -232,7 +234,7 @@ describe('useTrackerAuth', () => {
232234
});
233235

234236
it('constructs the correct auth URL with encoded token and redirect_uri', async () => {
235-
mockSession.access_token = 'token/with special&chars=yes';
237+
mockGetAccessToken.mockResolvedValue('token/with special&chars=yes');
236238
mockOpenAuthSession.mockResolvedValue(browserSuccess({ tracker_id: 'trk-1' }));
237239

238240
const { result } = renderHook(() => useTrackerAuth(), {
@@ -248,7 +250,7 @@ describe('useTrackerAuth', () => {
248250

249251
// Auth URL starts with the correct base
250252
expect(authUrl).toContain(`${SUPABASE_URL}/functions/v1/fitbit-auth-start`);
251-
// Token is URI-encoded
253+
// Token from getAccessToken() is URI-encoded
252254
expect(authUrl).toContain(`token=${encodeURIComponent('token/with special&chars=yes')}`);
253255
// redirect_uri is appended and encoded
254256
expect(authUrl).toContain(`redirect_uri=${encodeURIComponent('healthdecoder://auth/fitness-tracker')}`);
@@ -377,4 +379,78 @@ describe('useTrackerAuth', () => {
377379
expect(result.current.error).toBeNull();
378380
});
379381
});
382+
383+
// -------------------------------------------------------------------------
384+
// Group 6 — Fresh token retrieval (Fix: stale JWT bug)
385+
// -------------------------------------------------------------------------
386+
387+
describe('fresh token retrieval', () => {
388+
it('calls getAccessToken() to get a fresh token instead of using session.access_token', async () => {
389+
mockOpenAuthSession.mockResolvedValue(browserSuccess({ tracker_id: 'trk-1' }));
390+
391+
const { result } = renderHook(() => useTrackerAuth(), {
392+
wrapper: createWrapper(queryClient),
393+
});
394+
395+
await act(async () => {
396+
await result.current.authenticate('fitbit');
397+
});
398+
399+
// Must call getAccessToken to get a fresh (possibly refreshed) token
400+
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
401+
});
402+
403+
it('uses the fresh token from getAccessToken() in the auth URL, not the stale session token', async () => {
404+
// Simulate stale session: session.access_token is old, getAccessToken returns fresh
405+
mockSession.access_token = 'stale-expired-token';
406+
mockGetAccessToken.mockResolvedValue('fresh-refreshed-token');
407+
mockOpenAuthSession.mockResolvedValue(browserSuccess({ tracker_id: 'trk-1' }));
408+
409+
const { result } = renderHook(() => useTrackerAuth(), {
410+
wrapper: createWrapper(queryClient),
411+
});
412+
413+
await act(async () => {
414+
await result.current.authenticate('fitbit');
415+
});
416+
417+
const [authUrl] = mockOpenAuthSession.mock.calls[0];
418+
// URL must contain the FRESH token, not the stale one
419+
expect(authUrl).toContain(`token=${encodeURIComponent('fresh-refreshed-token')}`);
420+
expect(authUrl).not.toContain('stale-expired-token');
421+
});
422+
423+
it('returns not-authenticated error when getAccessToken() returns undefined', async () => {
424+
mockGetAccessToken.mockResolvedValue(undefined);
425+
426+
const { result } = renderHook(() => useTrackerAuth(), {
427+
wrapper: createWrapper(queryClient),
428+
});
429+
430+
let authResult: any;
431+
await act(async () => {
432+
authResult = await result.current.authenticate('fitbit');
433+
});
434+
435+
expect(authResult).toEqual({ success: false, error: 'Not authenticated' });
436+
expect(mockOpenAuthSession).not.toHaveBeenCalled();
437+
});
438+
439+
it('handles getAccessToken() throwing an error', async () => {
440+
mockGetAccessToken.mockRejectedValue(new Error('SecureStore read failed'));
441+
442+
const { result } = renderHook(() => useTrackerAuth(), {
443+
wrapper: createWrapper(queryClient),
444+
});
445+
446+
let authResult: any;
447+
await act(async () => {
448+
authResult = await result.current.authenticate('fitbit');
449+
});
450+
451+
expect(authResult.success).toBe(false);
452+
expect(authResult.error).toBe('SecureStore read failed');
453+
expect(mockOpenAuthSession).not.toHaveBeenCalled();
454+
});
455+
});
380456
});

mobile/src/utils/fitnessTrackers/useTrackerAuth.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,34 @@ export function useTrackerAuth(): UseTrackerAuthReturn {
3939
const [isAuthenticating, setIsAuthenticating] = useState(false);
4040
const [error, setError] = useState<string | null>(null);
4141
const queryClient = useQueryClient();
42-
const { session } = useAuth();
42+
const { getAccessToken } = useAuth();
4343

4444
/**
4545
* Start the OAuth flow for a fitness tracker provider
4646
* @param provider - 'fitbit', 'whoop', or 'oura'
4747
* @returns Promise with authentication result
4848
*/
4949
const authenticate = useCallback(async (provider: ProviderId): Promise<AuthenticateResult> => {
50-
if (!session?.access_token) {
51-
setError('Not authenticated');
52-
return { success: false, error: 'Not authenticated' };
53-
}
54-
5550
setIsAuthenticating(true);
5651
setError(null);
5752

5853
try {
54+
// Get a fresh token — session.access_token may be stale if the Supabase
55+
// client auto-refreshed since the hook last rendered. getAccessToken()
56+
// reads from SecureStore and refreshes via GoAuth if expired.
57+
const accessToken = await getAccessToken();
58+
if (!accessToken) {
59+
setError('Not authenticated');
60+
return { success: false, error: 'Not authenticated' };
61+
}
62+
5963
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
6064
if (!supabaseUrl) {
6165
throw new Error('EXPO_PUBLIC_SUPABASE_URL is not configured');
6266
}
6367

64-
// Build the auth start URL with the token
65-
const authUrl = `${supabaseUrl}/functions/v1/${provider}-auth-start?token=${encodeURIComponent(session.access_token)}`;
68+
// Build the auth start URL with the fresh token
69+
const authUrl = `${supabaseUrl}/functions/v1/${provider}-auth-start?token=${encodeURIComponent(accessToken)}`;
6670

6771
// Create the redirect URL that the callback will redirect to
6872
const redirectUrl = Linking.createURL('auth/fitness-tracker');
@@ -138,7 +142,7 @@ export function useTrackerAuth(): UseTrackerAuthReturn {
138142
} finally {
139143
setIsAuthenticating(false);
140144
}
141-
}, [session?.access_token, queryClient]);
145+
}, [getAccessToken, queryClient]);
142146

143147
/**
144148
* Clear any error state

0 commit comments

Comments
 (0)