Skip to content

Commit a5d0875

Browse files
authored
fix: auth race condition when using the SDK in the Studio structure (#646)
* fix: auth race condition when using the SDK in the Studio structure * fix(auth): fix cookie authentication in studio mode * fix(auth): do not redirect to the login if Studio mode is enabled
1 parent 8ed0524 commit a5d0875

File tree

9 files changed

+490
-506
lines changed

9 files changed

+490
-506
lines changed

packages/core/src/auth/authStore.test.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ describe('authStore', () => {
237237
it('sets to logged in using studio token when studio mode is enabled and token exists', () => {
238238
const studioToken = 'studio-token'
239239
const projectId = 'studio-project'
240+
const studioStorageKey = `__studio_auth_token_${projectId}`
240241
const mockStorage = {
241242
getItem: vi.fn(),
242243
setItem: vi.fn(),
@@ -252,40 +253,38 @@ describe('authStore', () => {
252253
})
253254

254255
const {authState, options} = authStore.getInitialState(instance)
255-
expect(getStudioTokenFromLocalStorage).toHaveBeenCalledWith(mockStorage, projectId)
256+
expect(getStudioTokenFromLocalStorage).toHaveBeenCalledWith(mockStorage, studioStorageKey)
256257
expect(authState).toMatchObject({type: AuthStateType.LOGGED_IN, token: studioToken})
257258
expect(options.authMethod).toBe('localstorage')
258259
})
259260

260-
it('checks for cookie auth when studio mode is enabled and no studio token exists', async () => {
261-
vi.useFakeTimers()
261+
it('checks for cookie auth during initialize when studio mode is enabled and no studio token exists', () => {
262262
const projectId = 'studio-project'
263+
const studioStorageKey = `__studio_auth_token_${projectId}`
263264
const mockStorage = {
264265
getItem: vi.fn(),
265266
setItem: vi.fn(),
266267
removeItem: vi.fn(),
267268
} as unknown as Storage // Mock storage
268269
vi.mocked(getStudioTokenFromLocalStorage).mockReturnValue(null)
269-
// Mock cookie check to return true asynchronously
270270
vi.mocked(checkForCookieAuth).mockResolvedValue(true)
271271

272272
instance = createSanityInstance({
273273
projectId,
274274
dataset: 'd',
275275
studioMode: {enabled: true},
276-
auth: {storageArea: mockStorage}, // Provide mock storage
276+
auth: {storageArea: mockStorage},
277277
})
278278

279-
// Initial state might be logged out before the async check completes
279+
// Verify initial state without async cookie probe
280280
const {authState: initialAuthState} = authStore.getInitialState(instance)
281-
expect(initialAuthState.type).toBe(AuthStateType.LOGGED_OUT) // Or potentially logging in depending on other factors
282-
expect(getStudioTokenFromLocalStorage).toHaveBeenCalledWith(mockStorage, projectId)
283-
expect(checkForCookieAuth).toHaveBeenCalledWith(projectId, expect.any(Function))
281+
expect(initialAuthState.type).toBe(AuthStateType.LOGGED_OUT)
282+
expect(getStudioTokenFromLocalStorage).toHaveBeenCalledWith(mockStorage, studioStorageKey)
284283

285-
// Wait for the promise in getInitialState to resolve
286-
await vi.runAllTimersAsync()
284+
// Trigger store creation + initialize
285+
getAuthState(instance)
287286

288-
vi.useRealTimers()
287+
expect(checkForCookieAuth).toHaveBeenCalledWith(projectId, expect.any(Function))
289288
})
290289

291290
it('falls back to default auth (storage token) when studio mode is disabled', () => {
@@ -573,14 +572,7 @@ describe('authStore', () => {
573572
expect(initialOrgId.getCurrent()).toBe('initial-org-id')
574573

575574
// Call handleCallback with the callback URL
576-
await handleAuthCallback(instance, callbackUrl) // Use await as handleAuthCallback is async
577-
578-
// Wait for the state update to be reflected in the selector
579-
await vi.waitUntil(
580-
() => getDashboardOrganizationId(instance).getCurrent() === 'callback-org-id',
581-
)
582-
// Add a microtask yield just in case
583-
await new Promise((resolve) => setTimeout(resolve, 0))
575+
await handleAuthCallback(instance, callbackUrl)
584576

585577
// Check that the orgId from the callback context is now set
586578
const finalOrgId = getDashboardOrganizationId(instance)

packages/core/src/auth/authStore.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ export interface DashboardContext {
6767
orgId?: string
6868
}
6969

70-
type AuthMethodOptions = 'localstorage' | 'cookie' | undefined
70+
/**
71+
* The method of authentication used.
72+
* @internal
73+
*/
74+
export type AuthMethodOptions = 'localstorage' | 'cookie' | undefined
7175

7276
let tokenRefresherRunning = false
7377

@@ -105,7 +109,8 @@ export const authStore = defineStore<AuthStoreState>({
105109
} = instance.config.auth ?? {}
106110
let storageArea = instance.config.auth?.storageArea
107111

108-
const storageKey = `__sanity_auth_token`
112+
let storageKey = `__sanity_auth_token`
113+
const studioModeEnabled = instance.config.studioMode?.enabled
109114

110115
// This login URL will only be used for local development
111116
let loginDomain = 'https://www.sanity.io'
@@ -149,23 +154,21 @@ export const authStore = defineStore<AuthStoreState>({
149154
console.error('Failed to parse dashboard context from initial location:', err)
150155
}
151156

152-
if (!isInDashboard) {
157+
if (!isInDashboard || studioModeEnabled) {
153158
// If not in dashboard, use the storage area from the config
159+
// If studio mode is enabled, use the local storage area (default)
154160
storageArea = storageArea ?? getDefaultStorage()
155161
}
156162

157163
let token: string | null
158164
let authMethod: AuthMethodOptions
159-
if (instance.config.studioMode?.enabled) {
160-
token = getStudioTokenFromLocalStorage(storageArea, instance.config.projectId)
165+
if (studioModeEnabled) {
166+
// In studio mode, always use the studio-specific storage key and subscribe to it
167+
const studioStorageKey = `__studio_auth_token_${instance.config.projectId ?? ''}`
168+
storageKey = studioStorageKey
169+
token = getStudioTokenFromLocalStorage(storageArea, studioStorageKey)
161170
if (token) {
162171
authMethod = 'localstorage'
163-
} else {
164-
checkForCookieAuth(instance.config.projectId, clientFactory).then((isCookieAuthEnabled) => {
165-
if (isCookieAuthEnabled) {
166-
authMethod = 'cookie'
167-
}
168-
})
169172
}
170173
} else {
171174
token = getTokenFromStorage(storageArea, storageKey)
@@ -177,14 +180,16 @@ export const authStore = defineStore<AuthStoreState>({
177180
let authState: AuthState
178181
if (providedToken) {
179182
authState = {type: AuthStateType.LOGGED_IN, token: providedToken, currentUser: null}
183+
} else if (token && studioModeEnabled) {
184+
authState = {type: AuthStateType.LOGGED_IN, token: token ?? '', currentUser: null}
180185
} else if (
181186
getAuthCode(callbackUrl, initialLocationHref) ||
182187
getTokenFromLocation(initialLocationHref)
183188
) {
184189
authState = {type: AuthStateType.LOGGING_IN, isExchangingToken: false}
185190
// Note: dashboardContext from the callback URL can be set later in handleAuthCallback too
186-
} else if (token && !isInDashboard) {
187-
// Only use token from storage if NOT running in dashboard
191+
} else if (token && !isInDashboard && !studioModeEnabled) {
192+
// Only use token from storage if NOT running in dashboard and studio mode is not enabled
188193
authState = {type: AuthStateType.LOGGED_IN, token, currentUser: null}
189194
} else {
190195
// Default to logged out if no provided token, not handling callback,
@@ -212,11 +217,37 @@ export const authStore = defineStore<AuthStoreState>({
212217
initialize(context) {
213218
const subscriptions: Subscription[] = []
214219
subscriptions.push(subscribeToStateAndFetchCurrentUser(context))
215-
216-
if (context.state.get().options?.storageArea) {
220+
const storageArea = context.state.get().options?.storageArea
221+
if (storageArea) {
217222
subscriptions.push(subscribeToStorageEventsAndSetToken(context))
218223
}
219224

225+
// If in Studio mode with no local token, resolve cookie auth asynchronously
226+
try {
227+
const {instance, state} = context
228+
const studioModeEnabled = !!instance.config.studioMode?.enabled
229+
const token: string | null =
230+
state.get().authState?.type === AuthStateType.LOGGED_IN
231+
? (state.get().authState as LoggedInAuthState).token
232+
: null
233+
if (studioModeEnabled && !token) {
234+
const projectId = instance.config.projectId
235+
const clientFactory = state.get().options.clientFactory
236+
checkForCookieAuth(projectId, clientFactory).then((isCookieAuthEnabled) => {
237+
if (!isCookieAuthEnabled) return
238+
state.set('enableCookieAuth', (prev) => ({
239+
options: {...prev.options, authMethod: 'cookie'},
240+
authState:
241+
prev.authState.type === AuthStateType.LOGGED_IN
242+
? prev.authState
243+
: {type: AuthStateType.LOGGED_IN, token: '', currentUser: null},
244+
}))
245+
})
246+
}
247+
} catch {
248+
// best-effort cookie detection
249+
}
250+
220251
if (!tokenRefresherRunning) {
221252
tokenRefresherRunning = true
222253
subscriptions.push(refreshStampedToken(context))

packages/core/src/auth/studioModeAuth.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,27 +96,27 @@ describe('getStudioTokenFromLocalStorage', () => {
9696
expect(getTokenFromStorageSpy).not.toHaveBeenCalled()
9797
})
9898

99-
it('should return null if projectId is undefined', () => {
99+
it('should return null if storageKey is undefined', () => {
100100
const result = getStudioTokenFromLocalStorage(storageArea, undefined)
101101
expect(result).toBeNull()
102102
expect(getTokenFromStorageSpy).not.toHaveBeenCalled()
103103
})
104104

105105
it('should call getTokenFromStorage with correct key', () => {
106106
getTokenFromStorageSpy.mockReturnValue(null) // Assume token not found for this test
107-
getStudioTokenFromLocalStorage(storageArea, projectId)
107+
getStudioTokenFromLocalStorage(storageArea, studioStorageKey)
108108
expect(getTokenFromStorageSpy).toHaveBeenCalledWith(storageArea, studioStorageKey)
109109
})
110110

111111
it('should return the token if found in storage', () => {
112112
getTokenFromStorageSpy.mockReturnValue(mockToken)
113-
const result = getStudioTokenFromLocalStorage(storageArea, projectId)
113+
const result = getStudioTokenFromLocalStorage(storageArea, studioStorageKey)
114114
expect(result).toBe(mockToken)
115115
})
116116

117117
it('should return null if token is not found in storage', () => {
118118
getTokenFromStorageSpy.mockReturnValue(null)
119-
const result = getStudioTokenFromLocalStorage(storageArea, projectId)
119+
const result = getStudioTokenFromLocalStorage(storageArea, studioStorageKey)
120120
expect(result).toBeNull()
121121
})
122122
})

packages/core/src/auth/studioModeAuth.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,16 @@ export async function checkForCookieAuth(
3333
/**
3434
* Attempts to retrieve a studio token from local storage.
3535
* @param storageArea - The storage area to retrieve the token from.
36-
* @param projectId - The project ID to retrieve the token for.
36+
* @param storageKey - The storage key to retrieve the token from.
3737
* @returns The studio token or null if it does not exist.
3838
* @internal
3939
*/
4040
export function getStudioTokenFromLocalStorage(
4141
storageArea: Storage | undefined,
42-
projectId: string | undefined,
42+
storageKey: string | undefined,
4343
): string | null {
44-
if (!storageArea || !projectId) return null
45-
const studioStorageKey = `__studio_auth_token_${projectId}`
46-
const token = getTokenFromStorage(storageArea, studioStorageKey)
44+
if (!storageArea || !storageKey) return null
45+
const token = getTokenFromStorage(storageArea, storageKey)
4746
if (token) {
4847
return token
4948
}

packages/core/src/auth/subscribeToStateAndFetchCurrentUser.ts

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,43 @@ import {distinctUntilChanged, filter, map, type Subscription, switchMap} from 'r
44
import {type StoreContext} from '../store/defineStore'
55
import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
66
import {AuthStateType} from './authStateType'
7-
import {type AuthState, type AuthStoreState} from './authStore'
7+
import {type AuthMethodOptions, type AuthState, type AuthStoreState} from './authStore'
88

99
export const subscribeToStateAndFetchCurrentUser = ({
1010
state,
11+
instance,
1112
}: StoreContext<AuthStoreState>): Subscription => {
1213
const {clientFactory, apiHost} = state.get().options
14+
const useProjectHostname = !!instance.config.studioMode?.enabled
15+
const projectId = instance.config.projectId
1316

1417
const currentUser$ = state.observable
1518
.pipe(
16-
map(({authState}) => authState),
19+
map(({authState, options}) => ({authState, authMethod: options.authMethod})),
1720
filter(
18-
(authState): authState is Extract<AuthState, {type: AuthStateType.LOGGED_IN}> =>
19-
authState.type === AuthStateType.LOGGED_IN && !authState.currentUser,
21+
(
22+
value,
23+
): value is {
24+
authState: Extract<AuthState, {type: AuthStateType.LOGGED_IN}>
25+
authMethod: AuthMethodOptions
26+
} => value.authState.type === AuthStateType.LOGGED_IN && !value.authState.currentUser,
27+
),
28+
map((value) => ({token: value.authState.token, authMethod: value.authMethod})),
29+
distinctUntilChanged(
30+
(prev, curr) => prev.token === curr.token && prev.authMethod === curr.authMethod,
2031
),
21-
map((authState) => authState.token),
22-
distinctUntilChanged(),
2332
)
2433
.pipe(
25-
map((token) =>
34+
map(({token, authMethod}) =>
2635
clientFactory({
2736
apiVersion: DEFAULT_API_VERSION,
2837
requestTagPrefix: REQUEST_TAG_PREFIX,
29-
token,
38+
token: authMethod === 'cookie' ? undefined : token,
3039
ignoreBrowserTokenWarning: true,
31-
useProjectHostname: false,
40+
useProjectHostname,
3241
useCdn: false,
42+
...(authMethod === 'cookie' ? {withCredentials: true} : {}),
43+
...(useProjectHostname && projectId ? {projectId} : {}),
3344
...(apiHost && {apiHost}),
3445
}),
3546
),

0 commit comments

Comments
 (0)