diff --git a/.changeset/oauth-token-expiry-tracking.md b/.changeset/oauth-token-expiry-tracking.md new file mode 100644 index 00000000000..0592589c550 --- /dev/null +++ b/.changeset/oauth-token-expiry-tracking.md @@ -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. diff --git a/package-lock.json b/package-lock.json index bf24eb34331..4bba01efae6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5770,7 +5770,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/packages/sdk/src/sdk/oauth/OAuth.test.ts b/packages/sdk/src/sdk/oauth/OAuth.test.ts index 745d2da5440..c2b8574ef6d 100644 --- a/packages/sdk/src/sdk/oauth/OAuth.test.ts +++ b/packages/sdk/src/sdk/oauth/OAuth.test.ts @@ -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) }) @@ -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) }) @@ -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) diff --git a/packages/sdk/src/sdk/oauth/OAuth.ts b/packages/sdk/src/sdk/oauth/OAuth.ts index fc33da08e9a..23d5e861919 100644 --- a/packages/sdk/src/sdk/oauth/OAuth.ts +++ b/packages/sdk/src/sdk/oauth/OAuth.ts @@ -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 { - 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 } /** @@ -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 { + 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 { const accessToken = await this.config.tokenStore.getAccessToken() const headers: Record = {} 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) } /** @@ -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 } @@ -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 ) } diff --git a/packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts b/packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts index c0237034a26..44779c4a7ef 100644 --- a/packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts +++ b/packages/sdk/src/sdk/oauth/TokenStoreAsyncStorage.ts @@ -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 { @@ -14,17 +16,59 @@ export class TokenStoreAsyncStorage implements OAuthTokenStore { return AsyncStorage.getItem(AS_REFRESH_TOKEN_KEY) } - async setTokens(access: string, refresh: string): Promise { - await Promise.all([ + async getAccessTokenExpiry(): Promise { + 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 { + 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 { + const ops: Array> = [ 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 { 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) ]) } } diff --git a/packages/sdk/src/sdk/oauth/TokenStoreLocalStorage.ts b/packages/sdk/src/sdk/oauth/TokenStoreLocalStorage.ts index 340e949a384..930c3a3cef8 100644 --- a/packages/sdk/src/sdk/oauth/TokenStoreLocalStorage.ts +++ b/packages/sdk/src/sdk/oauth/TokenStoreLocalStorage.ts @@ -2,6 +2,8 @@ import type { OAuthTokenStore } from './tokenStore' const LS_ACCESS_TOKEN_KEY = 'audius_access_token' const LS_REFRESH_TOKEN_KEY = 'audius_refresh_token' +const LS_ACCESS_TOKEN_EXPIRY_KEY = 'audius_access_token_expiry' +const LS_REFRESH_TOKEN_EXPIRY_KEY = 'audius_refresh_token_expiry' /** * Default token store implementation that persists tokens to `localStorage` @@ -24,13 +26,58 @@ export class TokenStoreLocalStorage implements OAuthTokenStore { } } - async setTokens(access: string, refresh: string): Promise { + async getAccessTokenExpiry(): Promise { + try { + const raw = window.localStorage.getItem(LS_ACCESS_TOKEN_EXPIRY_KEY) + if (raw == null) return null + const n = Number(raw) + return Number.isFinite(n) ? n : null + } catch { + return null + } + } + + async getRefreshTokenExpiry(): Promise { + try { + const raw = window.localStorage.getItem(LS_REFRESH_TOKEN_EXPIRY_KEY) + if (raw == null) return null + const n = Number(raw) + return Number.isFinite(n) ? n : null + } catch { + return null + } + } + + async setTokens( + access: string, + refresh: string, + expiresIn?: number, + refreshExpiresIn?: number + ): Promise { window.localStorage.setItem(LS_ACCESS_TOKEN_KEY, access) window.localStorage.setItem(LS_REFRESH_TOKEN_KEY, refresh) + if (expiresIn != null) { + window.localStorage.setItem( + LS_ACCESS_TOKEN_EXPIRY_KEY, + String(Date.now() + expiresIn * 1000) + ) + } else { + window.localStorage.removeItem(LS_ACCESS_TOKEN_EXPIRY_KEY) + } + if (refreshExpiresIn != null) { + window.localStorage.setItem( + LS_REFRESH_TOKEN_EXPIRY_KEY, + String(Date.now() + refreshExpiresIn * 1000) + ) + } else { + window.localStorage.removeItem(LS_REFRESH_TOKEN_EXPIRY_KEY) + } } async clear(): Promise { window.localStorage.removeItem(LS_ACCESS_TOKEN_KEY) window.localStorage.removeItem(LS_REFRESH_TOKEN_KEY) + window.localStorage.removeItem(LS_ACCESS_TOKEN_EXPIRY_KEY) + window.localStorage.removeItem(LS_REFRESH_TOKEN_EXPIRY_KEY) } } diff --git a/packages/sdk/src/sdk/oauth/TokenStoreMemory.ts b/packages/sdk/src/sdk/oauth/TokenStoreMemory.ts index af9076e7344..4b56dbde8ee 100644 --- a/packages/sdk/src/sdk/oauth/TokenStoreMemory.ts +++ b/packages/sdk/src/sdk/oauth/TokenStoreMemory.ts @@ -12,6 +12,8 @@ import type { OAuthTokenStore } from './tokenStore' export class TokenStoreMemory implements OAuthTokenStore { private _accessToken: string | null = null private _refreshToken: string | null = null + private _accessTokenExpiry: number | null = null + private _refreshTokenExpiry: number | null = null async getAccessToken(): Promise { return this._accessToken @@ -21,13 +23,32 @@ export class TokenStoreMemory implements OAuthTokenStore { return this._refreshToken } - async setTokens(access: string, refresh: string): Promise { + async getAccessTokenExpiry(): Promise { + return this._accessTokenExpiry + } + + async getRefreshTokenExpiry(): Promise { + return this._refreshTokenExpiry + } + + async setTokens( + access: string, + refresh: string, + expiresIn?: number, + refreshExpiresIn?: number + ): Promise { this._accessToken = access this._refreshToken = refresh + this._accessTokenExpiry = + expiresIn != null ? Date.now() + expiresIn * 1000 : null + this._refreshTokenExpiry = + refreshExpiresIn != null ? Date.now() + refreshExpiresIn * 1000 : null } async clear(): Promise { this._accessToken = null this._refreshToken = null + this._accessTokenExpiry = null + this._refreshTokenExpiry = null } } diff --git a/packages/sdk/src/sdk/oauth/tokenStore.test.ts b/packages/sdk/src/sdk/oauth/tokenStore.test.ts index 1dc4356ec50..0e5448620e5 100644 --- a/packages/sdk/src/sdk/oauth/tokenStore.test.ts +++ b/packages/sdk/src/sdk/oauth/tokenStore.test.ts @@ -3,10 +3,12 @@ import { describe, it, expect } from 'vitest' import { TokenStoreMemory } from './TokenStoreMemory' describe('TokenStoreMemory', () => { - it('starts with null tokens', async () => { + it('starts with null tokens and expiries', async () => { const store = new TokenStoreMemory() expect(await store.getAccessToken()).toBeNull() expect(await store.getRefreshToken()).toBeNull() + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() }) it('setTokens stores both tokens', async () => { @@ -16,11 +18,38 @@ describe('TokenStoreMemory', () => { expect(await store.getRefreshToken()).toBe('refresh-456') }) - it('clear resets both tokens to null', async () => { + it('setTokens stores expiry when expiresIn is provided', async () => { const store = new TokenStoreMemory() - await store.setTokens('a', 'r') + const before = Date.now() + await store.setTokens('at', 'rt', 3600) + const expiry = await store.getAccessTokenExpiry() + expect(expiry).toBeGreaterThanOrEqual(before + 3600 * 1000) + expect(expiry).toBeLessThanOrEqual(Date.now() + 3600 * 1000) + }) + + it('setTokens stores refresh expiry when refreshExpiresIn is provided', async () => { + const store = new TokenStoreMemory() + const before = Date.now() + await store.setTokens('at', 'rt', 3600, 2592000) + const expiry = await store.getRefreshTokenExpiry() + expect(expiry).toBeGreaterThanOrEqual(before + 2592000 * 1000) + expect(expiry).toBeLessThanOrEqual(Date.now() + 2592000 * 1000) + }) + + it('setTokens leaves expiry null when not provided', async () => { + const store = new TokenStoreMemory() + await store.setTokens('at', 'rt') + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() + }) + + it('clear resets all values to null', async () => { + const store = new TokenStoreMemory() + await store.setTokens('a', 'r', 3600, 2592000) await store.clear() expect(await store.getAccessToken()).toBeNull() expect(await store.getRefreshToken()).toBeNull() + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() }) }) diff --git a/packages/sdk/src/sdk/oauth/tokenStore.ts b/packages/sdk/src/sdk/oauth/tokenStore.ts index 9c63d856700..96d653f8166 100644 --- a/packages/sdk/src/sdk/oauth/tokenStore.ts +++ b/packages/sdk/src/sdk/oauth/tokenStore.ts @@ -6,6 +6,15 @@ export interface OAuthTokenStore { getAccessToken(): Promise getRefreshToken(): Promise - setTokens(access: string, refresh: string): Promise + /** Returns the epoch (ms) at which the access token expires, or null if unknown. */ + getAccessTokenExpiry(): Promise + /** Returns the epoch (ms) at which the refresh token expires, or null if unknown. */ + getRefreshTokenExpiry(): Promise + setTokens( + access: string, + refresh: string, + expiresIn?: number, + refreshExpiresIn?: number + ): Promise clear(): Promise } diff --git a/packages/sdk/src/sdk/oauth/tokenStoreAsyncStorage.test.ts b/packages/sdk/src/sdk/oauth/tokenStoreAsyncStorage.test.ts new file mode 100644 index 00000000000..a33fbc81815 --- /dev/null +++ b/packages/sdk/src/sdk/oauth/tokenStoreAsyncStorage.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockAsyncStorage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +} + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: mockAsyncStorage +})) + +// Import after mock is registered +const { TokenStoreAsyncStorage } = await import('./TokenStoreAsyncStorage') + +describe('TokenStoreAsyncStorage — AsyncStorage persistence', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('reads persisted tokens from AsyncStorage', async () => { + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'audius_access_token') return 'persisted-access' + if (key === 'audius_refresh_token') return 'persisted-refresh' + return null + }) + const store = new TokenStoreAsyncStorage() + expect(await store.getAccessToken()).toBe('persisted-access') + expect(await store.getRefreshToken()).toBe('persisted-refresh') + }) + + it('returns null when AsyncStorage is empty', async () => { + mockAsyncStorage.getItem.mockResolvedValue(null) + const store = new TokenStoreAsyncStorage() + expect(await store.getAccessToken()).toBeNull() + expect(await store.getRefreshToken()).toBeNull() + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() + }) + + it('setTokens writes tokens to AsyncStorage', async () => { + mockAsyncStorage.setItem.mockResolvedValue(undefined) + mockAsyncStorage.removeItem.mockResolvedValue(undefined) + const store = new TokenStoreAsyncStorage() + await store.setTokens('at', 'rt') + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'audius_access_token', + 'at' + ) + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'audius_refresh_token', + 'rt' + ) + }) + + it('setTokens writes expiry keys when provided', async () => { + mockAsyncStorage.setItem.mockResolvedValue(undefined) + mockAsyncStorage.removeItem.mockResolvedValue(undefined) + const store = new TokenStoreAsyncStorage() + await store.setTokens('at', 'rt', 3600, 2592000) + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'audius_access_token_expiry', + expect.any(String) + ) + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry', + expect.any(String) + ) + }) + + it('setTokens removes expiry keys when not provided', async () => { + mockAsyncStorage.setItem.mockResolvedValue(undefined) + mockAsyncStorage.removeItem.mockResolvedValue(undefined) + const store = new TokenStoreAsyncStorage() + await store.setTokens('at', 'rt') + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_access_token_expiry' + ) + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry' + ) + }) + + it('reads persisted expiry from AsyncStorage', async () => { + const futureMs = String(Date.now() + 3600000) + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'audius_access_token_expiry') return futureMs + if (key === 'audius_refresh_token_expiry') return futureMs + return null + }) + const store = new TokenStoreAsyncStorage() + expect(await store.getAccessTokenExpiry()).toBe(Number(futureMs)) + expect(await store.getRefreshTokenExpiry()).toBe(Number(futureMs)) + }) + + it('returns null for non-numeric expiry values (NaN safety)', async () => { + mockAsyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === 'audius_access_token_expiry') return 'corrupted-value' + if (key === 'audius_refresh_token_expiry') return 'not-a-number' + return null + }) + const store = new TokenStoreAsyncStorage() + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() + }) + + it('clear removes all keys from AsyncStorage', async () => { + mockAsyncStorage.setItem.mockResolvedValue(undefined) + mockAsyncStorage.removeItem.mockResolvedValue(undefined) + const store = new TokenStoreAsyncStorage() + await store.setTokens('at', 'rt') + await store.clear() + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_access_token' + ) + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_refresh_token' + ) + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_access_token_expiry' + ) + expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry' + ) + }) +}) diff --git a/packages/sdk/src/sdk/oauth/tokenStoreLocalStorage.test.ts b/packages/sdk/src/sdk/oauth/tokenStoreLocalStorage.test.ts index cff52783d52..f8d288369c4 100644 --- a/packages/sdk/src/sdk/oauth/tokenStoreLocalStorage.test.ts +++ b/packages/sdk/src/sdk/oauth/tokenStoreLocalStorage.test.ts @@ -34,6 +34,8 @@ describe('TokenStoreLocalStorage — localStorage persistence', () => { const store = new TokenStoreLocalStorage() expect(await store.getAccessToken()).toBeNull() expect(await store.getRefreshToken()).toBeNull() + expect(await store.getAccessTokenExpiry()).toBeNull() + expect(await store.getRefreshTokenExpiry()).toBeNull() }) it('setTokens writes to localStorage', async () => { @@ -49,7 +51,43 @@ describe('TokenStoreLocalStorage — localStorage persistence', () => { ) }) - it('clear removes tokens from localStorage', async () => { + it('setTokens writes expiry keys when provided', async () => { + const store = new TokenStoreLocalStorage() + await store.setTokens('at', 'rt', 3600, 2592000) + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + 'audius_access_token_expiry', + expect.any(String) + ) + expect(mockLocalStorage.setItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry', + expect.any(String) + ) + }) + + it('setTokens removes expiry keys when not provided', async () => { + const store = new TokenStoreLocalStorage() + await store.setTokens('at', 'rt') + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'audius_access_token_expiry' + ) + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry' + ) + }) + + it('reads persisted expiry from localStorage', async () => { + const futureMs = String(Date.now() + 3600000) + mockLocalStorage.getItem.mockImplementation((key: string) => { + if (key === 'audius_access_token_expiry') return futureMs + if (key === 'audius_refresh_token_expiry') return futureMs + return null + }) + const store = new TokenStoreLocalStorage() + expect(await store.getAccessTokenExpiry()).toBe(Number(futureMs)) + expect(await store.getRefreshTokenExpiry()).toBe(Number(futureMs)) + }) + + it('clear removes all keys from localStorage', async () => { const store = new TokenStoreLocalStorage() await store.setTokens('at', 'rt') await store.clear() @@ -59,5 +97,11 @@ describe('TokenStoreLocalStorage — localStorage persistence', () => { expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( 'audius_refresh_token' ) + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'audius_access_token_expiry' + ) + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith( + 'audius_refresh_token_expiry' + ) }) })