Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oauth-token-expiry-tracking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@audius/sdk": minor
---

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.
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 110 additions & 4 deletions packages/sdk/src/sdk/oauth/OAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,11 +493,15 @@ describe('OAuth._receiveMessage (popup parent handler)', () => {
// ---------------------------------------------------------------------------

describe('OAuth.isAuthenticated / hasRefreshToken', () => {
it('isAuthenticated is false when no token store is configured', async () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('isAuthenticated is false when no tokens are stored', async () => {
expect(await makeOAuth().isAuthenticated()).toBe(false)
})

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

it('isAuthenticated is true when access token is missing but refresh token exists', async () => {
const tokenStore = new TokenStoreMemory()
await tokenStore.setTokens('at', 'rt')
// Simulate cleared access token but refresh token remains
;(tokenStore as any)._accessToken = null
expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(true)
})

it('isAuthenticated attempts refresh when access token is expired', async () => {
const tokenStore = new TokenStoreMemory()
// Set tokens with 1-second expiry, then backdate
await tokenStore.setTokens('at', 'rt', 1)
;(tokenStore as any)._accessTokenExpiry = Date.now() - 1000

vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'new-at',
refresh_token: 'new-rt',
expires_in: 3600
}),
{ status: 200 }
)
)
)

const oauth = makeOAuth({ tokenStore })
expect(await oauth.isAuthenticated()).toBe(true)
expect(await tokenStore.getAccessToken()).toBe('new-at')
})

it('isAuthenticated returns false when access token expired and refresh fails', async () => {
const tokenStore = new TokenStoreMemory()
await tokenStore.setTokens('at', 'rt', 1)
;(tokenStore as any)._accessTokenExpiry = Date.now() - 1000

vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 }))
)

expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(false)
})

it('isAuthenticated returns false when refresh token is expired', async () => {
const tokenStore = new TokenStoreMemory()
await tokenStore.setTokens('at', 'rt', 3600, 1)
;(tokenStore as any)._refreshTokenExpiry = Date.now() - 1000

expect(await makeOAuth({ tokenStore }).isAuthenticated()).toBe(false)
})

it('hasRefreshToken is false when no token store is configured', async () => {
expect(await makeOAuth().hasRefreshToken()).toBe(false)
})
Expand Down Expand Up @@ -568,11 +626,59 @@ describe('OAuth.getUser', () => {
)
})

it('throws ResponseError on non-2xx response', async () => {
it('retries after refreshing token on 401', async () => {
const tokenStore = new TokenStoreMemory()
await tokenStore.setTokens('expired-at', 'valid-rt')
const oauth = makeOAuth({ tokenStore })
const userData = { id: '1', handle: 'foo' }
const fetchSpy = vi
.fn()
// First call: 401
.mockResolvedValueOnce(new Response(null, { status: 401 }))
// Refresh call: success
.mockResolvedValueOnce(
new Response(
JSON.stringify({
access_token: 'new-at',
refresh_token: 'new-rt',
expires_in: 3600
}),
{ status: 200 }
)
)
// Retry call: success
.mockResolvedValueOnce(
new Response(JSON.stringify({ data: userData }), { status: 200 })
)
vi.stubGlobal('fetch', fetchSpy)

const result = await oauth.getUser()
expect(result).toEqual(userData)
expect(fetchSpy).toHaveBeenCalledTimes(3)
})

it('throws ResponseError on non-2xx response when refresh also fails', async () => {
const tokenStore = new TokenStoreMemory()
await tokenStore.setTokens('expired-at', 'bad-rt')
const oauth = makeOAuth({ tokenStore })
vi.stubGlobal(
'fetch',
vi
.fn()
// First call: 401
.mockResolvedValueOnce(new Response(null, { status: 401 }))
// Refresh call: fails
.mockResolvedValueOnce(new Response(null, { status: 401 }))
)

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

it('throws ResponseError on non-401 error without retrying', async () => {
const oauth = makeOAuth()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(new Response(null, { status: 401 }))
vi.fn().mockResolvedValueOnce(new Response(null, { status: 500 }))
)

await expect(oauth.getUser()).rejects.toBeInstanceOf(ResponseError)
Expand Down
63 changes: 51 additions & 12 deletions packages/sdk/src/sdk/oauth/OAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,34 @@ export class OAuth {
}

/**
* Returns true if the user is currently authenticated (i.e. an access
* token is present in the token store).
* Returns true if the user has a valid session. Checks the access token
* expiry when available — if the token has expired but a refresh token
* exists, a silent refresh is attempted. Returns false only when no
* usable session remains.
*/
async isAuthenticated(): Promise<boolean> {
return !!(await this.config.tokenStore.getAccessToken())
const store = this.config.tokenStore

// If the refresh token is known-expired, the session is dead
const refreshExpiry = await store.getRefreshTokenExpiry()
if (refreshExpiry != null && Date.now() >= refreshExpiry) {
return false
}

const accessToken = await store.getAccessToken()
if (!accessToken) {
// No access token but we may still have a valid refresh token
return !!(await store.getRefreshToken())
}

const accessExpiry = await store.getAccessTokenExpiry()
if (accessExpiry != null && Date.now() >= accessExpiry) {
// Access token expired — try a silent refresh
const refreshed = await this.refreshAccessToken()
return refreshed != null
}

return true
}

/**
Expand All @@ -315,30 +338,42 @@ export class OAuth {
* current server-side state (useful for detecting revoked sessions or
* refreshing stale profile data on page load).
*
* If the access token has expired, a single token refresh is attempted
* before failing.
*
* Throws `ResponseError` if the server returns a non-2xx response (e.g. 401
* if no token is stored or the token has expired), or `FetchError` if the
* request fails at the network level.
*/
async getUser(): Promise<User> {
let res = await this._fetchMe()
if (res.status === 401) {
const newToken = await this.refreshAccessToken()
if (newToken) {
res = await this._fetchMe()
}
}
if (!res.ok) {
throw new ResponseError(res, 'Failed to fetch user profile.')
}
const json = await res.json()
return UserFromJSON(json.data)
}

private async _fetchMe(): Promise<Response> {
const accessToken = await this.config.tokenStore.getAccessToken()
const headers: Record<string, string> = {}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
let res: Response
try {
res = await fetch(`${this.config.basePath}/me`, { headers })
return await fetch(`${this.config.basePath}/me`, { headers })
} catch (e) {
throw new FetchError(
e instanceof Error ? e : new Error(String(e)),
'Failed to fetch user profile.'
)
}
if (!res.ok) {
throw new ResponseError(res, 'Failed to fetch user profile.')
}
const json = await res.json()
return UserFromJSON(json.data)
}

/**
Expand Down Expand Up @@ -369,7 +404,9 @@ export class OAuth {
if (tokens.access_token && tokens.refresh_token) {
await this.config.tokenStore.setTokens(
tokens.access_token,
tokens.refresh_token
tokens.refresh_token,
tokens.expires_in,
tokens.refresh_expires_in
)
return tokens.access_token
}
Expand Down Expand Up @@ -433,7 +470,9 @@ export class OAuth {
const tokens = await tokenRes.json()
await this.config.tokenStore.setTokens(
tokens.access_token,
tokens.refresh_token
tokens.refresh_token,
tokens.expires_in,
tokens.refresh_expires_in
)
}

Expand Down
52 changes: 48 additions & 4 deletions packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { OAuthTokenStore } from './tokenStore'

const AS_ACCESS_TOKEN_KEY = 'audius_access_token'
const AS_REFRESH_TOKEN_KEY = 'audius_refresh_token'
const AS_ACCESS_TOKEN_EXPIRY_KEY = 'audius_access_token_expiry'
const AS_REFRESH_TOKEN_EXPIRY_KEY = 'audius_refresh_token_expiry'

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

async setTokens(access: string, refresh: string): Promise<void> {
await Promise.all([
async getAccessTokenExpiry(): Promise<number | null> {
const raw = await AsyncStorage.getItem(AS_ACCESS_TOKEN_EXPIRY_KEY)
if (raw == null) return null
const n = Number(raw)
return Number.isFinite(n) ? n : null
}

async getRefreshTokenExpiry(): Promise<number | null> {
const raw = await AsyncStorage.getItem(AS_REFRESH_TOKEN_EXPIRY_KEY)
if (raw == null) return null
const n = Number(raw)
return Number.isFinite(n) ? n : null
}

async setTokens(
access: string,
refresh: string,
expiresIn?: number,
refreshExpiresIn?: number
): Promise<void> {
const ops: Array<Promise<void>> = [
AsyncStorage.setItem(AS_ACCESS_TOKEN_KEY, access),
AsyncStorage.setItem(AS_REFRESH_TOKEN_KEY, refresh)
])
]
if (expiresIn != null) {
ops.push(
AsyncStorage.setItem(
AS_ACCESS_TOKEN_EXPIRY_KEY,
String(Date.now() + expiresIn * 1000)
)
)
} else {
ops.push(AsyncStorage.removeItem(AS_ACCESS_TOKEN_EXPIRY_KEY))
}
if (refreshExpiresIn != null) {
ops.push(
AsyncStorage.setItem(
AS_REFRESH_TOKEN_EXPIRY_KEY,
String(Date.now() + refreshExpiresIn * 1000)
)
)
} else {
ops.push(AsyncStorage.removeItem(AS_REFRESH_TOKEN_EXPIRY_KEY))
}
await Promise.all(ops)
}

async clear(): Promise<void> {
await Promise.all([
AsyncStorage.removeItem(AS_ACCESS_TOKEN_KEY),
AsyncStorage.removeItem(AS_REFRESH_TOKEN_KEY)
AsyncStorage.removeItem(AS_REFRESH_TOKEN_KEY),
AsyncStorage.removeItem(AS_ACCESS_TOKEN_EXPIRY_KEY),
AsyncStorage.removeItem(AS_REFRESH_TOKEN_EXPIRY_KEY)
])
}
}
Loading
Loading