@sanity/sdk 3.3.0-rc.0 → 3.3.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.
Files changed (46) hide show
  1. package/dist/_exports/_internal.d.ts +12 -4
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +24 -6
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.js +1 -1
  6. package/dist/_exports/comlink.d.ts +1 -1
  7. package/dist/_exports/dashboard.d.ts +1 -1
  8. package/dist/_exports/dashboard.js +1 -1
  9. package/dist/{authStore-BGoEGxOq.d.ts → applications-pRS8tuC2.d.ts} +349 -362
  10. package/dist/applications-pRS8tuC2.d.ts.map +1 -0
  11. package/dist/{clientStore-QDrJNTM4.js → clientStore-BI4D8HhJ.js} +10 -14
  12. package/dist/clientStore-BI4D8HhJ.js.map +1 -0
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.js +123 -156
  15. package/dist/index.js.map +1 -1
  16. package/dist/{topicStore-Ct7IFNtY.js → topicStore-BJ54_X6H.js} +3 -1
  17. package/dist/topicStore-BJ54_X6H.js.map +1 -0
  18. package/dist/{topicStore-DXvuH9h4.d.ts → topicStore-D-jEwzlX.d.ts} +54 -25
  19. package/dist/topicStore-D-jEwzlX.d.ts.map +1 -0
  20. package/dist/{version-CIhX5ppJ.js → version-CD4aBigm.js} +3 -3
  21. package/dist/{version-CIhX5ppJ.js.map → version-CD4aBigm.js.map} +1 -1
  22. package/package.json +7 -7
  23. package/src/_exports/_internal.ts +2 -0
  24. package/src/applications/applicationOrigin.test.ts +51 -0
  25. package/src/applications/applicationOrigin.ts +26 -0
  26. package/src/auth/authStore.test.ts +0 -18
  27. package/src/auth/authStore.ts +1 -4
  28. package/src/auth/handleAuthCallback.test.ts +0 -33
  29. package/src/auth/handleAuthCallback.ts +1 -11
  30. package/src/auth/logout.test.ts +1 -9
  31. package/src/auth/logout.ts +1 -18
  32. package/src/auth/oauth/oauthActions.test.ts +0 -136
  33. package/src/auth/oauth/oauthActions.ts +29 -78
  34. package/src/auth/oauth/oauthAuth.test.ts +0 -23
  35. package/src/auth/oauth/oauthAuth.ts +8 -18
  36. package/src/dashboard/messageBus/bus.test.ts +39 -0
  37. package/src/dashboard/messageBus/bus.ts +32 -22
  38. package/src/dashboard/messageBus/topics.test.ts +36 -2
  39. package/src/dashboard/messageBus/topics.ts +51 -16
  40. package/src/installations/installations.test-d.ts +11 -0
  41. package/src/installations/installations.test.ts +6 -4
  42. package/src/installations/installations.ts +13 -2
  43. package/dist/authStore-BGoEGxOq.d.ts.map +0 -1
  44. package/dist/clientStore-QDrJNTM4.js.map +0 -1
  45. package/dist/topicStore-Ct7IFNtY.js.map +0 -1
  46. package/dist/topicStore-DXvuH9h4.d.ts.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/sdk",
3
- "version": "3.3.0-rc.0",
3
+ "version": "3.3.0",
4
4
  "private": false,
5
5
  "description": "Sanity SDK",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@sanity/bifur-client": "^1.0.0",
48
- "@sanity/client": "^8.6.1",
48
+ "@sanity/client": "^8.6.2",
49
49
  "@sanity/comlink": "^4.0.3",
50
50
  "@sanity/diff-match-patch": "^3.2.0",
51
51
  "@sanity/diff-patch": "^6.0.0",
@@ -55,7 +55,7 @@
55
55
  "@sanity/message-protocol": "^0.24.0",
56
56
  "@sanity/mutate": "^0.18.2",
57
57
  "@sanity/telemetry": "^1.1.0",
58
- "@sanity/types": "^6.13.2",
58
+ "@sanity/types": "^6.14.1",
59
59
  "groq": "^6.13.2",
60
60
  "groq-js": "^2.0.0",
61
61
  "reselect": "^5.1.1",
@@ -70,14 +70,14 @@
70
70
  "@repo/tsconfig": "0.0.1",
71
71
  "@sanity/browserslist-config": "^1.0.5",
72
72
  "@sanity/pkg-utils": "^13.0.0",
73
- "@types/node": "^24.13.4",
74
- "@vitest/coverage-v8": "^5.0.0",
73
+ "@types/node": "^24.13.5",
74
+ "@vitest/coverage-v8": "^5.0.1",
75
75
  "eslint": "^10.10.0",
76
- "oxfmt": "^0.67.0",
76
+ "oxfmt": "^0.68.0",
77
77
  "rolldown": "^1.2.8",
78
78
  "typescript": "^6.0.3",
79
79
  "vite": "^8.3.0",
80
- "vitest": "^5.0.0"
80
+ "vitest": "^5.0.1"
81
81
  },
82
82
  "browserslist": "extends @sanity/browserslist-config",
83
83
  "scripts": {
@@ -1,3 +1,4 @@
1
+ export {getApplicationOrigin} from '../applications/applicationOrigin'
1
2
  export {isStudioConfig} from '../auth/authMode'
2
3
  export {
3
4
  type ApiErrorBody,
@@ -17,6 +18,7 @@ export {
17
18
  isDashboardEnvironment,
18
19
  requireDashboardMessageBus,
19
20
  } from '../dashboard/messageBus/store'
21
+ export type {ApplicationStatus, ApplicationStatusUpdate} from '../dashboard/messageBus/topics'
20
22
  export {getTopicState, resolveTopic} from '../dashboard/messageBus/topicStore'
21
23
  export {PREVIEW_PROJECTION} from '../preview/previewConstants'
22
24
  export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
@@ -0,0 +1,51 @@
1
+ import {afterEach, describe, expect, it, vi} from 'vitest'
2
+
3
+ import {getApplicationOrigin} from './applicationOrigin'
4
+ import {type ApplicationBase} from './applications'
5
+
6
+ const application: ApplicationBase = {
7
+ id: 'application-1',
8
+ type: 'coreApp',
9
+ title: 'Inbox',
10
+ name: 'inbox',
11
+ reference: 'sanity/inbox',
12
+ icon: null,
13
+ isSingleton: true,
14
+ visibility: 'default',
15
+ slug: 'inbox',
16
+ externalUrl: null,
17
+ organizationId: 'organization-1',
18
+ createdAt: '2026-01-01T00:00:00.000Z',
19
+ updatedAt: '2026-01-02T00:00:00.000Z',
20
+ }
21
+
22
+ describe('getApplicationOrigin', () => {
23
+ afterEach(() => vi.unstubAllGlobals())
24
+
25
+ it('derives singleton and organization origins from the slug', () => {
26
+ expect(getApplicationOrigin(application)).toBe('https://inbox-apps-organization-1.sanity.run')
27
+ expect(getApplicationOrigin({...application, isSingleton: false, slug: 'canvas'})).toBe(
28
+ 'https://canvas.sanity.studio',
29
+ )
30
+ })
31
+
32
+ it('uses the staging domains when the host sets the runtime flag', () => {
33
+ vi.stubGlobal('__SANITY_STAGING__', true)
34
+ expect(getApplicationOrigin(application)).toBe(
35
+ 'https://inbox-apps-organization-1.run.sanity.work',
36
+ )
37
+ expect(getApplicationOrigin({...application, isSingleton: false})).toBe(
38
+ 'https://inbox.studio.sanity.work',
39
+ )
40
+ })
41
+
42
+ it('prefers the external URL origin and returns null without slug or URL', () => {
43
+ expect(
44
+ getApplicationOrigin({
45
+ ...application,
46
+ externalUrl: 'https://apps.example.com/external/index.html',
47
+ }),
48
+ ).toBe('https://apps.example.com')
49
+ expect(getApplicationOrigin({...application, slug: null})).toBeNull()
50
+ })
51
+ })
@@ -0,0 +1,26 @@
1
+ import {type ApplicationBase} from './applications'
2
+
3
+ type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
4
+
5
+ /**
6
+ * The origin an application is served from, or `null` when it has neither an external URL nor
7
+ * a slug to derive one from. Callers treat `null` as "nothing to load" rather than failing the
8
+ * whole list on one bad record.
9
+ *
10
+ * @internal
11
+ */
12
+ export function getApplicationOrigin(application: ApplicationBase): string | null {
13
+ if (application.externalUrl !== null) return new URL(application.externalUrl).origin
14
+ if (application.slug === null) return null
15
+
16
+ // Read at runtime, not via a bundler define: a remote is built once and runs in whichever
17
+ // host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
18
+ const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
19
+ if (application.isSingleton) {
20
+ const domain = staging ? 'run.sanity.work' : 'sanity.run'
21
+ return `https://${application.slug}-apps-${application.organizationId}.${domain}`
22
+ }
23
+
24
+ const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
25
+ return `https://${application.slug}.${domain}`
26
+ }
@@ -339,24 +339,6 @@ 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
-
360
342
  it('logs auth initialized in logged out state when no token available', () => {
361
343
  instance = createSanityInstance({
362
344
  projectId: 'p',
@@ -191,10 +191,7 @@ export const authStore = defineStore<AuthStoreState>({
191
191
  storageKey: result.storageKey,
192
192
  storageArea: result.storageArea,
193
193
  authMethod: result.authMethod,
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,
194
+ oauth: authConfig.oauth,
198
195
  },
199
196
  }
200
197
  },
@@ -5,7 +5,6 @@ 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'
9
8
  import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
10
9
  import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
11
10
  import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
@@ -23,11 +22,6 @@ vi.mock('./utils', async (importOriginal) => {
23
22
  vi.mock('./subscribeToStateAndFetchCurrentUser')
24
23
  vi.mock('./subscribeToStorageEventsAndSetToken')
25
24
 
26
- vi.mock('./oauth/oauthActions', async (importOriginal) => {
27
- const original = await importOriginal<typeof import('./oauth/oauthActions')>()
28
- return {...original, handleOAuthCallback: vi.fn()}
29
- })
30
-
31
25
  // Mock logger to prevent actual logging during tests
32
26
  vi.mock('../utils/logger', async (importOriginal) => {
33
27
  const original = await importOriginal<typeof import('../utils/logger')>()
@@ -283,31 +277,4 @@ describe('handleCallback', () => {
283
277
  // Verify logger was called
284
278
  expect(createLogger).toHaveBeenCalled()
285
279
  })
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
- })
313
280
  })
@@ -3,7 +3,6 @@ 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'
7
6
  import {
8
7
  createLoggedInAuthState,
9
8
  getAuthCode,
@@ -13,10 +12,6 @@ import {
13
12
  } from './utils'
14
13
 
15
14
  /**
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
- *
20
15
  * @public
21
16
  */
22
17
  export const handleAuthCallback = bindActionGlobally(
@@ -24,7 +19,7 @@ export const handleAuthCallback = bindActionGlobally(
24
19
  async ({state, instance}, locationHref: string = getDefaultLocation()) => {
25
20
  const logger = getAuthLogger(instance)
26
21
 
27
- const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey, oauth} =
22
+ const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
28
23
  state.get().options
29
24
 
30
25
  // If a token is provided, no need to handle callback
@@ -33,11 +28,6 @@ export const handleAuthCallback = bindActionGlobally(
33
28
  return false
34
29
  }
35
30
 
36
- if (oauth) {
37
- logger.debug('OAuth mode - delegating to handleOAuthCallback')
38
- return handleOAuthCallback(instance, locationHref)
39
- }
40
-
41
31
  // Don't handle the callback if already in flight.
42
32
  const {authState} = state.get()
43
33
  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('revokes OAuth tokens instead of calling /auth/logout in OAuth mode', async () => {
172
+ it('clears the OAuth tokens from the store on logout', async () => {
173
173
  const oauthTokens: OAuthTokens = {
174
174
  accessToken: 'oauth-access',
175
175
  tokenType: 'bearer',
@@ -204,16 +204,8 @@ 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})
210
207
  await logout(instance)
211
- await first
212
208
 
213
- expect(mockRequest).toHaveBeenCalledTimes(1)
214
- expect(mockRequest).toHaveBeenCalledWith(
215
- expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
216
- )
217
209
  expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
218
210
  expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
219
211
  expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
@@ -3,22 +3,14 @@ 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'
7
6
 
8
7
  /**
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
- *
15
8
  * @public
16
9
  */
17
10
  export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
18
11
  const logger = getAuthLogger(instance)
19
12
 
20
- const {clientFactory, apiHost, providedToken, storageArea, storageKey, oauth} =
21
- state.get().options
13
+ const {clientFactory, apiHost, providedToken, storageArea, storageKey} = state.get().options
22
14
 
23
15
  // If a token is statically provided, logout does nothing
24
16
  if (providedToken) {
@@ -33,15 +25,6 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
33
25
  logger.debug('Skipping logout - already in progress')
34
26
  return
35
27
  }
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
- }
45
28
  const token = authState.type === AuthStateType.LOGGED_IN && authState.token
46
29
 
47
30
  try {
@@ -9,7 +9,6 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
9
9
  import {
10
10
  getOAuthTokensState,
11
11
  handleOAuthCallback,
12
- OAUTH_RETURN_TO_KEY,
13
12
  OAUTH_STATE_KEY,
14
13
  OAUTH_VERIFIER_KEY,
15
14
  refreshOAuthTokens,
@@ -157,28 +156,6 @@ describe('startOAuthAuthorization', () => {
157
156
  expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
158
157
  })
159
158
 
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
-
182
159
  it('throws when OAuth is not configured', async () => {
183
160
  setup({withOAuthConfig: false})
184
161
  await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
@@ -226,75 +203,6 @@ describe('handleOAuthCallback', () => {
226
203
  })
227
204
  })
228
205
 
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
-
298
206
  it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
299
207
  const pending = deferred<typeof tokenResponse>()
300
208
  const request = vi.fn().mockReturnValue(pending.promise)
@@ -313,31 +221,6 @@ describe('handleOAuthCallback', () => {
313
221
  expect(await first).toBe('https://app.example.com/callback')
314
222
  })
315
223
 
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
-
341
224
  it('surfaces an ?error= callback as ERROR without exchanging', async () => {
342
225
  const {request} = setup()
343
226
 
@@ -351,25 +234,6 @@ describe('handleOAuthCallback', () => {
351
234
  expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
352
235
  })
353
236
 
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
-
373
237
  it('surfaces an ?error= callback without a description', async () => {
374
238
  setup()
375
239
  await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
@@ -17,25 +17,6 @@ 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
-
39
20
  interface TokenEndpointResponse {
40
21
  access_token: string
41
22
  token_type: string
@@ -118,17 +99,6 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
118
99
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
119
100
  session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
120
101
  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
- }
132
102
 
133
103
  const authorizeUrl = new URL(
134
104
  '/v1/auth/oauth/authorize',
@@ -153,14 +123,9 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
153
123
  * responses, exchanges the authorization `code` for tokens, persists them, and
154
124
  * transitions to `LOGGED_IN`.
155
125
  *
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.
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).
164
129
  *
165
130
  * @public
166
131
  */
@@ -187,29 +152,22 @@ export const handleOAuthCallback = bindActionGlobally(
187
152
 
188
153
  const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
189
154
 
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
155
+ const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
156
+ for (const param of ['code', 'state', 'error', 'error_description']) {
157
+ cleanedUrlObj.searchParams.delete(param)
206
158
  }
159
+ const cleanedUrl = cleanedUrlObj.toString()
207
160
 
208
161
  if (error) {
209
- return rejectCallback(
210
- 'oauthCallbackError',
211
- errorDescription ? `${error}: ${errorDescription}` : error,
212
- )
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
213
171
  }
214
172
 
215
173
  if (!code) {
@@ -219,12 +177,22 @@ export const handleOAuthCallback = bindActionGlobally(
219
177
 
220
178
  const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
221
179
  if (!returnedState || !storedState || returnedState !== storedState) {
222
- return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
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
223
186
  }
224
187
 
225
188
  const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
226
189
  if (!codeVerifier) {
227
- return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
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
228
196
  }
229
197
 
230
198
  logger.info('Exchanging OAuth code for tokens')
@@ -252,7 +220,6 @@ export const handleOAuthCallback = bindActionGlobally(
252
220
 
253
221
  const tokens = toOAuthTokens(response)
254
222
  options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
255
- const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
256
223
  clearOAuthArtifacts(session)
257
224
 
258
225
  logger.info('OAuth tokens obtained, user logged in')
@@ -260,7 +227,7 @@ export const handleOAuthCallback = bindActionGlobally(
260
227
  authState: createLoggedInAuthState(tokens.accessToken, null),
261
228
  oauthTokens: tokens,
262
229
  })
263
- return returnTo ?? cleanedUrl
230
+ return cleanedUrl
264
231
  } catch (exchangeError) {
265
232
  logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
266
233
  clearOAuthArtifacts(session)
@@ -419,20 +386,4 @@ export const getOAuthTokensState = bindActionGlobally(
419
386
  function clearOAuthArtifacts(session: Storage | undefined): void {
420
387
  session?.removeItem(OAUTH_VERIFIER_KEY)
421
388
  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
- }
438
389
  }