@sanity/sdk 3.2.0 → 3.3.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "3.2.0",
3
+ "version": "3.3.0-rc.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -53,9 +53,9 @@
53
53
  "@sanity/image-url": "^2.1.1",
54
54
  "@sanity/json-match": "^1.0.5",
55
55
  "@sanity/message-protocol": "^0.24.0",
56
- "@sanity/mutate": "^0.18.1",
56
+ "@sanity/mutate": "^0.18.2",
57
57
  "@sanity/telemetry": "^1.1.0",
58
- "@sanity/types": "^6.13.0",
58
+ "@sanity/types": "^6.13.2",
59
59
  "groq": "^6.13.2",
60
60
  "groq-js": "^2.0.0",
61
61
  "reselect": "^5.1.1",
@@ -339,6 +339,24 @@ describe('authStore', () => {
339
339
  expect(options.authMethod).toBe('localstorage')
340
340
  })
341
341
 
342
+ it('only exposes oauth options when the resolved mode is oauth', () => {
343
+ const oauth = {
344
+ clientId: 'client-abc',
345
+ redirectUri: 'https://app.example.com/callback',
346
+ organizationId: 'org123',
347
+ }
348
+ vi.mocked(getStudioTokenFromLocalStorage).mockReturnValue(null)
349
+ vi.mocked(getTokenFromStorage).mockReturnValue(null)
350
+
351
+ // studio takes precedence over oauth, so oauth options must be dropped
352
+ instance = createSanityInstance({projectId: 'p', dataset: 'd', studio: {}, auth: {oauth}})
353
+ expect(authStore.getInitialState(instance, null).options.oauth).toBeUndefined()
354
+ instance.dispose()
355
+
356
+ instance = createSanityInstance({projectId: 'p', dataset: 'd', auth: {oauth}})
357
+ expect(authStore.getInitialState(instance, null).options.oauth).toEqual(oauth)
358
+ })
359
+
342
360
  it('logs auth initialized in logged out state when no token available', () => {
343
361
  instance = createSanityInstance({
344
362
  projectId: 'p',
@@ -191,7 +191,10 @@ export const authStore = defineStore<AuthStoreState>({
191
191
  storageKey: result.storageKey,
192
192
  storageArea: result.storageArea,
193
193
  authMethod: result.authMethod,
194
- oauth: authConfig.oauth,
194
+ // Only expose oauth options when the oauth strategy is actually in use,
195
+ // so mode-gated actions (logout, handleAuthCallback) stay consistent
196
+ // with the resolved mode (e.g. `studio` takes precedence over `oauth`).
197
+ oauth: mode === 'oauth' ? authConfig.oauth : undefined,
195
198
  },
196
199
  }
197
200
  },
@@ -5,6 +5,7 @@ import {createSanityInstance, type SanityInstance} from '../store/createSanityIn
5
5
  import {AuthStateType} from './authStateType'
6
6
  import {getAuthState} from './authStore'
7
7
  import {handleAuthCallback} from './handleAuthCallback'
8
+ import {handleOAuthCallback} from './oauth/oauthActions'
8
9
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
9
10
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
10
11
  import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
@@ -22,6 +23,11 @@ vi.mock('./utils', async (importOriginal) => {
22
23
  vi.mock('./subscribeToStateAndFetchCurrentUser')
23
24
  vi.mock('./subscribeToStorageEventsAndSetToken')
24
25
 
26
+ vi.mock('./oauth/oauthActions', async (importOriginal) => {
27
+ const original = await importOriginal<typeof import('./oauth/oauthActions')>()
28
+ return {...original, handleOAuthCallback: vi.fn()}
29
+ })
30
+
25
31
  // Mock logger to prevent actual logging during tests
26
32
  vi.mock('../utils/logger', async (importOriginal) => {
27
33
  const original = await importOriginal<typeof import('../utils/logger')>()
@@ -277,4 +283,31 @@ describe('handleCallback', () => {
277
283
  // Verify logger was called
278
284
  expect(createLogger).toHaveBeenCalled()
279
285
  })
286
+
287
+ it('delegates to handleOAuthCallback in OAuth mode', async () => {
288
+ const clientFactory = vi.fn()
289
+ vi.mocked(getTokenFromStorage).mockReturnValue(null)
290
+ vi.mocked(handleOAuthCallback).mockResolvedValue('https://app.example.com/callback')
291
+
292
+ instance = createSanityInstance({
293
+ projectId: 'p',
294
+ dataset: 'd',
295
+ auth: {
296
+ clientFactory,
297
+ oauth: {
298
+ clientId: 'client-abc',
299
+ redirectUri: 'https://app.example.com/callback',
300
+ organizationId: 'org123',
301
+ },
302
+ },
303
+ })
304
+
305
+ const href = 'https://app.example.com/callback?code=abc&state=xyz'
306
+ const result = await handleAuthCallback(instance, href)
307
+
308
+ expect(result).toBe('https://app.example.com/callback')
309
+ expect(handleOAuthCallback).toHaveBeenCalledWith(instance, href)
310
+ expect(getAuthCode).not.toHaveBeenCalled()
311
+ expect(clientFactory).not.toHaveBeenCalled()
312
+ })
280
313
  })
@@ -3,6 +3,7 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
3
3
  import {getAuthLogger} from './authLogger'
4
4
  import {AuthStateType} from './authStateType'
5
5
  import {authStore, type AuthStoreState, type DashboardContext} from './authStore'
6
+ import {handleOAuthCallback} from './oauth/oauthActions'
6
7
  import {
7
8
  createLoggedInAuthState,
8
9
  getAuthCode,
@@ -12,6 +13,10 @@ import {
12
13
  } from './utils'
13
14
 
14
15
  /**
16
+ * Handles the auth redirect callback. In OAuth mode this delegates to
17
+ * {@link handleOAuthCallback}; otherwise it exchanges the legacy `sid` for a
18
+ * long-lived token.
19
+ *
15
20
  * @public
16
21
  */
17
22
  export const handleAuthCallback = bindActionGlobally(
@@ -19,7 +24,7 @@ export const handleAuthCallback = bindActionGlobally(
19
24
  async ({state, instance}, locationHref: string = getDefaultLocation()) => {
20
25
  const logger = getAuthLogger(instance)
21
26
 
22
- const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
27
+ const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey, oauth} =
23
28
  state.get().options
24
29
 
25
30
  // If a token is provided, no need to handle callback
@@ -28,6 +33,11 @@ export const handleAuthCallback = bindActionGlobally(
28
33
  return false
29
34
  }
30
35
 
36
+ if (oauth) {
37
+ logger.debug('OAuth mode - delegating to handleOAuthCallback')
38
+ return handleOAuthCallback(instance, locationHref)
39
+ }
40
+
31
41
  // Don't handle the callback if already in flight.
32
42
  const {authState} = state.get()
33
43
  if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
@@ -169,7 +169,7 @@ describe('logout', () => {
169
169
  expect(removeItem).toHaveBeenCalledWith('__sanity_auth_token')
170
170
  })
171
171
 
172
- it('clears the OAuth tokens from the store on logout', async () => {
172
+ it('revokes OAuth tokens instead of calling /auth/logout in OAuth mode', async () => {
173
173
  const oauthTokens: OAuthTokens = {
174
174
  accessToken: 'oauth-access',
175
175
  tokenType: 'bearer',
@@ -204,8 +204,16 @@ describe('logout', () => {
204
204
  // Boots logged in with the persisted OAuth tokens exposed on the store.
205
205
  expect(getOAuthTokensState(instance).getCurrent()).toMatchObject({accessToken: 'oauth-access'})
206
206
 
207
+ const first = logout(instance)
208
+ // Session is marked as destroying so a concurrent call is a no-op
209
+ expect(getAuthState(instance).getCurrent()).toMatchObject({isDestroyingSession: true})
207
210
  await logout(instance)
211
+ await first
208
212
 
213
+ expect(mockRequest).toHaveBeenCalledTimes(1)
214
+ expect(mockRequest).toHaveBeenCalledWith(
215
+ expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
216
+ )
209
217
  expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
210
218
  expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
211
219
  expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
@@ -3,14 +3,22 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
3
3
  import {getAuthLogger} from './authLogger'
4
4
  import {AuthStateType} from './authStateType'
5
5
  import {authStore} from './authStore'
6
+ import {revokeOAuthTokens} from './oauth/oauthActions'
6
7
 
7
8
  /**
9
+ * Logs the current user out. In OAuth mode this revokes the OAuth tokens
10
+ * instead of calling the legacy `/auth/logout` endpoint; revocation is
11
+ * best-effort, so the OAuth path resolves even if the revoke request fails,
12
+ * whereas the legacy path rejects when `/auth/logout` fails. Local state and
13
+ * storage are cleared in both cases.
14
+ *
8
15
  * @public
9
16
  */
10
17
  export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
11
18
  const logger = getAuthLogger(instance)
12
19
 
13
- const {clientFactory, apiHost, providedToken, storageArea, storageKey} = state.get().options
20
+ const {clientFactory, apiHost, providedToken, storageArea, storageKey, oauth} =
21
+ state.get().options
14
22
 
15
23
  // If a token is statically provided, logout does nothing
16
24
  if (providedToken) {
@@ -25,6 +33,15 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
25
33
  logger.debug('Skipping logout - already in progress')
26
34
  return
27
35
  }
36
+
37
+ if (oauth) {
38
+ logger.debug('OAuth mode - revoking tokens instead of calling /auth/logout')
39
+ state.set('loggingOut', {
40
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: true},
41
+ })
42
+ await revokeOAuthTokens(instance)
43
+ return
44
+ }
28
45
  const token = authState.type === AuthStateType.LOGGED_IN && authState.token
29
46
 
30
47
  try {
@@ -9,6 +9,7 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
9
9
  import {
10
10
  getOAuthTokensState,
11
11
  handleOAuthCallback,
12
+ OAUTH_RETURN_TO_KEY,
12
13
  OAUTH_STATE_KEY,
13
14
  OAUTH_VERIFIER_KEY,
14
15
  refreshOAuthTokens,
@@ -156,6 +157,28 @@ describe('startOAuthAuthorization', () => {
156
157
  expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
157
158
  })
158
159
 
160
+ it('stashes the current location to return to, keeping app params intact', async () => {
161
+ const href = 'https://app.example.com/documents/abc?state=draft&code=x#h'
162
+ vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
163
+ vi.stubGlobal('location', {href})
164
+ const {session} = setup()
165
+
166
+ await startOAuthAuthorization(instance!)
167
+
168
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe(href)
169
+ })
170
+
171
+ it('strips stale OAuth params from the stashed location when already on the callback route', async () => {
172
+ const href = 'https://app.example.com/callback?x=1&code=old&state=old'
173
+ vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
174
+ vi.stubGlobal('location', {href})
175
+ const {session} = setup()
176
+
177
+ await startOAuthAuthorization(instance!)
178
+
179
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe('https://app.example.com/callback?x=1')
180
+ })
181
+
159
182
  it('throws when OAuth is not configured', async () => {
160
183
  setup({withOAuthConfig: false})
161
184
  await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
@@ -203,6 +226,75 @@ describe('handleOAuthCallback', () => {
203
226
  })
204
227
  })
205
228
 
229
+ it('returns the stashed same-origin location after a successful exchange', async () => {
230
+ const returnTo = 'https://app.example.com/documents/abc?x=1'
231
+ const {session} = setup({
232
+ sessionSeed: {
233
+ [OAUTH_STATE_KEY]: 'state-xyz',
234
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
235
+ [OAUTH_RETURN_TO_KEY]: returnTo,
236
+ },
237
+ })
238
+
239
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(returnTo)
240
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
241
+ })
242
+
243
+ it('ignores a stashed location on a different origin', async () => {
244
+ setup({
245
+ sessionSeed: {
246
+ [OAUTH_STATE_KEY]: 'state-xyz',
247
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
248
+ [OAUTH_RETURN_TO_KEY]: 'https://evil.example.com/',
249
+ },
250
+ })
251
+
252
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
253
+ 'https://app.example.com/callback',
254
+ )
255
+ })
256
+
257
+ it('ignores a stashed location that is not a URL', async () => {
258
+ setup({
259
+ sessionSeed: {
260
+ [OAUTH_STATE_KEY]: 'state-xyz',
261
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
262
+ [OAUTH_RETURN_TO_KEY]: 'not a url',
263
+ },
264
+ })
265
+
266
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
267
+ 'https://app.example.com/callback',
268
+ )
269
+ })
270
+
271
+ it('does not restore the stashed location on a state mismatch', async () => {
272
+ const {session} = setup({
273
+ sessionSeed: {
274
+ [OAUTH_STATE_KEY]: 'different',
275
+ [OAUTH_VERIFIER_KEY]: 'verifier-1',
276
+ [OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep',
277
+ },
278
+ })
279
+
280
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
281
+ 'https://app.example.com/callback',
282
+ )
283
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
284
+ })
285
+
286
+ it('does not restore the stashed location on an ?error= callback', async () => {
287
+ const {session} = setup({sessionSeed: {[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep'}})
288
+
289
+ const result = await handleOAuthCallback(
290
+ instance!,
291
+ 'https://app.example.com/callback?error=access_denied',
292
+ )
293
+
294
+ expect(result).toBe('https://app.example.com/callback')
295
+ expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
296
+ })
297
+
206
298
  it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
207
299
  const pending = deferred<typeof tokenResponse>()
208
300
  const request = vi.fn().mockReturnValue(pending.promise)
@@ -221,6 +313,31 @@ describe('handleOAuthCallback', () => {
221
313
  expect(await first).toBe('https://app.example.com/callback')
222
314
  })
223
315
 
316
+ it('does not clobber LOGGED_IN when called again with a stale callback URL', async () => {
317
+ const {request} = setup({
318
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
319
+ })
320
+
321
+ expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
322
+ 'https://app.example.com/callback',
323
+ )
324
+ // Artifacts are now cleared; a repeat with the same URL fails state validation.
325
+ // Seed fresh artifacts as if a re-authorization started in this tab: the
326
+ // ignored stale callback must not wipe them.
327
+ sessionStorage.setItem(OAUTH_STATE_KEY, 'state-next')
328
+ sessionStorage.setItem(OAUTH_VERIFIER_KEY, 'verifier-next')
329
+ const result = await handleOAuthCallback(instance!, callbackHref)
330
+
331
+ expect(result).toBe('https://app.example.com/callback')
332
+ expect(request).toHaveBeenCalledTimes(1)
333
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({
334
+ type: AuthStateType.LOGGED_IN,
335
+ token: 'new-access',
336
+ })
337
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
338
+ expect(sessionStorage.getItem(OAUTH_VERIFIER_KEY)).toBe('verifier-next')
339
+ })
340
+
224
341
  it('surfaces an ?error= callback as ERROR without exchanging', async () => {
225
342
  const {request} = setup()
226
343
 
@@ -234,6 +351,25 @@ describe('handleOAuthCallback', () => {
234
351
  expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
235
352
  })
236
353
 
354
+ it('ignores an ?error= callback when a session is already established', async () => {
355
+ setup({
356
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
357
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-next', [OAUTH_VERIFIER_KEY]: 'verifier-next'},
358
+ })
359
+
360
+ const result = await handleOAuthCallback(
361
+ instance!,
362
+ 'https://app.example.com/callback?error=access_denied',
363
+ )
364
+
365
+ expect(result).toBe('https://app.example.com/callback')
366
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({
367
+ type: AuthStateType.LOGGED_IN,
368
+ token: seededTokens.accessToken,
369
+ })
370
+ expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
371
+ })
372
+
237
373
  it('surfaces an ?error= callback without a description', async () => {
238
374
  setup()
239
375
  await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
@@ -17,6 +17,25 @@ export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
17
17
  /** sessionStorage key for the CSRF `state` value. */
18
18
  export const OAUTH_STATE_KEY = '__sanity_oauth_state'
19
19
 
20
+ /** sessionStorage key for the location to return to after the callback. */
21
+ export const OAUTH_RETURN_TO_KEY = '__sanity_oauth_return_to'
22
+
23
+ const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error', 'error_description']
24
+
25
+ /** Strips the OAuth callback params from a URL. */
26
+ function stripOAuthParams(href: string): string {
27
+ const url = new URL(href, DEFAULT_BASE)
28
+ for (const param of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(param)
29
+ return url.toString()
30
+ }
31
+
32
+ /** Whether `href` is the registered redirect URI (origin + pathname; query and hash ignored). */
33
+ function isCallbackRoute(href: string, redirectUri: string): boolean {
34
+ const current = new URL(href, DEFAULT_BASE)
35
+ const redirect = new URL(redirectUri, DEFAULT_BASE)
36
+ return current.origin === redirect.origin && current.pathname === redirect.pathname
37
+ }
38
+
20
39
  interface TokenEndpointResponse {
21
40
  access_token: string
22
41
  token_type: string
@@ -99,6 +118,17 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
99
118
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
100
119
  session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
101
120
  session?.setItem(OAUTH_STATE_KEY, oauthState)
121
+ // The redirect URI must match the registered one exactly, so the current
122
+ // location (deep link) is stashed here and restored by the callback. OAuth
123
+ // params are only stripped when we are already on the callback route, so
124
+ // legitimate app params like `?state=draft` survive the round trip.
125
+ const currentHref = getDefaultLocation()
126
+ if (currentHref !== DEFAULT_BASE) {
127
+ const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
128
+ ? stripOAuthParams(currentHref)
129
+ : currentHref
130
+ session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
131
+ }
102
132
 
103
133
  const authorizeUrl = new URL(
104
134
  '/v1/auth/oauth/authorize',
@@ -123,9 +153,14 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
123
153
  * responses, exchanges the authorization `code` for tokens, persists them, and
124
154
  * transitions to `LOGGED_IN`.
125
155
  *
126
- * Returns the callback URL cleaned of OAuth params (for the caller to
127
- * `history.replaceState`) when a callback was processed, or `false` when there
128
- * was nothing to handle (no code, or an exchange already in progress).
156
+ * Returns the URL for the caller to `history.replaceState` to when a callback
157
+ * was processed: on success, the same-origin location the user was on when
158
+ * the flow started (if any), otherwise the callback URL cleaned of OAuth
159
+ * params. Returns `false` when there was nothing to handle (no code, or an
160
+ * exchange already in progress). A callback that fails validation while a
161
+ * session is already `LOGGED_IN` (e.g. a remount before the params were
162
+ * stripped) is ignored with a warning and still returns the cleaned URL, so a
163
+ * string result does not imply an exchange ran.
129
164
  *
130
165
  * @public
131
166
  */
@@ -152,22 +187,29 @@ export const handleOAuthCallback = bindActionGlobally(
152
187
 
153
188
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
154
189
 
155
- const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
156
- for (const param of ['code', 'state', 'error', 'error_description']) {
157
- cleanedUrlObj.searchParams.delete(param)
190
+ const cleanedUrl = stripOAuthParams(locationHref)
191
+
192
+ // Callback failures move to ERROR, unless a session is already
193
+ // established: a stale callback URL (e.g. remount before the params were
194
+ // stripped, after the artifacts were cleared) or a denied re-authorization
195
+ // must not clobber LOGGED_IN.
196
+ const rejectCallback = (name: string, message: string): string => {
197
+ if (authState.type === AuthStateType.LOGGED_IN) {
198
+ // Leave PKCE artifacts alone: a re-authorization may be in flight.
199
+ logger.warn(`${message} — ignoring callback, session already established`)
200
+ return cleanedUrl
201
+ }
202
+ logger.error(`${message} — rejecting callback`)
203
+ clearOAuthArtifacts(session)
204
+ state.set(name, {authState: {type: AuthStateType.ERROR, error: new Error(message)}})
205
+ return cleanedUrl
158
206
  }
159
- const cleanedUrl = cleanedUrlObj.toString()
160
207
 
161
208
  if (error) {
162
- logger.warn('OAuth callback returned an error', {error, errorDescription})
163
- clearOAuthArtifacts(session)
164
- state.set('oauthCallbackError', {
165
- authState: {
166
- type: AuthStateType.ERROR,
167
- error: new Error(errorDescription ? `${error}: ${errorDescription}` : error),
168
- },
169
- })
170
- return cleanedUrl
209
+ return rejectCallback(
210
+ 'oauthCallbackError',
211
+ errorDescription ? `${error}: ${errorDescription}` : error,
212
+ )
171
213
  }
172
214
 
173
215
  if (!code) {
@@ -177,22 +219,12 @@ export const handleOAuthCallback = bindActionGlobally(
177
219
 
178
220
  const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
179
221
  if (!returnedState || !storedState || returnedState !== storedState) {
180
- logger.error('OAuth state mismatch — rejecting callback')
181
- clearOAuthArtifacts(session)
182
- state.set('oauthStateMismatch', {
183
- authState: {type: AuthStateType.ERROR, error: new Error('OAuth state mismatch')},
184
- })
185
- return cleanedUrl
222
+ return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
186
223
  }
187
224
 
188
225
  const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
189
226
  if (!codeVerifier) {
190
- logger.error('OAuth code verifier missing — cannot exchange code')
191
- clearOAuthArtifacts(session)
192
- state.set('oauthVerifierMissing', {
193
- authState: {type: AuthStateType.ERROR, error: new Error('OAuth code verifier missing')},
194
- })
195
- return cleanedUrl
227
+ return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
196
228
  }
197
229
 
198
230
  logger.info('Exchanging OAuth code for tokens')
@@ -220,6 +252,7 @@ export const handleOAuthCallback = bindActionGlobally(
220
252
 
221
253
  const tokens = toOAuthTokens(response)
222
254
  options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
255
+ const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
223
256
  clearOAuthArtifacts(session)
224
257
 
225
258
  logger.info('OAuth tokens obtained, user logged in')
@@ -227,7 +260,7 @@ export const handleOAuthCallback = bindActionGlobally(
227
260
  authState: createLoggedInAuthState(tokens.accessToken, null),
228
261
  oauthTokens: tokens,
229
262
  })
230
- return cleanedUrl
263
+ return returnTo ?? cleanedUrl
231
264
  } catch (exchangeError) {
232
265
  logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
233
266
  clearOAuthArtifacts(session)
@@ -386,4 +419,20 @@ export const getOAuthTokensState = bindActionGlobally(
386
419
  function clearOAuthArtifacts(session: Storage | undefined): void {
387
420
  session?.removeItem(OAUTH_VERIFIER_KEY)
388
421
  session?.removeItem(OAUTH_STATE_KEY)
422
+ session?.removeItem(OAUTH_RETURN_TO_KEY)
423
+ }
424
+
425
+ /**
426
+ * Reads the stashed pre-authorize location, only honouring it when it shares
427
+ * the registered redirect URI's origin so a tampered value cannot redirect
428
+ * off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
429
+ */
430
+ function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
431
+ const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
432
+ if (!returnTo) return null
433
+ try {
434
+ return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
435
+ } catch {
436
+ return null
437
+ }
389
438
  }
@@ -83,6 +83,11 @@ describe('serialize/deserialize tokens', () => {
83
83
  expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
84
84
  expect(deserializeTokens('123')).toBeNull()
85
85
  })
86
+
87
+ it('returns null when expiresAt does not parse to a valid date', () => {
88
+ const raw = JSON.stringify({...JSON.parse(serializeTokens(tokens)), expiresAt: 'garbage'})
89
+ expect(deserializeTokens(raw)).toBeNull()
90
+ })
86
91
  })
87
92
 
88
93
  describe('getOauthInitialState', () => {
@@ -94,6 +99,16 @@ describe('getOauthInitialState', () => {
94
99
  expect(result.authMethod).toBe('localstorage')
95
100
  })
96
101
 
102
+ it('returns LOGGED_OUT and clears storage when persisted tokens are corrupt', () => {
103
+ const storageArea = createMemoryStorage({
104
+ [OAUTH_TOKENS_KEY]: '{"accessToken":"a","expiresAt":"garbage"}',
105
+ })
106
+ const result = getOauthInitialState(baseOptions({storageArea}))
107
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
108
+ expect(result.oauthTokens).toBeUndefined()
109
+ expect(storageArea.getItem(OAUTH_TOKENS_KEY)).toBeNull()
110
+ })
111
+
97
112
  it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
98
113
  const storageArea = createMemoryStorage()
99
114
  const result = getOauthInitialState(
@@ -110,6 +125,14 @@ describe('getOauthInitialState', () => {
110
125
  expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
111
126
  })
112
127
 
128
+ it('returns LOGGED_OUT when the redirect URI only carries a stray state param', () => {
129
+ const storageArea = createMemoryStorage()
130
+ const result = getOauthInitialState(
131
+ baseOptions({storageArea, initialLocationHref: 'https://app/callback?state=s'}),
132
+ )
133
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
134
+ })
135
+
113
136
  it('returns LOGGED_OUT when there are no tokens and no callback', () => {
114
137
  const storageArea = createMemoryStorage()
115
138
  const result = getOauthInitialState(baseOptions({storageArea}))
@@ -19,7 +19,9 @@ interface SerializedOAuthTokens extends Omit<OAuthTokens, 'expiresAt'> {
19
19
 
20
20
  /**
21
21
  * Parses persisted token JSON back into {@link OAuthTokens}. Returns `null`
22
- * when the value is missing or malformed.
22
+ * when the value is missing or malformed, including an `expiresAt` that does
23
+ * not parse to a valid date (an `Invalid Date` would otherwise read as never
24
+ * expiring, since `NaN <= now` is always `false`).
23
25
  *
24
26
  * @internal
25
27
  */
@@ -38,11 +40,13 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
38
40
  return null
39
41
  }
40
42
  const value = parsed as SerializedOAuthTokens
43
+ const expiresAt = new Date(value.expiresAt)
44
+ if (Number.isNaN(expiresAt.getTime())) return null
41
45
  return {
42
46
  accessToken: value.accessToken,
43
47
  tokenType: 'bearer',
44
48
  expiresIn: value.expiresIn,
45
- expiresAt: new Date(value.expiresAt),
49
+ expiresAt,
46
50
  ...(value.refreshToken !== undefined && {refreshToken: value.refreshToken}),
47
51
  }
48
52
  } catch {
@@ -55,7 +59,7 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
55
59
  *
56
60
  * State discovery order:
57
61
  * 1. Persisted tokens in `__sanity_oauth_tokens` → `LOGGED_IN`
58
- * 2. Callback URL (contains `code`/`state`/`error` and matches `redirectUri`)
62
+ * 2. Callback URL (contains `code` or `error` and matches `redirectUri`)
59
63
  * → `LOGGING_IN`
60
64
  * 3. Otherwise → `LOGGED_OUT`
61
65
  *
@@ -68,7 +72,12 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
68
72
  const redirectUri = authConfig.oauth?.redirectUri
69
73
 
70
74
  // Persisted tokens win
71
- const tokens = deserializeTokens(storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null)
75
+ const rawTokens = storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null
76
+ const tokens = deserializeTokens(rawTokens)
77
+ if (rawTokens && !tokens) {
78
+ // Corrupt entry: drop it so it cannot keep shadowing a fresh login
79
+ storageArea?.removeItem(OAUTH_TOKENS_KEY)
80
+ }
72
81
  if (tokens) {
73
82
  return {
74
83
  authState: createLoggedInAuthState(tokens.accessToken, null),
@@ -80,11 +89,12 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
80
89
  }
81
90
  }
82
91
 
83
- // Callback URL with code/state/error whose origin + pathname match our
84
- // redirect URI (query and hash are ignored).
92
+ // Callback URL with code/error whose origin + pathname match our redirect
93
+ // URI (query and hash are ignored). `state` alone is not a callback: the
94
+ // server always sends `code` or `error`, and handleOAuthCallback would
95
+ // otherwise leave the app stuck in LOGGING_IN.
85
96
  const {searchParams} = new URL(initialLocationHref, DEFAULT_BASE)
86
- const isCallback =
87
- searchParams.has('code') || searchParams.has('state') || searchParams.has('error')
97
+ const isCallback = searchParams.has('code') || searchParams.has('error')
88
98
  if (redirectUri && isCallback) {
89
99
  const loc = new URL(initialLocationHref, DEFAULT_BASE)
90
100
  const redirect = new URL(redirectUri, DEFAULT_BASE)