Skip to content

Commit 6c4c717

Browse files
rickyromboclaudeCopilotCopilot
authored
fix(sdk): Add token expiry tracking and retry logic to OAuth (#14078)
## Summary - Adds `getAccessTokenExpiry()` / `getRefreshTokenExpiry()` to `OAuthTokenStore` interface and all three implementations (Memory, LocalStorage, AsyncStorage) - `isAuthenticated()` now checks token expiry — returns false when refresh token is expired, attempts silent refresh when access token is expired - `getUser()` retries once with a refreshed token on 401 instead of immediately throwing - `setTokens()` now accepts optional `expiresIn` / `refreshExpiresIn` (seconds), which are persisted as absolute epoch timestamps **Note:** Companion API change to return `refresh_expires_in` in the token response: AudiusProject/api#756 ## Test plan - [x] All existing OAuth tests pass (74/74) - [ ] `isAuthenticated()` returns false when refresh token is expired - [ ] `isAuthenticated()` silently refreshes when access token is expired but refresh token is valid - [ ] `getUser()` retries and succeeds after token refresh on 401 - [ ] `getUser()` throws when both initial request and refresh fail - [ ] Token expiry survives page reload (localStorage) and app restart (AsyncStorage) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 9071973 commit 6c4c717

File tree

11 files changed

+501
-28
lines changed

11 files changed

+501
-28
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@audius/sdk": minor
3+
---
4+
5+
Add token expiry tracking to OAuth token stores and improve session reliability. `isAuthenticated()` now checks token expiry and attempts silent refresh when needed. `getUser()` retries with a fresh token on 401 instead of immediately throwing.

package-lock.json

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk/src/sdk/oauth/OAuth.test.ts

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -493,11 +493,15 @@ describe('OAuth._receiveMessage (popup parent handler)', () => {
493493
// ---------------------------------------------------------------------------
494494

495495
describe('OAuth.isAuthenticated / hasRefreshToken', () => {
496-
it('isAuthenticated is false when no token store is configured', async () => {
496+
afterEach(() => {
497+
vi.restoreAllMocks()
498+
})
499+
500+
it('isAuthenticated is false when no tokens are stored', async () => {
497501
expect(await makeOAuth().isAuthenticated()).toBe(false)
498502
})
499503

500-
it('isAuthenticated is false when token store has no access token', async () => {
504+
it('isAuthenticated is false when token store has no access token and no refresh token', async () => {
501505
const tokenStore = new TokenStoreMemory()
502506
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(false)
503507
})
@@ -508,6 +512,60 @@ describe('OAuth.isAuthenticated / hasRefreshToken', () => {
508512
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(true)
509513
})
510514

515+
it('isAuthenticated is true when access token is missing but refresh token exists', async () => {
516+
const tokenStore = new TokenStoreMemory()
517+
await tokenStore.setTokens('at', 'rt')
518+
// Simulate cleared access token but refresh token remains
519+
;(tokenStore as any)._accessToken = null
520+
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(true)
521+
})
522+
523+
it('isAuthenticated attempts refresh when access token is expired', async () => {
524+
const tokenStore = new TokenStoreMemory()
525+
// Set tokens with 1-second expiry, then backdate
526+
await tokenStore.setTokens('at', 'rt', 1)
527+
;(tokenStore as any)._accessTokenExpiry = Date.now() - 1000
528+
529+
vi.stubGlobal(
530+
'fetch',
531+
vi.fn().mockResolvedValueOnce(
532+
new Response(
533+
JSON.stringify({
534+
access_token: 'new-at',
535+
refresh_token: 'new-rt',
536+
expires_in: 3600
537+
}),
538+
{ status: 200 }
539+
)
540+
)
541+
)
542+
543+
const oauth = makeOAuth({ tokenStore })
544+
expect(await oauth.isAuthenticated()).toBe(true)
545+
expect(await tokenStore.getAccessToken()).toBe('new-at')
546+
})
547+
548+
it('isAuthenticated returns false when access token expired and refresh fails', async () => {
549+
const tokenStore = new TokenStoreMemory()
550+
await tokenStore.setTokens('at', 'rt', 1)
551+
;(tokenStore as any)._accessTokenExpiry = Date.now() - 1000
552+
553+
vi.stubGlobal(
554+
'fetch',
555+
vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 }))
556+
)
557+
558+
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(false)
559+
})
560+
561+
it('isAuthenticated returns false when refresh token is expired', async () => {
562+
const tokenStore = new TokenStoreMemory()
563+
await tokenStore.setTokens('at', 'rt', 3600, 1)
564+
;(tokenStore as any)._refreshTokenExpiry = Date.now() - 1000
565+
566+
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(false)
567+
})
568+
511569
it('hasRefreshToken is false when no token store is configured', async () => {
512570
expect(await makeOAuth().hasRefreshToken()).toBe(false)
513571
})
@@ -568,11 +626,59 @@ describe('OAuth.getUser', () => {
568626
)
569627
})
570628

571-
it('throws ResponseError on non-2xx response', async () => {
629+
it('retries after refreshing token on 401', async () => {
630+
const tokenStore = new TokenStoreMemory()
631+
await tokenStore.setTokens('expired-at', 'valid-rt')
632+
const oauth = makeOAuth({ tokenStore })
633+
const userData = { id: '1', handle: 'foo' }
634+
const fetchSpy = vi
635+
.fn()
636+
// First call: 401
637+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
638+
// Refresh call: success
639+
.mockResolvedValueOnce(
640+
new Response(
641+
JSON.stringify({
642+
access_token: 'new-at',
643+
refresh_token: 'new-rt',
644+
expires_in: 3600
645+
}),
646+
{ status: 200 }
647+
)
648+
)
649+
// Retry call: success
650+
.mockResolvedValueOnce(
651+
new Response(JSON.stringify({ data: userData }), { status: 200 })
652+
)
653+
vi.stubGlobal('fetch', fetchSpy)
654+
655+
const result = await oauth.getUser()
656+
expect(result).toEqual(userData)
657+
expect(fetchSpy).toHaveBeenCalledTimes(3)
658+
})
659+
660+
it('throws ResponseError on non-2xx response when refresh also fails', async () => {
661+
const tokenStore = new TokenStoreMemory()
662+
await tokenStore.setTokens('expired-at', 'bad-rt')
663+
const oauth = makeOAuth({ tokenStore })
664+
vi.stubGlobal(
665+
'fetch',
666+
vi
667+
.fn()
668+
// First call: 401
669+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
670+
// Refresh call: fails
671+
.mockResolvedValueOnce(new Response(null, { status: 401 }))
672+
)
673+
674+
await expect(oauth.getUser()).rejects.toBeInstanceOf(ResponseError)
675+
})
676+
677+
it('throws ResponseError on non-401 error without retrying', async () => {
572678
const oauth = makeOAuth()
573679
vi.stubGlobal(
574680
'fetch',
575-
vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 }))
681+
vi.fn().mockResolvedValueOnce(new Response(null, { status: 500 }))
576682
)
577683

578684
await expect(oauth.getUser()).rejects.toBeInstanceOf(ResponseError)

packages/sdk/src/sdk/oauth/OAuth.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,34 @@ export class OAuth {
294294
}
295295

296296
/**
297-
* Returns true if the user is currently authenticated (i.e. an access
298-
* token is present in the token store).
297+
* Returns true if the user has a valid session. Checks the access token
298+
* expiry when available — if the token has expired but a refresh token
299+
* exists, a silent refresh is attempted. Returns false only when no
300+
* usable session remains.
299301
*/
300302
async isAuthenticated(): Promise<boolean> {
301-
return !!(await this.config.tokenStore.getAccessToken())
303+
const store = this.config.tokenStore
304+
305+
// If the refresh token is known-expired, the session is dead
306+
const refreshExpiry = await store.getRefreshTokenExpiry()
307+
if (refreshExpiry != null && Date.now() >= refreshExpiry) {
308+
return false
309+
}
310+
311+
const accessToken = await store.getAccessToken()
312+
if (!accessToken) {
313+
// No access token but we may still have a valid refresh token
314+
return !!(await store.getRefreshToken())
315+
}
316+
317+
const accessExpiry = await store.getAccessTokenExpiry()
318+
if (accessExpiry != null && Date.now() >= accessExpiry) {
319+
// Access token expired — try a silent refresh
320+
const refreshed = await this.refreshAccessToken()
321+
return refreshed != null
322+
}
323+
324+
return true
302325
}
303326

304327
/**
@@ -315,30 +338,42 @@ export class OAuth {
315338
* current server-side state (useful for detecting revoked sessions or
316339
* refreshing stale profile data on page load).
317340
*
341+
* If the access token has expired, a single token refresh is attempted
342+
* before failing.
343+
*
318344
* Throws `ResponseError` if the server returns a non-2xx response (e.g. 401
319345
* if no token is stored or the token has expired), or `FetchError` if the
320346
* request fails at the network level.
321347
*/
322348
async getUser(): Promise<User> {
349+
let res = await this._fetchMe()
350+
if (res.status === 401) {
351+
const newToken = await this.refreshAccessToken()
352+
if (newToken) {
353+
res = await this._fetchMe()
354+
}
355+
}
356+
if (!res.ok) {
357+
throw new ResponseError(res, 'Failed to fetch user profile.')
358+
}
359+
const json = await res.json()
360+
return UserFromJSON(json.data)
361+
}
362+
363+
private async _fetchMe(): Promise<Response> {
323364
const accessToken = await this.config.tokenStore.getAccessToken()
324365
const headers: Record<string, string> = {}
325366
if (accessToken) {
326367
headers.Authorization = `Bearer ${accessToken}`
327368
}
328-
let res: Response
329369
try {
330-
res = await fetch(`${this.config.basePath}/me`, { headers })
370+
return await fetch(`${this.config.basePath}/me`, { headers })
331371
} catch (e) {
332372
throw new FetchError(
333373
e instanceof Error ? e : new Error(String(e)),
334374
'Failed to fetch user profile.'
335375
)
336376
}
337-
if (!res.ok) {
338-
throw new ResponseError(res, 'Failed to fetch user profile.')
339-
}
340-
const json = await res.json()
341-
return UserFromJSON(json.data)
342377
}
343378

344379
/**
@@ -369,7 +404,9 @@ export class OAuth {
369404
if (tokens.access_token && tokens.refresh_token) {
370405
await this.config.tokenStore.setTokens(
371406
tokens.access_token,
372-
tokens.refresh_token
407+
tokens.refresh_token,
408+
tokens.expires_in,
409+
tokens.refresh_expires_in
373410
)
374411
return tokens.access_token
375412
}
@@ -433,7 +470,9 @@ export class OAuth {
433470
const tokens = await tokenRes.json()
434471
await this.config.tokenStore.setTokens(
435472
tokens.access_token,
436-
tokens.refresh_token
473+
tokens.refresh_token,
474+
tokens.expires_in,
475+
tokens.refresh_expires_in
437476
)
438477
}
439478

packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { OAuthTokenStore } from './tokenStore'
44

55
const AS_ACCESS_TOKEN_KEY = 'audius_access_token'
66
const AS_REFRESH_TOKEN_KEY = 'audius_refresh_token'
7+
const AS_ACCESS_TOKEN_EXPIRY_KEY = 'audius_access_token_expiry'
8+
const AS_REFRESH_TOKEN_EXPIRY_KEY = 'audius_refresh_token_expiry'
79

810
export class TokenStoreAsyncStorage implements OAuthTokenStore {
911
getAccessToken(): Promise<string | null> {
@@ -14,17 +16,59 @@ export class TokenStoreAsyncStorage implements OAuthTokenStore {
1416
return AsyncStorage.getItem(AS_REFRESH_TOKEN_KEY)
1517
}
1618

17-
async setTokens(access: string, refresh: string): Promise<void> {
18-
await Promise.all([
19+
async getAccessTokenExpiry(): Promise<number | null> {
20+
const raw = await AsyncStorage.getItem(AS_ACCESS_TOKEN_EXPIRY_KEY)
21+
if (raw == null) return null
22+
const n = Number(raw)
23+
return Number.isFinite(n) ? n : null
24+
}
25+
26+
async getRefreshTokenExpiry(): Promise<number | null> {
27+
const raw = await AsyncStorage.getItem(AS_REFRESH_TOKEN_EXPIRY_KEY)
28+
if (raw == null) return null
29+
const n = Number(raw)
30+
return Number.isFinite(n) ? n : null
31+
}
32+
33+
async setTokens(
34+
access: string,
35+
refresh: string,
36+
expiresIn?: number,
37+
refreshExpiresIn?: number
38+
): Promise<void> {
39+
const ops: Array<Promise<void>> = [
1940
AsyncStorage.setItem(AS_ACCESS_TOKEN_KEY, access),
2041
AsyncStorage.setItem(AS_REFRESH_TOKEN_KEY, refresh)
21-
])
42+
]
43+
if (expiresIn != null) {
44+
ops.push(
45+
AsyncStorage.setItem(
46+
AS_ACCESS_TOKEN_EXPIRY_KEY,
47+
String(Date.now() + expiresIn * 1000)
48+
)
49+
)
50+
} else {
51+
ops.push(AsyncStorage.removeItem(AS_ACCESS_TOKEN_EXPIRY_KEY))
52+
}
53+
if (refreshExpiresIn != null) {
54+
ops.push(
55+
AsyncStorage.setItem(
56+
AS_REFRESH_TOKEN_EXPIRY_KEY,
57+
String(Date.now() + refreshExpiresIn * 1000)
58+
)
59+
)
60+
} else {
61+
ops.push(AsyncStorage.removeItem(AS_REFRESH_TOKEN_EXPIRY_KEY))
62+
}
63+
await Promise.all(ops)
2264
}
2365

2466
async clear(): Promise<void> {
2567
await Promise.all([
2668
AsyncStorage.removeItem(AS_ACCESS_TOKEN_KEY),
27-
AsyncStorage.removeItem(AS_REFRESH_TOKEN_KEY)
69+
AsyncStorage.removeItem(AS_REFRESH_TOKEN_KEY),
70+
AsyncStorage.removeItem(AS_ACCESS_TOKEN_EXPIRY_KEY),
71+
AsyncStorage.removeItem(AS_REFRESH_TOKEN_EXPIRY_KEY)
2872
])
2973
}
3074
}

0 commit comments

Comments
 (0)