Skip to content

Commit cbae38c

Browse files
committed
Add token refresh & integrate w/ UseFetch
Add token refresh logic to AuthContext, and integrate valid access token handling with useFetch. This change allows us to stop passing access tokens throughout the app Key changes: - `AuthContext` now exposes `getValidAccessToken()` which auto-refreshes expired tokens, updates context state, and prevents concurrent refreshes via a ref. - Added `src/utils/authToken.ts` with `isValidToken`, `getAccessTokenExpirationDate`, and `getUpdatedAuthFromRefreshResponse` helpers. - Added `getRefreshToken()` in `src/services/authService.ts` to call Spotify's token endpoint and added `SpotifyRefreshResponse` type (`src/types/SpotifyAuth.ts`). - `useFetch` now retrieves a valid access token via `useAuth` and passes it to fetch functions; `fetchFn` signature changed to `(accessToken, signal)`. - Updated `useFetchSpotify` hooks (`useFetchPlaylists`, `useFetchTracks`) and components (Playlists, PlaylistTracks, CompareTracks) to correspond with these changes This change centralizes token handling, prevents duplicate refresh requests, and simplifies component usage.
1 parent bcdb6ac commit cbae38c

9 files changed

Lines changed: 127 additions & 46 deletions

File tree

src/components/CompareTracks.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {useMemo} from 'react';
22
import {SpotifyPlaylist, SpotifyTrack} from '../types/SpotifyPlaylist';
33
import Tracks from './Tracks';
4-
import {useAuth} from '../contexts/AuthContext';
54
import {View, StyleSheet} from 'react-native';
65
import {getSharedTracks} from '../utils/trackUtils';
76
import PlaylistHeader from './headers/PlaylistHeader';
@@ -18,18 +17,17 @@ export default function TracksComparison({
1817
selectedPlaylists,
1918
}: SpotifyTracksComparisonProps) {
2019
const [leftPlaylist, rightPlaylist] = selectedPlaylists;
21-
const {accessToken} = useAuth();
2220

2321
const {
2422
data: leftTracks,
2523
error: leftError,
2624
isLoading: leftIsLoading,
27-
} = useFetchTracks(leftPlaylist.id, accessToken);
25+
} = useFetchTracks(leftPlaylist.id);
2826
const {
2927
data: rightTracks,
3028
error: rightError,
3129
isLoading: rightIsLoading,
32-
} = useFetchTracks(rightPlaylist.id, accessToken);
30+
} = useFetchTracks(rightPlaylist.id);
3331

3432
const sharedTracks = useMemo(() => {
3533
if (!leftTracks || !rightTracks) return null;

src/components/PlaylistTracks.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {SpotifyPlaylist} from '../types/SpotifyPlaylist';
2-
import {useAuth} from '../contexts/AuthContext';
32
import Tracks from './Tracks';
43
import {View, StyleSheet} from 'react-native';
54
import PlaylistHeader from './headers/PlaylistHeader';
@@ -12,12 +11,7 @@ type PlaylistTracksProps = {
1211
};
1312

1413
export default function PlaylistTracks({spotifyPlaylist}: PlaylistTracksProps) {
15-
const {accessToken} = useAuth();
16-
const {
17-
data: tracks,
18-
error,
19-
isLoading,
20-
} = useFetchTracks(spotifyPlaylist.id, accessToken);
14+
const {data: tracks, error, isLoading} = useFetchTracks(spotifyPlaylist.id);
2115

2216
return (
2317
<View style={styles.column}>

src/components/Playlists.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {SpotifyPlaylist} from '../types/SpotifyPlaylist';
22
import {SpotifyPlaylistActions} from '../types/SpotifyPlaylistProps';
33
import Playlist from './Playlist';
44
import Header from './headers/Header';
5-
import {useAuth} from '../contexts/AuthContext';
65
import {FlatList} from 'react-native';
76
import ErrorMessage from './ErrorMessage';
87
import {useFetchPlaylists} from '../hooks/useFetchSpotify';
@@ -17,8 +16,7 @@ export default function Playlists({
1716
updateSelectedPlaylists,
1817
focusPlaylist,
1918
}: SpotifyPlaylistsProps) {
20-
const {accessToken} = useAuth();
21-
const {data: playlists, error, isLoading} = useFetchPlaylists(accessToken);
19+
const {data: playlists, error, isLoading} = useFetchPlaylists();
2220

2321
return (
2422
<>

src/contexts/AuthContext.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
1-
import {createContext, useContext, useState, ReactNode} from 'react';
2-
import {SpotifyAuth} from '../types/SpotifyAuth';
3-
import {handleSpotifyAuth} from '../services/authService';
4-
5-
// For addressing edge cases
6-
// - (i.e. when a request fires right at token expiry time)
7-
const ACCESS_TOKEN_EXPIRY_BUFFER = 15_000; // 15-sec
1+
import {createContext, useContext, useState, useRef, ReactNode} from 'react';
2+
import {SpotifyAuth, SpotifyRefreshResponse} from '../types/SpotifyAuth';
3+
import {handleSpotifyAuth, getRefreshToken} from '../services/authService';
4+
import {
5+
isValidToken,
6+
getUpdatedAuthFromRefreshResponse,
7+
} from '../utils/authToken';
88

99
interface AuthContextType {
10-
accessToken: string | null;
1110
isAuthenticated: boolean;
11+
getValidAccessToken: () => Promise<string | null>;
1212
login: () => Promise<void>;
1313
logout: () => void;
1414
}
@@ -17,15 +17,39 @@ interface AuthProviderProps {
1717
children: ReactNode;
1818
}
1919

20-
const isExpiredToken = (auth: SpotifyAuth): boolean => {
21-
const expiry = new Date(auth.accessTokenExpirationDate).getTime();
22-
return !expiry || expiry < Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER;
23-
};
24-
2520
const AuthContext = createContext<AuthContextType | undefined>(undefined);
2621

2722
export const AuthProvider = ({children}: AuthProviderProps) => {
2823
const [auth, setAuth] = useState<SpotifyAuth | null>(null);
24+
const refreshTokenPromiseRef = useRef<Promise<string | null> | null>(null);
25+
26+
const handleTokenRefresh = async (authSnapshot: SpotifyAuth) => {
27+
try {
28+
const response: SpotifyRefreshResponse = await getRefreshToken(
29+
authSnapshot,
30+
);
31+
const updatedAuth = getUpdatedAuthFromRefreshResponse(
32+
authSnapshot,
33+
response,
34+
);
35+
setAuth(updatedAuth);
36+
return updatedAuth.accessToken;
37+
} finally {
38+
refreshTokenPromiseRef.current = null;
39+
}
40+
};
41+
42+
const getValidAccessToken = async () => {
43+
if (!auth) return null;
44+
if (isValidToken(auth)) return auth.accessToken;
45+
// If a refresh is already in progress, return the same promise
46+
// (Prevents concurrent requests from triggering multiple getRefreshToken() calls)
47+
if (refreshTokenPromiseRef.current) return refreshTokenPromiseRef.current;
48+
// Initiate a refresh and store the promise for other callers to await
49+
// (After it's completion, handleTokenRefresh clears the ref)
50+
refreshTokenPromiseRef.current = handleTokenRefresh(auth);
51+
return refreshTokenPromiseRef.current;
52+
};
2953

3054
const login = async () => {
3155
try {
@@ -39,11 +63,12 @@ export const AuthProvider = ({children}: AuthProviderProps) => {
3963

4064
const logout = () => setAuth(null);
4165

42-
const accessToken = !auth || isExpiredToken(auth) ? null : auth.accessToken;
43-
const isAuthenticated = !!accessToken;
66+
// "authenticated" is having (or being able to obtain) a valid access token
67+
const isAuthenticated = !!auth && (!!auth.refreshToken || isValidToken(auth));
4468

4569
return (
46-
<AuthContext.Provider value={{accessToken, isAuthenticated, login, logout}}>
70+
<AuthContext.Provider
71+
value={{isAuthenticated, getValidAccessToken, login, logout}}>
4772
{children}
4873
</AuthContext.Provider>
4974
);

src/hooks/useFetch.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {useState, useEffect} from 'react';
2+
import {useAuth} from '../contexts/AuthContext';
23

34
type FetchState<T> = {
45
data: T | null;
@@ -7,14 +8,15 @@ type FetchState<T> = {
78
};
89

910
export function useFetch<T>(
10-
fetchFn: (signal: AbortSignal) => Promise<T>,
11+
fetchFn: (accessToken: string, signal: AbortSignal) => Promise<T>,
1112
deps: any[] = [],
1213
) {
1314
const [fetchState, setFetchState] = useState<FetchState<T>>({
1415
data: null,
1516
error: null,
1617
isLoading: false,
1718
});
19+
const {isAuthenticated, getValidAccessToken} = useAuth();
1820

1921
useEffect(() => {
2022
const controller = new AbortController();
@@ -23,20 +25,30 @@ export function useFetch<T>(
2325
const handleFetch = async () => {
2426
setFetchState({data: null, error: null, isLoading: true});
2527
try {
26-
const data = await fetchFn(signal);
28+
const accessToken = await getValidAccessToken();
29+
if (!accessToken) {
30+
setFetchState({data: null, error: null, isLoading: false});
31+
return;
32+
}
33+
const data = await fetchFn(accessToken, signal);
2734
setFetchState({data, error: null, isLoading: false});
2835
} catch (error: any) {
2936
if (signal.aborted) return;
3037
setFetchState({data: null, error, isLoading: false});
3138
}
3239
};
3340

41+
if (!isAuthenticated) {
42+
setFetchState({data: null, error: null, isLoading: false});
43+
return () => controller.abort();
44+
}
45+
3446
handleFetch();
3547

3648
return () => {
3749
controller.abort();
3850
};
39-
}, deps);
51+
}, [isAuthenticated, ...deps]);
4052

4153
return fetchState;
4254
}

src/hooks/useFetchSpotify.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,16 @@ import {getPlaylistTracks} from '../services/trackService';
33
import {SpotifyPlaylist, SpotifyTrack} from '../types/SpotifyPlaylist';
44
import {useFetch} from './useFetch';
55

6-
export const useFetchPlaylists = (accessToken: string | null) => {
6+
export const useFetchPlaylists = () => {
77
return useFetch<SpotifyPlaylist[] | null>(
8-
signal =>
9-
accessToken ? getPlaylists(accessToken, signal) : Promise.resolve(null),
10-
[accessToken],
8+
(accessToken, signal) => getPlaylists(accessToken, signal),
9+
[],
1110
);
1211
};
1312

14-
export const useFetchTracks = (
15-
playlistId: string,
16-
accessToken: string | null,
17-
) => {
13+
export const useFetchTracks = (playlistId: string) => {
1814
return useFetch<SpotifyTrack[] | null>(
19-
signal =>
20-
accessToken
21-
? getPlaylistTracks(playlistId, accessToken, signal)
22-
: Promise.resolve(null),
23-
[accessToken, playlistId],
15+
(accessToken, signal) => getPlaylistTracks(playlistId, accessToken, signal),
16+
[playlistId],
2417
);
2518
};

src/services/authService.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,29 @@ import {authorize} from 'react-native-app-auth';
22
import spotifyAuthConfig from '../config/spotifyAuthConfig';
33
import {SpotifyAuth} from '../types/SpotifyAuth';
44

5+
const REFRESH_TOKEN_URL = 'https://accounts.spotify.com/api/token';
6+
7+
// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens
8+
export const getRefreshToken = async (auth: SpotifyAuth) => {
9+
const response = await fetch(REFRESH_TOKEN_URL, {
10+
method: 'POST',
11+
headers: {
12+
'Content-Type': 'application/x-www-form-urlencoded',
13+
},
14+
body: new URLSearchParams({
15+
grant_type: 'refresh_token',
16+
refresh_token: auth.refreshToken,
17+
client_id: spotifyAuthConfig.clientId,
18+
}).toString(),
19+
});
20+
21+
if (!response.ok) {
22+
throw new Error(`Spotify token refresh failed: ${response.status}`);
23+
}
24+
25+
return response.json();
26+
};
27+
528
export const handleSpotifyAuth = async (): Promise<SpotifyAuth> => {
629
try {
730
const spotifyAuthData = await authorize(spotifyAuthConfig);

src/types/SpotifyAuth.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,12 @@ export type SpotifyAuth = {
88
tokenAdditionalParameters?: Record<string, any>;
99
tokenType: string;
1010
};
11+
12+
// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens
13+
export type SpotifyRefreshResponse = {
14+
access_token: string;
15+
token_type: string;
16+
expires_in: number;
17+
refresh_token?: string;
18+
scope: string;
19+
};

src/utils/authToken.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {SpotifyAuth, SpotifyRefreshResponse} from '../types/SpotifyAuth';
2+
3+
// For addressing edge cases
4+
// - (i.e. when a request fires right at token expiry time)
5+
const ACCESS_TOKEN_EXPIRY_BUFFER = 15_000; // 15-sec
6+
7+
export const isValidToken = (auth: SpotifyAuth): boolean => {
8+
const expiry = new Date(auth.accessTokenExpirationDate).getTime();
9+
return !!expiry && expiry > Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER;
10+
};
11+
12+
export const getAccessTokenExpirationDate = (expires_in: number) => {
13+
return new Date(Date.now() + expires_in * 1000).toISOString();
14+
};
15+
16+
// https://developer.spotify.com/documentation/web-api/tutorials/refreshing-tokens
17+
export const getUpdatedAuthFromRefreshResponse = (
18+
auth: SpotifyAuth,
19+
response: SpotifyRefreshResponse,
20+
) => {
21+
return {
22+
...auth,
23+
accessToken: response.access_token,
24+
refreshToken: response.refresh_token ?? auth.refreshToken,
25+
accessTokenExpirationDate: getAccessTokenExpirationDate(
26+
response.expires_in,
27+
),
28+
};
29+
};

0 commit comments

Comments
 (0)