Skip to content

Commit 604a5fa

Browse files
watsonboxclaude
andcommitted
Keep Spotify sessions alive with the refresh token
Login now keeps Spotify's refresh token and when the access token expires. Each request refreshes the token in the minute before it expires, and a 401 gets one retry with a renewed token, reusing one another request or tab already fetched. Overlapping refreshes share a single request, use the client ID that logged in, and store a rotated refresh token. Only when Spotify refuses the refresh, or no refresh token was stored, does the user go back to login; the refusal is reported without the token. A long Export All now outlives the one-hour token (#225). apiCall reads the stored token for each request rather than having it threaded through every component and data class, so a refreshed token reaches exports already in progress. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9e16d71 commit 604a5fa

23 files changed

Lines changed: 596 additions & 101 deletions

‎src/App.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,13 @@ describe("logging in", () => {
151151
expect(window.location.search).toBe("")
152152
})
153153

154-
test("renders the playlist table when an access token is already stored", () => {
154+
test("renders the playlist table when an access token is already stored", async () => {
155155
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
156156

157157
render(<App />)
158158

159159
expect(screen.getByTestId("playlistTableSpinner")).toBeInTheDocument()
160+
expect(await screen.findByText(/Export All/)).toBeInTheDocument()
160161
})
161162
})
162163

‎src/App.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ function App() {
9393
}, [])
9494

9595
if (accessToken) {
96-
view = <PlaylistTable accessToken={accessToken!} onSetSubtitle={onSetSubtitle} />
96+
view = <PlaylistTable onSetSubtitle={onSetSubtitle} />
9797
} else {
9898
view = <>
9999
{loginError && (

‎src/auth.test.ts‎

Lines changed: 153 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,54 @@
11
import { http, HttpResponse } from "msw"
22
import { server } from "./mocks/server"
33
import { redirectTo } from "./navigation"
4-
import { exchangeCodeForToken, logout } from "./auth"
4+
import { captureBugsnagReports } from "./test/bugsnag"
5+
import { currentAccessToken, exchangeCodeForToken, logout, refreshAccessToken, SessionExpiredError } from "./auth"
6+
import grantedToken from "./test/fixtures/spotify/documented/token.json"
7+
import refreshedToken from "./test/fixtures/spotify/documented/token-refresh.json"
8+
import revokedRefreshToken from "./test/fixtures/spotify/documented/token-refresh-revoked.json"
9+
10+
const tokenUrl = "https://accounts.spotify.com/api/token"
11+
const HOUR = 60 * 60 * 1000
12+
13+
// Records each token request body and answers with respond
14+
function mockTokenEndpoint(respond: () => Response | Promise<Response>) {
15+
const requests: URLSearchParams[] = []
16+
server.use(http.post(tokenUrl, async ({ request }) => {
17+
requests.push(new URLSearchParams(await request.text()))
18+
return respond()
19+
}))
20+
return requests
21+
}
22+
23+
const refresh = (body: object = refreshedToken.body) => () => HttpResponse.json(body)
24+
const revoke = () => HttpResponse.json(revokedRefreshToken.body, { status: revokedRefreshToken.status })
25+
26+
function logIn({ expiresAt = Date.now() + HOUR, clientId }: { expiresAt?: number, clientId?: string } = {}) {
27+
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
28+
localStorage.setItem("refresh_token", "TEST_REFRESH_TOKEN")
29+
localStorage.setItem("token_expires_at", String(expiresAt))
30+
if (clientId) { localStorage.setItem("client_id", clientId) }
31+
}
532

633
beforeEach(() => {
34+
vi.useFakeTimers({ toFake: ["Date"] })
735
window.history.replaceState(null, "", "/exportify")
836
})
937

38+
afterEach(() => {
39+
vi.useRealTimers()
40+
})
41+
1042
describe("exchangeCodeForToken", () => {
11-
test("stores the token and discards the single-use code verifier", async () => {
12-
server.use(http.post("https://accounts.spotify.com/api/token", () =>
13-
HttpResponse.json({ access_token: "TEST_ACCESS_TOKEN", token_type: "Bearer", expires_in: 3600 })))
43+
test("stores the tokens and their expiry, and discards the single-use code verifier", async () => {
44+
server.use(http.post(tokenUrl, () => HttpResponse.json(grantedToken.body)))
1445
localStorage.setItem("code_verifier", "TEST_CODE_VERIFIER")
46+
const now = Date.now()
1547

1648
await expect(exchangeCodeForToken("TEST_AUTH_CODE")).resolves.toBe("TEST_ACCESS_TOKEN")
1749
expect(localStorage.getItem("access_token")).toBe("TEST_ACCESS_TOKEN")
50+
expect(localStorage.getItem("refresh_token")).toBe("TEST_REFRESH_TOKEN")
51+
expect(localStorage.getItem("token_expires_at")).toBe(String(now + HOUR))
1852
expect(localStorage.getItem("code_verifier")).toBeNull()
1953
})
2054

@@ -33,11 +67,124 @@ describe("exchangeCodeForToken", () => {
3367
})
3468
})
3569

70+
describe("refreshAccessToken", () => {
71+
test("exchanges the refresh token using the client ID that logged in", async () => {
72+
const requests = mockTokenEndpoint(refresh())
73+
logIn({ clientId: "CUSTOM_CLIENT_ID" })
74+
75+
await expect(refreshAccessToken()).resolves.toBe("REFRESHED_ACCESS_TOKEN")
76+
expect(Object.fromEntries(requests[0])).toEqual({
77+
grant_type: "refresh_token",
78+
refresh_token: "TEST_REFRESH_TOKEN",
79+
client_id: "CUSTOM_CLIENT_ID",
80+
})
81+
})
82+
83+
test("stores the new access token, its expiry and the rotated refresh token", async () => {
84+
mockTokenEndpoint(refresh())
85+
logIn({ expiresAt: Date.now() })
86+
const now = Date.now()
87+
88+
await refreshAccessToken()
89+
90+
expect(localStorage.getItem("access_token")).toBe("REFRESHED_ACCESS_TOKEN")
91+
expect(localStorage.getItem("token_expires_at")).toBe(String(now + HOUR))
92+
expect(localStorage.getItem("refresh_token")).toBe("ROTATED_REFRESH_TOKEN")
93+
})
94+
95+
test("keeps the refresh token when Spotify doesn't rotate it", async () => {
96+
const { refresh_token: _, ...withoutRefreshToken } = refreshedToken.body
97+
mockTokenEndpoint(refresh(withoutRefreshToken))
98+
logIn()
99+
100+
await refreshAccessToken()
101+
102+
expect(localStorage.getItem("refresh_token")).toBe("TEST_REFRESH_TOKEN")
103+
})
104+
105+
test("shares one request between overlapping refreshes", async () => {
106+
const requests = mockTokenEndpoint(refresh())
107+
logIn()
108+
109+
const tokens = await Promise.all([refreshAccessToken(), refreshAccessToken(), refreshAccessToken()])
110+
111+
expect(tokens).toEqual(["REFRESHED_ACCESS_TOKEN", "REFRESHED_ACCESS_TOKEN", "REFRESHED_ACCESS_TOKEN"])
112+
expect(requests).toHaveLength(1)
113+
})
114+
115+
test("refreshes again once the previous refresh has finished", async () => {
116+
const requests = mockTokenEndpoint(refresh())
117+
logIn()
118+
119+
await refreshAccessToken()
120+
await refreshAccessToken()
121+
122+
expect(requests).toHaveLength(2)
123+
expect(requests[1].get("refresh_token")).toBe("ROTATED_REFRESH_TOKEN")
124+
})
125+
126+
test("ends the session when Spotify refuses the refresh token, reporting why but not the token", async () => {
127+
const reports = captureBugsnagReports()
128+
mockTokenEndpoint(revoke)
129+
logIn()
130+
131+
await expect(refreshAccessToken()).rejects.toBeInstanceOf(SessionExpiredError)
132+
expect(reports).toHaveLength(1)
133+
expect(reports[0].groupingHash).toBe("Token refresh: Spotify: invalid_grant (Refresh token revoked)")
134+
expect(reports[0].metadata).toEqual({ oauth: { status: 400, error: "invalid_grant", error_description: "Refresh token revoked" } })
135+
expect(JSON.stringify(reports)).not.toContain("TEST_REFRESH_TOKEN")
136+
})
137+
138+
test("ends the session without calling Spotify when no refresh token was stored", async () => {
139+
const reports = captureBugsnagReports()
140+
const requests = mockTokenEndpoint(refresh())
141+
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
142+
143+
await expect(refreshAccessToken()).rejects.toBeInstanceOf(SessionExpiredError)
144+
expect(requests).toHaveLength(0)
145+
expect(reports).toEqual([])
146+
})
147+
148+
test("passes on a network failure so the request can be retried", async () => {
149+
mockTokenEndpoint(() => HttpResponse.error())
150+
logIn()
151+
152+
await expect(refreshAccessToken()).rejects.toMatchObject({ code: "ERR_NETWORK" })
153+
expect(localStorage.getItem("refresh_token")).toBe("TEST_REFRESH_TOKEN")
154+
})
155+
})
156+
157+
describe("currentAccessToken", () => {
158+
test("is the stored token while it has time left", async () => {
159+
const requests = mockTokenEndpoint(refresh())
160+
logIn()
161+
vi.setSystemTime(Date.now() + HOUR - 2 * 60 * 1000)
162+
163+
await expect(currentAccessToken()).resolves.toBe("TEST_ACCESS_TOKEN")
164+
expect(requests).toHaveLength(0)
165+
})
166+
167+
test("is refreshed in the last minute before expiry", async () => {
168+
mockTokenEndpoint(refresh())
169+
logIn()
170+
vi.setSystemTime(Date.now() + HOUR - 30 * 1000)
171+
172+
await expect(currentAccessToken()).resolves.toBe("REFRESHED_ACCESS_TOKEN")
173+
})
174+
175+
test("is the stored token for a login from before refresh tokens were kept", async () => {
176+
const requests = mockTokenEndpoint(refresh())
177+
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
178+
179+
await expect(currentAccessToken()).resolves.toBe("TEST_ACCESS_TOKEN")
180+
expect(requests).toHaveLength(0)
181+
})
182+
})
183+
36184
describe("logout", () => {
37185
test("clears stored credentials and returns to the current page", () => {
38-
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
186+
logIn({ clientId: "CUSTOM_CLIENT_ID" })
39187
localStorage.setItem("code_verifier", "TEST_CODE_VERIFIER")
40-
localStorage.setItem("client_id", "CUSTOM_CLIENT_ID")
41188

42189
logout()
43190

‎src/auth.ts‎

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,98 @@
22
// - https://developer.spotify.com/documentation/web-api/tutorials/migration-implicit-auth-code
33
// - https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow
44

5+
import Bugsnag from "@bugsnag/js"
56
import axios from "axios"
67
import { redirectTo } from "./navigation"
78

89
const SPOTIFY_CLIENT_ID = "9950ac751e34487dbbe027c4fd7f8e99"
910
const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize"
1011
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
1112
const SPOTIFY_SCOPES = "playlist-read-private playlist-read-collaborative user-library-read"
13+
const EXPIRY_MARGIN = 60 * 1000
14+
15+
type TokenResponse = { access_token: string, expires_in: number, refresh_token?: string }
1216

1317
// Access token management
1418
export function loadAccessToken(): string | null {
1519
return localStorage.getItem('access_token')
1620
}
1721

18-
export function saveAccessToken(token: string): void {
19-
localStorage.setItem('access_token', token)
22+
// Spotify may rotate the refresh token, so a response without one keeps the old
23+
function saveTokens({ access_token, expires_in, refresh_token }: TokenResponse): void {
24+
localStorage.setItem('access_token', access_token)
25+
localStorage.setItem('token_expires_at', String(Date.now() + expires_in * 1000))
26+
if (refresh_token) { localStorage.setItem('refresh_token', refresh_token) }
2027
}
2128

2229
export function clearAccessToken(): void {
2330
localStorage.removeItem('access_token')
31+
localStorage.removeItem('refresh_token')
32+
localStorage.removeItem('token_expires_at')
2433
localStorage.removeItem('code_verifier')
2534
localStorage.removeItem('client_id')
2635
}
2736

37+
// Thrown when the session can't be renewed and the user has to log in again
38+
export class SessionExpiredError extends Error {
39+
constructor() {
40+
super('Spotify session expired')
41+
this.name = 'SessionExpiredError'
42+
}
43+
}
44+
45+
let refreshing: Promise<string> | null = null
46+
47+
// Overlapping callers share one refresh, since each would otherwise spend the same single-use refresh token
48+
export function refreshAccessToken(): Promise<string> {
49+
refreshing ??= requestRefresh().finally(() => { refreshing = null })
50+
return refreshing
51+
}
52+
53+
async function requestRefresh(): Promise<string> {
54+
const refreshToken = localStorage.getItem('refresh_token')
55+
if (!refreshToken) { throw new SessionExpiredError() }
56+
57+
try {
58+
const response = await axios.post(
59+
SPOTIFY_TOKEN_URL,
60+
new URLSearchParams({
61+
grant_type: "refresh_token",
62+
refresh_token: refreshToken,
63+
client_id: localStorage.getItem('client_id') || SPOTIFY_CLIENT_ID,
64+
}),
65+
{ headers: { "Content-Type": "application/x-www-form-urlencoded" } }
66+
)
67+
saveTokens(response.data)
68+
return response.data.access_token
69+
} catch (error: any) {
70+
// With no response it's the connection, which the request's own retries can ride out
71+
if (!error.response) { throw error }
72+
73+
reportRefreshFailure(error)
74+
throw new SessionExpiredError()
75+
}
76+
}
77+
78+
// Sends Spotify's reason but never the request, whose body holds the refresh token
79+
function reportRefreshFailure(error: any) {
80+
const { status, data } = error.response
81+
const reason = data?.error_description ? `${data.error} (${data.error_description})` : data?.error ?? status
82+
83+
Bugsnag.notify(new Error(`Token refresh failed: ${reason}`), (event) => {
84+
event.groupingHash = `Token refresh: Spotify: ${reason}`
85+
event.addMetadata("oauth", { status, error: data?.error, error_description: data?.error_description })
86+
})
87+
}
88+
89+
// The stored access token, refreshed first if it's about to expire
90+
export async function currentAccessToken(): Promise<string | null> {
91+
const expiresAt = Number(localStorage.getItem('token_expires_at'))
92+
if (expiresAt && Date.now() >= expiresAt - EXPIRY_MARGIN) { return refreshAccessToken() }
93+
94+
return loadAccessToken()
95+
}
96+
2897
// Generate code verifier for PKCE flow
2998
function generateCodeVerifier(): string {
3099
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
@@ -120,13 +189,11 @@ export async function exchangeCodeForToken(code: string): Promise<string> {
120189
}
121190
)
122191

123-
const accessToken = response.data.access_token
124-
125192
// The verifier is single-use, like the code it was paired with
126-
saveAccessToken(accessToken)
193+
saveTokens(response.data)
127194
localStorage.removeItem('code_verifier')
128195

129-
return accessToken
196+
return response.data.access_token
130197
}
131198

132199
// Logout and optionally redirect to change user

‎src/components/PlaylistExporter.test.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ const playlist = buildPlaylist({ id: 'p1', name: 'Mixtape', tracks: { total: 1 }
1616
const serveTracks = (...tracks: object[]) =>
1717
server.use(pagedGet(`${API}/playlists/p1/tracks`, tracks.map(track => buildPlaylistItem({ track, added_by: buildUser({ id: 'dj' }), added_at: '2024-01-02T03:04:05Z' }))))
1818

19+
beforeEach(() => {
20+
localStorage.setItem("access_token", "TEST_ACCESS_TOKEN")
21+
})
22+
1923
describe("PlaylistExporter", () => {
2024
test("puts base track columns before the item columns", async () => {
2125
serveTracks(buildTrack())
2226

23-
const csv = await new PlaylistExporter('TEST_ACCESS_TOKEN', playlist, {}).csvData()
27+
const csv = await new PlaylistExporter(playlist, {}).csvData()
2428

2529
expect(csvHeaders(csv)).toEqual(baseHeaders)
2630
})
@@ -34,7 +38,7 @@ describe("PlaylistExporter", () => {
3438
)
3539
const config = { includeArtistsData: true, includeAudioFeaturesData: true, includeAlbumData: true }
3640

37-
const csv = await new PlaylistExporter('TEST_ACCESS_TOKEN', playlist, config).csvData()
41+
const csv = await new PlaylistExporter(playlist, config).csvData()
3842

3943
expect(csvHeaders(csv).slice(baseHeaders.length)).toEqual([
4044
"Artist Genres",
@@ -47,7 +51,7 @@ describe("PlaylistExporter", () => {
4751
test("quotes every field so commas, quotes and newlines survive", async () => {
4852
serveTracks(buildTrack({ id: 't1', name: 'Say "Hi",\nthen leave', artists: [buildArtist({ name: 'A, B' })] }))
4953

50-
const csv = await new PlaylistExporter('TEST_ACCESS_TOKEN', playlist, {}).csvData()
54+
const csv = await new PlaylistExporter(playlist, {}).csvData()
5155

5256
expect(csvRecords(csv)).toEqual([expect.objectContaining({
5357
"Track URI": "spotify:track:t1",
@@ -64,7 +68,7 @@ describe("PlaylistExporter", () => {
6468
['tabs\tand "quotes"', 'tabs_and_quotes_'],
6569
['Déjà Vu', 'déjà_vu'],
6670
])("names the file for %j as %j", (name, fileName) => {
67-
const exporter = new PlaylistExporter('TEST_ACCESS_TOKEN', { ...playlist, name }, {})
71+
const exporter = new PlaylistExporter({ ...playlist, name }, {})
6872

6973
expect(exporter.fileName(false)).toBe(fileName)
7074
expect(exporter.fileName()).toBe(`${fileName}.csv`)

0 commit comments

Comments
 (0)