From e31a332994001174692df488781199dccc8ec196 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 12:13:33 +0800 Subject: [PATCH 01/12] fix(auth): coordinate token refresh across pages --- spx-gui/src/stores/user/signed-in.test.ts | 50 ++++++++++++++++++ spx-gui/src/stores/user/signed-in.ts | 64 ++++++++++++++++------- 2 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 spx-gui/src/stores/user/signed-in.test.ts diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts new file mode 100644 index 000000000..bb5d5e275 --- /dev/null +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { accountOAuthApisForXBuilder } from '@/apis/account/oauth' +import { ensureAccessToken, initUserState } from './signed-in' + +const userStateStorageKey = 'builder-user' + +describe('ensureAccessToken', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses credentials refreshed by another page while waiting for the refresh lock', async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'expired-access-token', + accessTokenExpiresAt: Date.now(), + refreshToken: 'old-refresh-token', + username: 'alice' + }) + ) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: vi.fn(async (_name: string, callback: () => Promise) => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'new-access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'new-refresh-token', + username: 'alice' + }) + ) + await callback() + }) + } + }) + const refreshToken = vi.spyOn(accountOAuthApisForXBuilder, 'refreshToken') + + initUserState('client-id') + + await expect(ensureAccessToken()).resolves.toBe('new-access-token') + expect(refreshToken).not.toHaveBeenCalled() + }) +}) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 78f06cf3c..5d9d15ff6 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,4 +1,4 @@ -import { reactive, watchEffect, computed } from 'vue' +import { reactive, computed } from 'vue' import { composeQuery, useQuery, useQueryCache, useQueryWithCache } from '@/utils/query' import { capture, useAction } from '@/utils/exception' import { OAuthFlow, type OAuthTokenResponse } from '@/utils/oauth' @@ -31,15 +31,27 @@ export function initUserState(clientId: string) { redirectUri: `${window.location.origin}/sign-in/callback` }) - const stored = localStorage.getItem(userStateStorageKey) - if (stored != null) { - try { - Object.assign(userState, JSON.parse(stored)) - } catch { - localStorage.removeItem(userStateStorageKey) - } + restoreUserState() + window.addEventListener('storage', (event) => { + if (event.key === userStateStorageKey) restoreUserState(event.newValue) + }) +} + +function restoreUserState(stored: string | null = localStorage.getItem(userStateStorageKey)) { + if (stored == null) { + clearUserState(false) + return + } + try { + Object.assign(userState, JSON.parse(stored)) + } catch { + localStorage.removeItem(userStateStorageKey) + clearUserState(false) } - watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) +} + +function persistUserState() { + localStorage.setItem(userStateStorageKey, JSON.stringify(userState)) } async function getSignedInUsernameByAccessToken(accessToken: string) { @@ -53,6 +65,7 @@ async function handleTokenResponse(resp: OAuthTokenResponse) { userState.accessTokenExpiresAt = resp.expires_in != null ? Date.now() + resp.expires_in * 1000 : null userState.refreshToken = resp.refresh_token ?? null userState.username = username + persistUserState() } export function useSignIn() { @@ -78,13 +91,15 @@ export async function signInWithAccessToken(accessToken: string) { userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = username + persistUserState() } -function clearUserState() { +function clearUserState(persist: boolean = true) { userState.accessToken = null userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = null + if (persist) persistUserState() } export async function signOut() { @@ -104,16 +119,27 @@ export async function ensureAccessToken(): Promise { return null } if (tokenRefreshPromise == null) { - tokenRefreshPromise = ensureOAuthFlow() - .refreshToken(userState.refreshToken) - .then(handleTokenResponse) - .catch((e) => { - capture(e, 'Failed to refresh access token') - clearUserState() - }) - .finally(() => { - tokenRefreshPromise = null + tokenRefreshPromise = (async () => { + await navigator.locks.request('builder-user-token-refresh', async () => { + restoreUserState() + if (isAccessTokenValid()) return + if (userState.refreshToken == null) { + clearUserState() + return + } + + const refreshToken = userState.refreshToken + try { + await ensureOAuthFlow().refreshToken(refreshToken).then(handleTokenResponse) + } catch (e) { + capture(e, 'Failed to refresh access token') + restoreUserState() + if (userState.refreshToken === refreshToken) clearUserState() + } }) + })().finally(() => { + tokenRefreshPromise = null + }) } await tokenRefreshPromise return userState.accessToken From 18f0130aaf25df2623d077315748f4a294b66848 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 16:42:53 +0800 Subject: [PATCH 02/12] refactor(auth): centralize user state persistence --- spx-gui/src/stores/user/signed-in.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 5d9d15ff6..cfffa976c 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,4 +1,4 @@ -import { reactive, computed } from 'vue' +import { reactive, watchEffect, computed } from 'vue' import { composeQuery, useQuery, useQueryCache, useQueryWithCache } from '@/utils/query' import { capture, useAction } from '@/utils/exception' import { OAuthFlow, type OAuthTokenResponse } from '@/utils/oauth' @@ -35,25 +35,22 @@ export function initUserState(clientId: string) { window.addEventListener('storage', (event) => { if (event.key === userStateStorageKey) restoreUserState(event.newValue) }) + watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } function restoreUserState(stored: string | null = localStorage.getItem(userStateStorageKey)) { if (stored == null) { - clearUserState(false) + clearUserState() return } try { Object.assign(userState, JSON.parse(stored)) } catch { localStorage.removeItem(userStateStorageKey) - clearUserState(false) + clearUserState() } } -function persistUserState() { - localStorage.setItem(userStateStorageKey, JSON.stringify(userState)) -} - async function getSignedInUsernameByAccessToken(accessToken: string) { const user = await userApis.getSignedInUser(accessToken) return user.username @@ -65,7 +62,6 @@ async function handleTokenResponse(resp: OAuthTokenResponse) { userState.accessTokenExpiresAt = resp.expires_in != null ? Date.now() + resp.expires_in * 1000 : null userState.refreshToken = resp.refresh_token ?? null userState.username = username - persistUserState() } export function useSignIn() { @@ -91,15 +87,13 @@ export async function signInWithAccessToken(accessToken: string) { userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = username - persistUserState() } -function clearUserState(persist: boolean = true) { +function clearUserState() { userState.accessToken = null userState.accessTokenExpiresAt = null userState.refreshToken = null userState.username = null - if (persist) persistUserState() } export async function signOut() { From 22e308a8afa09e9f5995c06e8587a4033765b8a8 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 17:02:21 +0800 Subject: [PATCH 03/12] refactor(auth): rely on web locks for refresh coordination --- spx-gui/src/stores/user/signed-in.ts | 43 +++++++++++----------------- 1 file changed, 17 insertions(+), 26 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index cfffa976c..45ee14e6e 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -104,38 +104,29 @@ export async function signOut() { ).catch((e) => capture(e, 'Failed to revoke tokens during sign out')) } -let tokenRefreshPromise: Promise | null = null - export async function ensureAccessToken(): Promise { if (isAccessTokenValid()) return userState.accessToken if (userState.refreshToken == null) { clearUserState() return null } - if (tokenRefreshPromise == null) { - tokenRefreshPromise = (async () => { - await navigator.locks.request('builder-user-token-refresh', async () => { - restoreUserState() - if (isAccessTokenValid()) return - if (userState.refreshToken == null) { - clearUserState() - return - } - - const refreshToken = userState.refreshToken - try { - await ensureOAuthFlow().refreshToken(refreshToken).then(handleTokenResponse) - } catch (e) { - capture(e, 'Failed to refresh access token') - restoreUserState() - if (userState.refreshToken === refreshToken) clearUserState() - } - }) - })().finally(() => { - tokenRefreshPromise = null - }) - } - await tokenRefreshPromise + await navigator.locks.request('builder-user-token-refresh', async () => { + restoreUserState() + if (isAccessTokenValid()) return + if (userState.refreshToken == null) { + clearUserState() + return + } + + const refreshToken = userState.refreshToken + try { + await ensureOAuthFlow().refreshToken(refreshToken).then(handleTokenResponse) + } catch (e) { + capture(e, 'Failed to refresh access token') + restoreUserState() + if (userState.refreshToken === refreshToken) clearUserState() + } + }) return userState.accessToken } From 140fb0823a9d1d2fd4df49680a961e17a44aa025 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 17:11:24 +0800 Subject: [PATCH 04/12] refactor(auth): serialize access token reads --- spx-gui/src/stores/user/signed-in.test.ts | 50 +++++++++++++++++------ spx-gui/src/stores/user/signed-in.ts | 8 +--- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index bb5d5e275..88d71ecff 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -23,21 +23,22 @@ describe('ensureAccessToken', () => { username: 'alice' }) ) + const request = vi.fn(async (_name: string, callback: () => Promise) => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'new-access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'new-refresh-token', + username: 'alice' + }) + ) + await callback() + }) Object.defineProperty(navigator, 'locks', { configurable: true, value: { - request: vi.fn(async (_name: string, callback: () => Promise) => { - localStorage.setItem( - userStateStorageKey, - JSON.stringify({ - accessToken: 'new-access-token', - accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - refreshToken: 'new-refresh-token', - username: 'alice' - }) - ) - await callback() - }) + request } }) const refreshToken = vi.spyOn(accountOAuthApisForXBuilder, 'refreshToken') @@ -45,6 +46,31 @@ describe('ensureAccessToken', () => { initUserState('client-id') await expect(ensureAccessToken()).resolves.toBe('new-access-token') + expect(request).toHaveBeenCalledWith('builder-user-access-token', expect.any(Function)) expect(refreshToken).not.toHaveBeenCalled() }) + + it('waits for the access token lock when the cached token is valid', async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'refresh-token', + username: 'alice' + }) + ) + const request = vi.fn(async (_name: string, callback: () => Promise) => callback()) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request + } + }) + + initUserState('client-id') + + await expect(ensureAccessToken()).resolves.toBe('access-token') + expect(request).toHaveBeenCalledWith('builder-user-access-token', expect.any(Function)) + }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 45ee14e6e..f22c69c7c 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -10,6 +10,7 @@ import { getUserQueryKey } from './query-keys' export type SignedInUser = userApis.SignedInUser const userStateStorageKey = 'builder-user' +const userAccessTokenLockName = 'builder-user-access-token' let oauthFlow: OAuthFlow<{ returnTo: string }> | null = null @@ -105,12 +106,7 @@ export async function signOut() { } export async function ensureAccessToken(): Promise { - if (isAccessTokenValid()) return userState.accessToken - if (userState.refreshToken == null) { - clearUserState() - return null - } - await navigator.locks.request('builder-user-token-refresh', async () => { + await navigator.locks.request(userAccessTokenLockName, async () => { restoreUserState() if (isAccessTokenValid()) return if (userState.refreshToken == null) { From 9e6d562ad61ff0bd3aff6fd9be672e3c39c1cdf4 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 17:12:14 +0800 Subject: [PATCH 05/12] refactor(auth): clarify refresh token snapshot --- spx-gui/src/stores/user/signed-in.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index f22c69c7c..d4e72c7f0 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -114,13 +114,13 @@ export async function ensureAccessToken(): Promise { return } - const refreshToken = userState.refreshToken + const refreshTokenBeforeRefresh = userState.refreshToken try { - await ensureOAuthFlow().refreshToken(refreshToken).then(handleTokenResponse) + await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh).then(handleTokenResponse) } catch (e) { capture(e, 'Failed to refresh access token') restoreUserState() - if (userState.refreshToken === refreshToken) clearUserState() + if (userState.refreshToken === refreshTokenBeforeRefresh) clearUserState() } }) return userState.accessToken From 872b3b0d049abd5a333a310e2be556a69fae3966 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 17:30:37 +0800 Subject: [PATCH 06/12] refactor(auth): clarify shared session recovery --- spx-gui/src/stores/user/signed-in.test.ts | 46 +++++++++++++++++++++-- spx-gui/src/stores/user/signed-in.ts | 20 +++++----- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index 88d71ecff..253cbcebf 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -1,12 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { accountOAuthApisForXBuilder } from '@/apis/account/oauth' -import { ensureAccessToken, initUserState } from './signed-in' const userStateStorageKey = 'builder-user' describe('ensureAccessToken', () => { - beforeEach(() => { + let ensureAccessToken: typeof import('./signed-in').ensureAccessToken + let initUserState: typeof import('./signed-in').initUserState + let accountOAuthApisForXBuilder: typeof import('@/apis/account/oauth').accountOAuthApisForXBuilder + + beforeEach(async () => { + vi.resetModules() localStorage.clear() + ;({ ensureAccessToken, initUserState } = await import('./signed-in')) + ;({ accountOAuthApisForXBuilder } = await import('@/apis/account/oauth')) }) afterEach(() => { @@ -73,4 +78,39 @@ describe('ensureAccessToken', () => { await expect(ensureAccessToken()).resolves.toBe('access-token') expect(request).toHaveBeenCalledWith('builder-user-access-token', expect.any(Function)) }) + + it('preserves a session written while a refresh request fails', async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'expired-access-token', + accessTokenExpiresAt: Date.now(), + refreshToken: 'old-refresh-token', + username: 'alice' + }) + ) + const request = vi.fn(async (_name: string, callback: () => Promise) => callback()) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request + } + }) + vi.spyOn(accountOAuthApisForXBuilder, 'refreshToken').mockImplementationOnce(async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'new-access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'new-refresh-token', + username: 'alice' + }) + ) + throw new Error('Refresh failed') + }) + + initUserState('client-id') + + await expect(ensureAccessToken()).resolves.toBe('new-access-token') + }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index d4e72c7f0..ce5d965ba 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -34,21 +34,18 @@ export function initUserState(clientId: string) { restoreUserState() window.addEventListener('storage', (event) => { - if (event.key === userStateStorageKey) restoreUserState(event.newValue) + if (event.key === userStateStorageKey) restoreUserState() }) watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } -function restoreUserState(stored: string | null = localStorage.getItem(userStateStorageKey)) { - if (stored == null) { - clearUserState() - return - } +function restoreUserState() { + const stored = localStorage.getItem(userStateStorageKey) + if (stored == null) return try { Object.assign(userState, JSON.parse(stored)) } catch { localStorage.removeItem(userStateStorageKey) - clearUserState() } } @@ -115,12 +112,17 @@ export async function ensureAccessToken(): Promise { } const refreshTokenBeforeRefresh = userState.refreshToken + const storedUserStateBeforeRefresh = localStorage.getItem(userStateStorageKey) try { await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh).then(handleTokenResponse) } catch (e) { capture(e, 'Failed to refresh access token') - restoreUserState() - if (userState.refreshToken === refreshTokenBeforeRefresh) clearUserState() + if (localStorage.getItem(userStateStorageKey) === storedUserStateBeforeRefresh) { + clearUserState() + } else { + // Preserve a session written by another page while this request was in flight. + restoreUserState() + } } }) return userState.accessToken From 941cf405022e4303c83c5b7fa59815bc3f9283e9 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 17:58:35 +0800 Subject: [PATCH 07/12] refactor(auth): simplify refresh failure handling --- spx-gui/src/stores/user/signed-in.test.ts | 35 ----------------------- spx-gui/src/stores/user/signed-in.ts | 8 +----- 2 files changed, 1 insertion(+), 42 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index 253cbcebf..a345b02d1 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -78,39 +78,4 @@ describe('ensureAccessToken', () => { await expect(ensureAccessToken()).resolves.toBe('access-token') expect(request).toHaveBeenCalledWith('builder-user-access-token', expect.any(Function)) }) - - it('preserves a session written while a refresh request fails', async () => { - localStorage.setItem( - userStateStorageKey, - JSON.stringify({ - accessToken: 'expired-access-token', - accessTokenExpiresAt: Date.now(), - refreshToken: 'old-refresh-token', - username: 'alice' - }) - ) - const request = vi.fn(async (_name: string, callback: () => Promise) => callback()) - Object.defineProperty(navigator, 'locks', { - configurable: true, - value: { - request - } - }) - vi.spyOn(accountOAuthApisForXBuilder, 'refreshToken').mockImplementationOnce(async () => { - localStorage.setItem( - userStateStorageKey, - JSON.stringify({ - accessToken: 'new-access-token', - accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - refreshToken: 'new-refresh-token', - username: 'alice' - }) - ) - throw new Error('Refresh failed') - }) - - initUserState('client-id') - - await expect(ensureAccessToken()).resolves.toBe('new-access-token') - }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index ce5d965ba..59fced7be 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -112,17 +112,11 @@ export async function ensureAccessToken(): Promise { } const refreshTokenBeforeRefresh = userState.refreshToken - const storedUserStateBeforeRefresh = localStorage.getItem(userStateStorageKey) try { await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh).then(handleTokenResponse) } catch (e) { capture(e, 'Failed to refresh access token') - if (localStorage.getItem(userStateStorageKey) === storedUserStateBeforeRefresh) { - clearUserState() - } else { - // Preserve a session written by another page while this request was in flight. - restoreUserState() - } + clearUserState() } }) return userState.accessToken From 41d2ed1458b2dbbaa35eca2728fdf9287480bf8b Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 18:38:35 +0800 Subject: [PATCH 08/12] refactor(auth): await refreshed token response --- spx-gui/src/stores/user/signed-in.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 59fced7be..381a69d0f 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -113,7 +113,8 @@ export async function ensureAccessToken(): Promise { const refreshTokenBeforeRefresh = userState.refreshToken try { - await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh).then(handleTokenResponse) + const token = await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh) + await handleTokenResponse(token) } catch (e) { capture(e, 'Failed to refresh access token') clearUserState() From d6a808f1eb75ce64b2133b46e210267f7e4e9269 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 19:06:42 +0800 Subject: [PATCH 09/12] perf(auth): skip lock for valid access tokens --- spx-gui/src/stores/user/signed-in.test.ts | 4 ++-- spx-gui/src/stores/user/signed-in.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index a345b02d1..c7775c3f1 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -55,7 +55,7 @@ describe('ensureAccessToken', () => { expect(refreshToken).not.toHaveBeenCalled() }) - it('waits for the access token lock when the cached token is valid', async () => { + it('returns the cached access token without acquiring the lock', async () => { localStorage.setItem( userStateStorageKey, JSON.stringify({ @@ -76,6 +76,6 @@ describe('ensureAccessToken', () => { initUserState('client-id') await expect(ensureAccessToken()).resolves.toBe('access-token') - expect(request).toHaveBeenCalledWith('builder-user-access-token', expect.any(Function)) + expect(request).not.toHaveBeenCalled() }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 381a69d0f..920adc4c4 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -103,6 +103,8 @@ export async function signOut() { } export async function ensureAccessToken(): Promise { + if (isAccessTokenValid()) return userState.accessToken + await navigator.locks.request(userAccessTokenLockName, async () => { restoreUserState() if (isAccessTokenValid()) return From 11da4aff4a40797c88c04083db8cf4c0384bff75 Mon Sep 17 00:00:00 2001 From: nighca Date: Thu, 30 Jul 2026 19:10:28 +0800 Subject: [PATCH 10/12] fix(auth): clear state when shared storage is missing --- spx-gui/src/stores/user/signed-in.test.ts | 24 +++++++++++++++++++++++ spx-gui/src/stores/user/signed-in.ts | 6 +++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index c7775c3f1..ddcc1f7c4 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -78,4 +78,28 @@ describe('ensureAccessToken', () => { await expect(ensureAccessToken()).resolves.toBe('access-token') expect(request).not.toHaveBeenCalled() }) + + it('clears cached state when the shared state is removed', async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'refresh-token', + username: 'alice' + }) + ) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: vi.fn(async (_name: string, callback: () => Promise) => callback()) + } + }) + initUserState('client-id') + + localStorage.removeItem(userStateStorageKey) + window.dispatchEvent(new StorageEvent('storage', { key: userStateStorageKey })) + + await expect(ensureAccessToken()).resolves.toBe(null) + }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 920adc4c4..6981415cf 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -41,11 +41,15 @@ export function initUserState(clientId: string) { function restoreUserState() { const stored = localStorage.getItem(userStateStorageKey) - if (stored == null) return + if (stored == null) { + clearUserState() + return + } try { Object.assign(userState, JSON.parse(stored)) } catch { localStorage.removeItem(userStateStorageKey) + clearUserState() } } From 3c2b05de1cb403a7c9373c6c44f26167ebf68158 Mon Sep 17 00:00:00 2001 From: nighca Date: Fri, 31 Jul 2026 14:08:53 +0800 Subject: [PATCH 11/12] refactor(auth): synchronize user state through storage --- spx-gui/src/stores/user/signed-in.test.ts | 42 +++++++++++++++ spx-gui/src/stores/user/signed-in.ts | 65 +++++++++++++---------- 2 files changed, 80 insertions(+), 27 deletions(-) diff --git a/spx-gui/src/stores/user/signed-in.test.ts b/spx-gui/src/stores/user/signed-in.test.ts index ddcc1f7c4..6f36f4cd5 100644 --- a/spx-gui/src/stores/user/signed-in.test.ts +++ b/spx-gui/src/stores/user/signed-in.test.ts @@ -102,4 +102,46 @@ describe('ensureAccessToken', () => { await expect(ensureAccessToken()).resolves.toBe(null) }) + + it('clears cached state when the shared storage is cleared', async () => { + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'refresh-token', + username: 'alice' + }) + ) + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: vi.fn(async (_name: string, callback: () => Promise) => callback()) + } + }) + initUserState('client-id') + + localStorage.clear() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + + await expect(ensureAccessToken()).resolves.toBe(null) + }) + + it('does not write state back after receiving a storage event', () => { + initUserState('client-id') + localStorage.setItem( + userStateStorageKey, + JSON.stringify({ + accessToken: 'access-token', + accessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + refreshToken: 'refresh-token', + username: 'alice' + }) + ) + const setItem = vi.spyOn(Storage.prototype, 'setItem') + + window.dispatchEvent(new StorageEvent('storage', { key: userStateStorageKey })) + + expect(setItem).not.toHaveBeenCalled() + }) }) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index 6981415cf..b7a1fcd43 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -1,4 +1,4 @@ -import { reactive, watchEffect, computed } from 'vue' +import { reactive, computed } from 'vue' import { composeQuery, useQuery, useQueryCache, useQueryWithCache } from '@/utils/query' import { capture, useAction } from '@/utils/exception' import { OAuthFlow, type OAuthTokenResponse } from '@/utils/oauth' @@ -12,14 +12,23 @@ export type SignedInUser = userApis.SignedInUser const userStateStorageKey = 'builder-user' const userAccessTokenLockName = 'builder-user-access-token' +type UserState = { + accessToken: string | null + accessTokenExpiresAt: number | null + refreshToken: string | null + username: string | null +} + +const emptyUserState: UserState = { + accessToken: null, + accessTokenExpiresAt: null, + refreshToken: null, + username: null +} + let oauthFlow: OAuthFlow<{ returnTo: string }> | null = null -const userState = reactive({ - accessToken: null as string | null, - accessTokenExpiresAt: null as number | null, - refreshToken: null as string | null, - username: null as string | null -}) +const userState = reactive({ ...emptyUserState }) function ensureOAuthFlow() { if (oauthFlow == null) throw new Error('OAuth flow is not initialized') @@ -34,25 +43,26 @@ export function initUserState(clientId: string) { restoreUserState() window.addEventListener('storage', (event) => { - if (event.key === userStateStorageKey) restoreUserState() + if (event.key == null || event.key === userStateStorageKey) restoreUserState() }) - watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState))) } function restoreUserState() { const stored = localStorage.getItem(userStateStorageKey) - if (stored == null) { - clearUserState() - return - } try { - Object.assign(userState, JSON.parse(stored)) + const newState: UserState = stored != null ? JSON.parse(stored) : emptyUserState + Object.assign(userState, newState) } catch { localStorage.removeItem(userStateStorageKey) - clearUserState() + Object.assign(userState, emptyUserState) } } +function setUserState(state: UserState) { + Object.assign(userState, state) + localStorage.setItem(userStateStorageKey, JSON.stringify(state)) +} + async function getSignedInUsernameByAccessToken(accessToken: string) { const user = await userApis.getSignedInUser(accessToken) return user.username @@ -60,10 +70,12 @@ async function getSignedInUsernameByAccessToken(accessToken: string) { async function handleTokenResponse(resp: OAuthTokenResponse) { const username = await getSignedInUsernameByAccessToken(resp.access_token) - userState.accessToken = resp.access_token - userState.accessTokenExpiresAt = resp.expires_in != null ? Date.now() + resp.expires_in * 1000 : null - userState.refreshToken = resp.refresh_token ?? null - userState.username = username + setUserState({ + accessToken: resp.access_token, + accessTokenExpiresAt: resp.expires_in != null ? Date.now() + resp.expires_in * 1000 : null, + refreshToken: resp.refresh_token ?? null, + username + }) } export function useSignIn() { @@ -85,17 +97,16 @@ export async function completeSignIn(search: string) { export async function signInWithAccessToken(accessToken: string) { const username = await getSignedInUsernameByAccessToken(accessToken) - userState.accessToken = accessToken - userState.accessTokenExpiresAt = null - userState.refreshToken = null - userState.username = username + setUserState({ + accessToken, + accessTokenExpiresAt: null, + refreshToken: null, + username + }) } function clearUserState() { - userState.accessToken = null - userState.accessTokenExpiresAt = null - userState.refreshToken = null - userState.username = null + setUserState(emptyUserState) } export async function signOut() { From acb7f36ba3bf7e57491ee9f615d5071a8cca17f2 Mon Sep 17 00:00:00 2001 From: nighca Date: Fri, 31 Jul 2026 14:10:18 +0800 Subject: [PATCH 12/12] docs(auth): explain cross-tab token refresh lock --- spx-gui/src/stores/user/signed-in.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/spx-gui/src/stores/user/signed-in.ts b/spx-gui/src/stores/user/signed-in.ts index b7a1fcd43..be68698c4 100644 --- a/spx-gui/src/stores/user/signed-in.ts +++ b/spx-gui/src/stores/user/signed-in.ts @@ -121,6 +121,7 @@ export async function ensureAccessToken(): Promise { if (isAccessTokenValid()) return userState.accessToken await navigator.locks.request(userAccessTokenLockName, async () => { + // Another tab may have refreshed the token while this request waited for the lock. restoreUserState() if (isAccessTokenValid()) return if (userState.refreshToken == null) {