Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
105 changes: 105 additions & 0 deletions spx-gui/src/stores/user/signed-in.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const userStateStorageKey = 'builder-user'

describe('ensureAccessToken', () => {
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(() => {
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'
})
)
const request = vi.fn(async (_name: string, callback: () => Promise<void>) => {
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
}
})
const refreshToken = vi.spyOn(accountOAuthApisForXBuilder, 'refreshToken')

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('returns the cached access token without acquiring the lock', 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<void>) => callback())
Object.defineProperty(navigator, 'locks', {
configurable: true,
value: {
request
}
})

initUserState('client-id')

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<void>) => callback())
}
})
initUserState('client-id')

localStorage.removeItem(userStateStorageKey)
window.dispatchEvent(new StorageEvent('storage', { key: userStateStorageKey }))

await expect(ensureAccessToken()).resolves.toBe(null)
})
})
62 changes: 36 additions & 26 deletions spx-gui/src/stores/user/signed-in.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -31,15 +32,25 @@ export function initUserState(clientId: string) {
redirectUri: `${window.location.origin}/sign-in/callback`
})

restoreUserState()
window.addEventListener('storage', (event) => {
if (event.key === userStateStorageKey) restoreUserState()
})
Comment thread
nighca marked this conversation as resolved.
watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState)))
Comment thread
nighca marked this conversation as resolved.
Outdated
}

function restoreUserState() {
const stored = localStorage.getItem(userStateStorageKey)
if (stored != null) {
try {
Object.assign(userState, JSON.parse(stored))
} catch {
localStorage.removeItem(userStateStorageKey)
}
if (stored == null) {
clearUserState()
return
Comment thread
nighca marked this conversation as resolved.
Outdated
}
try {
Object.assign(userState, JSON.parse(stored))
Comment thread
nighca marked this conversation as resolved.
Outdated
} catch {
localStorage.removeItem(userStateStorageKey)
clearUserState()
}
watchEffect(() => localStorage.setItem(userStateStorageKey, JSON.stringify(userState)))
}

async function getSignedInUsernameByAccessToken(accessToken: string) {
Expand Down Expand Up @@ -95,27 +106,26 @@ export async function signOut() {
).catch((e) => capture(e, 'Failed to revoke tokens during sign out'))
}

let tokenRefreshPromise: Promise<void> | null = null

export async function ensureAccessToken(): Promise<string | null> {
if (isAccessTokenValid()) return userState.accessToken
if (userState.refreshToken == null) {
clearUserState()
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
})
}
await tokenRefreshPromise

await navigator.locks.request(userAccessTokenLockName, async () => {
Comment thread
nighca marked this conversation as resolved.
restoreUserState()
if (isAccessTokenValid()) return
if (userState.refreshToken == null) {
clearUserState()
return
}

const refreshTokenBeforeRefresh = userState.refreshToken
try {
const token = await ensureOAuthFlow().refreshToken(refreshTokenBeforeRefresh)
await handleTokenResponse(token)
Comment thread
nighca marked this conversation as resolved.
} catch (e) {
capture(e, 'Failed to refresh access token')
clearUserState()
}
})
return userState.accessToken
}

Expand Down