@sanity/sdk 3.1.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.
Files changed (86) hide show
  1. package/dist/_exports/_internal.d.ts +52 -21
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +10 -9
  4. package/dist/_exports/_internal.js.map +1 -1
  5. package/dist/_exports/agent.d.ts +7 -7
  6. package/dist/_exports/agent.d.ts.map +1 -1
  7. package/dist/_exports/agent.js +1 -1
  8. package/dist/_exports/comlink.d.ts +9 -9
  9. package/dist/_exports/comlink.d.ts.map +1 -1
  10. package/dist/_exports/comlink.js +3 -2
  11. package/dist/_exports/comlink.js.map +1 -1
  12. package/dist/_exports/dashboard.d.ts +2 -0
  13. package/dist/_exports/dashboard.js +2 -0
  14. package/dist/{authStore-C-OEkGaM.d.ts → authStore-BGoEGxOq.d.ts} +531 -63
  15. package/dist/authStore-BGoEGxOq.d.ts.map +1 -0
  16. package/dist/{clientStore-gUALQ0qW.js → clientStore-QDrJNTM4.js} +128 -7
  17. package/dist/clientStore-QDrJNTM4.js.map +1 -0
  18. package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
  19. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  20. package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
  21. package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
  22. package/dist/defineProperty-Bho9Ngsi.js +31 -0
  23. package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
  24. package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.js +371 -54
  27. package/dist/index.js.map +1 -1
  28. package/dist/object-CxXlSVg7.js +83 -0
  29. package/dist/object-CxXlSVg7.js.map +1 -0
  30. package/dist/topicStore-Ct7IFNtY.js +565 -0
  31. package/dist/topicStore-Ct7IFNtY.js.map +1 -0
  32. package/dist/topicStore-DXvuH9h4.d.ts +435 -0
  33. package/dist/topicStore-DXvuH9h4.d.ts.map +1 -0
  34. package/dist/{version-C90rNWaX.js → version-CIhX5ppJ.js} +6 -6
  35. package/dist/{version-C90rNWaX.js.map → version-CIhX5ppJ.js.map} +1 -1
  36. package/package.json +20 -19
  37. package/src/_exports/_internal.ts +12 -0
  38. package/src/_exports/dashboard.ts +36 -0
  39. package/src/_exports/index.ts +17 -1
  40. package/src/applications/applications.test-d.ts +28 -4
  41. package/src/applications/applications.ts +39 -14
  42. package/src/auth/authMode.test.ts +24 -0
  43. package/src/auth/authMode.ts +8 -3
  44. package/src/auth/authStore.test.ts +18 -0
  45. package/src/auth/authStore.ts +15 -0
  46. package/src/auth/authStrategy.ts +2 -0
  47. package/src/auth/handleAuthCallback.test.ts +33 -0
  48. package/src/auth/handleAuthCallback.ts +11 -1
  49. package/src/auth/logout.test.ts +53 -0
  50. package/src/auth/logout.ts +19 -1
  51. package/src/auth/oauth/oauthActions.test.ts +593 -0
  52. package/src/auth/oauth/oauthActions.ts +438 -0
  53. package/src/auth/oauth/oauthAuth.test.ts +229 -0
  54. package/src/auth/oauth/oauthAuth.ts +178 -0
  55. package/src/auth/oauth/pkce.test.ts +44 -0
  56. package/src/auth/oauth/pkce.ts +60 -0
  57. package/src/auth/oauth/types.ts +12 -0
  58. package/src/config/authConfig.ts +18 -0
  59. package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
  60. package/src/dashboard/messageBus/bus.test.ts +1033 -0
  61. package/src/dashboard/messageBus/bus.ts +1246 -0
  62. package/src/dashboard/messageBus/store.test.ts +142 -0
  63. package/src/dashboard/messageBus/store.ts +74 -0
  64. package/src/dashboard/messageBus/topicStore.test.ts +172 -0
  65. package/src/dashboard/messageBus/topicStore.ts +184 -0
  66. package/src/dashboard/messageBus/topics.test.ts +71 -0
  67. package/src/dashboard/messageBus/topics.ts +260 -0
  68. package/src/document/actions.ts +3 -3
  69. package/src/document/applyDocumentActions.ts +3 -3
  70. package/src/document/documentStore.ts +12 -12
  71. package/src/document/processMutations.ts +58 -22
  72. package/src/installations/installations.test-d.ts +3 -3
  73. package/src/installations/installations.ts +2 -19
  74. package/src/projection/getProjectionState.ts +2 -2
  75. package/src/projection/resolveProjection.ts +2 -2
  76. package/src/query/queryStore.ts +3 -3
  77. package/src/typegen/defineProjection.test.ts +17 -0
  78. package/src/typegen/defineProjection.ts +19 -0
  79. package/src/typegen/groqCompat.ts +122 -0
  80. package/src/typegen/resolve.ts +37 -0
  81. package/src/utils/createGroqSearchFilter.test.ts +8 -0
  82. package/src/utils/createGroqSearchFilter.ts +6 -3
  83. package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
  84. package/dist/clientStore-gUALQ0qW.js.map +0 -1
  85. package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
  86. package/dist/object-BwynGPRZ.js.map +0 -1
@@ -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 {
@@ -57,6 +74,7 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
57
74
  logger.info('User logged out, clearing stored tokens')
58
75
  state.set('logoutSuccess', {
59
76
  authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
77
+ oauthTokens: undefined,
60
78
  })
61
79
  storageArea?.removeItem(storageKey)
62
80
  storageArea?.removeItem(`${storageKey}_last_refresh`)
@@ -0,0 +1,593 @@
1
+ import {type ClientConfig, ClientError, type SanityClient} from '@sanity/client'
2
+ import {NEVER} from 'rxjs'
3
+ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
4
+
5
+ import {createSanityInstance, type SanityInstance} from '../../store/createSanityInstance'
6
+ import {AuthStateType} from '../authStateType'
7
+ import {getAuthState} from '../authStore'
8
+ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCurrentUser'
9
+ import {
10
+ getOAuthTokensState,
11
+ handleOAuthCallback,
12
+ OAUTH_RETURN_TO_KEY,
13
+ OAUTH_STATE_KEY,
14
+ OAUTH_VERIFIER_KEY,
15
+ refreshOAuthTokens,
16
+ revokeOAuthTokens,
17
+ serializeTokens,
18
+ startOAuthAuthorization,
19
+ } from './oauthActions'
20
+ import {deserializeTokens, OAUTH_TOKENS_KEY} from './oauthAuth'
21
+ import {type OAuthTokens} from './types'
22
+
23
+ const readStored = (storage: Storage) => deserializeTokens(storage.getItem(OAUTH_TOKENS_KEY))
24
+
25
+ vi.mock('../subscribeToStateAndFetchCurrentUser')
26
+ vi.mock('../../utils/logger', async (importOriginal) => {
27
+ const original = await importOriginal<typeof import('../../utils/logger')>()
28
+ return {
29
+ ...original,
30
+ createLogger: vi.fn(() => ({
31
+ info: vi.fn(),
32
+ debug: vi.fn(),
33
+ warn: vi.fn(),
34
+ error: vi.fn(),
35
+ trace: vi.fn(),
36
+ })),
37
+ }
38
+ })
39
+
40
+ const oauthConfig = {
41
+ clientId: 'client-abc',
42
+ redirectUri: 'https://app.example.com/callback',
43
+ organizationId: 'org123',
44
+ }
45
+
46
+ const seededTokens: OAuthTokens = {
47
+ accessToken: 'stored-access',
48
+ tokenType: 'bearer',
49
+ expiresIn: 3600,
50
+ expiresAt: new Date('2030-01-01T00:00:00.000Z'),
51
+ refreshToken: 'stored-refresh',
52
+ }
53
+
54
+ const tokenResponse = {
55
+ access_token: 'new-access',
56
+ token_type: 'bearer',
57
+ expires_in: 3600,
58
+ refresh_token: 'new-refresh',
59
+ }
60
+
61
+ function createMemoryStorage(seed?: Record<string, string>): Storage {
62
+ const map = new Map<string, string>(Object.entries(seed ?? {}))
63
+ return {
64
+ getItem: (k) => map.get(k) ?? null,
65
+ setItem: (k, v) => void map.set(k, v),
66
+ removeItem: (k) => void map.delete(k),
67
+ clear: () => map.clear(),
68
+ key: (i) => [...map.keys()][i] ?? null,
69
+ get length() {
70
+ return map.size
71
+ },
72
+ } as Storage
73
+ }
74
+
75
+ function deferred<T>(): {
76
+ promise: Promise<T>
77
+ resolve: (v: T) => void
78
+ reject: (e: unknown) => void
79
+ } {
80
+ let resolve!: (v: T) => void
81
+ let reject!: (e: unknown) => void
82
+ const promise = new Promise<T>((res, rej) => {
83
+ resolve = res
84
+ reject = rej
85
+ })
86
+ return {promise, resolve, reject}
87
+ }
88
+
89
+ let instance: SanityInstance | undefined
90
+
91
+ interface SetupOptions {
92
+ request?: ReturnType<typeof vi.fn>
93
+ storageSeed?: Record<string, string>
94
+ sessionSeed?: Record<string, string>
95
+ initialLocationHref?: string
96
+ withOAuthConfig?: boolean
97
+ apiHost?: string
98
+ }
99
+
100
+ function setup(options: SetupOptions = {}) {
101
+ const request = options.request ?? vi.fn().mockResolvedValue(tokenResponse)
102
+ const clientFactory = vi.fn((_config: ClientConfig) => ({request}) as unknown as SanityClient)
103
+ const storageArea = createMemoryStorage(options.storageSeed)
104
+ const session = createMemoryStorage(options.sessionSeed)
105
+ vi.stubGlobal('sessionStorage', session)
106
+
107
+ instance = createSanityInstance({
108
+ projectId: 'p',
109
+ dataset: 'd',
110
+ auth: {
111
+ clientFactory,
112
+ storageArea,
113
+ initialLocationHref: options.initialLocationHref ?? 'https://app.example.com/',
114
+ ...(options.apiHost && {apiHost: options.apiHost}),
115
+ ...(options.withOAuthConfig === false ? {} : {oauth: oauthConfig}),
116
+ },
117
+ })
118
+
119
+ return {request, clientFactory, storageArea, session}
120
+ }
121
+
122
+ function parseBody(body: unknown): URLSearchParams {
123
+ return new URLSearchParams(body as string)
124
+ }
125
+
126
+ beforeEach(() => {
127
+ vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(NEVER.subscribe())
128
+ })
129
+
130
+ afterEach(() => {
131
+ instance?.dispose()
132
+ instance = undefined
133
+ vi.unstubAllGlobals()
134
+ vi.clearAllMocks()
135
+ })
136
+
137
+ describe('startOAuthAuthorization', () => {
138
+ it('persists verifier/state and navigates to the authorize URL', async () => {
139
+ const assign = vi.fn()
140
+ vi.stubGlobal('window', {location: {assign}})
141
+ const {session} = setup({initialLocationHref: 'https://app.example.com/'})
142
+
143
+ await startOAuthAuthorization(instance!)
144
+
145
+ expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeTruthy()
146
+ expect(session.getItem(OAUTH_STATE_KEY)).toBeTruthy()
147
+
148
+ expect(assign).toHaveBeenCalledTimes(1)
149
+ const url = new URL(assign.mock.calls[0][0])
150
+ expect(url.pathname).toBe('/v1/auth/oauth/authorize')
151
+ expect(url.searchParams.get('response_type')).toBe('code')
152
+ expect(url.searchParams.get('client_id')).toBe('client-abc')
153
+ expect(url.searchParams.get('redirect_uri')).toBe(oauthConfig.redirectUri)
154
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256')
155
+ expect(url.searchParams.get('code_challenge')).toBeTruthy()
156
+ expect(url.searchParams.get('state')).toBe(session.getItem(OAUTH_STATE_KEY))
157
+ expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
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
+
182
+ it('throws when OAuth is not configured', async () => {
183
+ setup({withOAuthConfig: false})
184
+ await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
185
+ })
186
+ })
187
+
188
+ describe('handleOAuthCallback', () => {
189
+ const callbackHref = 'https://app.example.com/callback?code=auth-code&state=state-xyz'
190
+
191
+ it('exchanges the code, persists tokens, clears artifacts and logs in', async () => {
192
+ const {request, storageArea, session, clientFactory} = setup({
193
+ apiHost: 'https://api.sanity.work',
194
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
195
+ })
196
+
197
+ const result = await handleOAuthCallback(instance!, callbackHref)
198
+
199
+ expect(result).toBe('https://app.example.com/callback')
200
+ expect(clientFactory).toHaveBeenCalledWith(
201
+ expect.objectContaining({apiHost: 'https://api.sanity.work'}),
202
+ )
203
+ expect(request).toHaveBeenCalledTimes(1)
204
+ const call = request.mock.calls[0][0]
205
+ expect(call).toMatchObject({
206
+ method: 'POST',
207
+ url: '/auth/oauth/token',
208
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
209
+ tag: 'oauth.token',
210
+ })
211
+ const body = parseBody(call.body)
212
+ expect(body.get('grant_type')).toBe('authorization_code')
213
+ expect(body.get('code')).toBe('auth-code')
214
+ expect(body.get('code_verifier')).toBe('verifier-1')
215
+ expect(body.get('redirect_uri')).toBe(oauthConfig.redirectUri)
216
+ expect(body.get('client_id')).toBe('client-abc')
217
+ expect(body.get('resource')).toBe('urn:io.sanity:organization:org123')
218
+
219
+ expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
220
+ expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeNull()
221
+ expect(session.getItem(OAUTH_STATE_KEY)).toBeNull()
222
+
223
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({
224
+ type: AuthStateType.LOGGED_IN,
225
+ token: 'new-access',
226
+ })
227
+ })
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
+
298
+ it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
299
+ const pending = deferred<typeof tokenResponse>()
300
+ const request = vi.fn().mockReturnValue(pending.promise)
301
+ setup({
302
+ request,
303
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
304
+ })
305
+
306
+ const first = handleOAuthCallback(instance!, callbackHref)
307
+ const second = await handleOAuthCallback(instance!, callbackHref)
308
+
309
+ expect(second).toBe(false)
310
+ expect(request).toHaveBeenCalledTimes(1)
311
+
312
+ pending.resolve(tokenResponse)
313
+ expect(await first).toBe('https://app.example.com/callback')
314
+ })
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
+
341
+ it('surfaces an ?error= callback as ERROR without exchanging', async () => {
342
+ const {request} = setup()
343
+
344
+ const result = await handleOAuthCallback(
345
+ instance!,
346
+ 'https://app.example.com/callback?error=access_denied&error_description=denied',
347
+ )
348
+
349
+ expect(result).toBe('https://app.example.com/callback')
350
+ expect(request).not.toHaveBeenCalled()
351
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
352
+ })
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
+
373
+ it('surfaces an ?error= callback without a description', async () => {
374
+ setup()
375
+ await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
376
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
377
+ })
378
+
379
+ it('rejects a state mismatch as ERROR without exchanging', async () => {
380
+ const {request} = setup({
381
+ sessionSeed: {[OAUTH_STATE_KEY]: 'different', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
382
+ })
383
+
384
+ const result = await handleOAuthCallback(instance!, callbackHref)
385
+
386
+ expect(result).toBe('https://app.example.com/callback')
387
+ expect(request).not.toHaveBeenCalled()
388
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
389
+ })
390
+
391
+ it('errors when the code verifier is missing', async () => {
392
+ const {request} = setup({sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz'}})
393
+
394
+ const result = await handleOAuthCallback(instance!, callbackHref)
395
+
396
+ expect(result).toBe('https://app.example.com/callback')
397
+ expect(request).not.toHaveBeenCalled()
398
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
399
+ })
400
+
401
+ it('returns false when there is no code in the URL', async () => {
402
+ const {request} = setup()
403
+ const result = await handleOAuthCallback(instance!, 'https://app.example.com/callback')
404
+ expect(result).toBe(false)
405
+ expect(request).not.toHaveBeenCalled()
406
+ })
407
+
408
+ it('sets ERROR when the token exchange fails', async () => {
409
+ const request = vi.fn().mockRejectedValue(new Error('boom'))
410
+ const {session} = setup({
411
+ request,
412
+ sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
413
+ })
414
+
415
+ const result = await handleOAuthCallback(instance!, callbackHref)
416
+
417
+ expect(result).toBe('https://app.example.com/callback')
418
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
419
+ expect(session.getItem(OAUTH_VERIFIER_KEY)).toBeNull()
420
+ })
421
+ })
422
+
423
+ describe('refreshOAuthTokens', () => {
424
+ it('refreshes tokens and persists them', async () => {
425
+ const {request, storageArea} = setup({
426
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
427
+ })
428
+
429
+ const result = await refreshOAuthTokens(instance!)
430
+
431
+ expect(request).toHaveBeenCalledTimes(1)
432
+ const body = parseBody(request.mock.calls[0][0].body)
433
+ expect(body.get('grant_type')).toBe('refresh_token')
434
+ expect(body.get('refresh_token')).toBe('stored-refresh')
435
+ expect(body.get('client_id')).toBe('client-abc')
436
+ expect(body.get('resource')).toBe('urn:io.sanity:organization:org123')
437
+
438
+ expect(result).toMatchObject({accessToken: 'new-access', refreshToken: 'new-refresh'})
439
+ expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
440
+ })
441
+
442
+ it('shares a single in-flight request across concurrent callers', async () => {
443
+ const pending = deferred<typeof tokenResponse>()
444
+ const request = vi.fn().mockReturnValue(pending.promise)
445
+ setup({
446
+ request,
447
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
448
+ })
449
+
450
+ const a = refreshOAuthTokens(instance!)
451
+ const b = refreshOAuthTokens(instance!)
452
+
453
+ pending.resolve(tokenResponse)
454
+ const [ra, rb] = await Promise.all([a, b])
455
+
456
+ expect(request).toHaveBeenCalledTimes(1)
457
+ expect(ra).toBe(rb)
458
+ expect(ra).toMatchObject({accessToken: 'new-access'})
459
+ })
460
+
461
+ it('keeps the previous refresh token when the endpoint omits one', async () => {
462
+ const request = vi.fn().mockResolvedValue({
463
+ access_token: 'new-access',
464
+ token_type: 'bearer',
465
+ expires_in: 3600,
466
+ })
467
+ const {storageArea} = setup({
468
+ request,
469
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
470
+ })
471
+
472
+ await refreshOAuthTokens(instance!)
473
+
474
+ expect(readStored(storageArea)).toMatchObject({refreshToken: 'stored-refresh'})
475
+ })
476
+
477
+ it('logs out when there is no refresh token', async () => {
478
+ const noRefresh = {...seededTokens, refreshToken: undefined}
479
+ const {request, storageArea} = setup({
480
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(noRefresh)},
481
+ })
482
+
483
+ const result = await refreshOAuthTokens(instance!)
484
+
485
+ expect(result).toBeNull()
486
+ expect(request).not.toHaveBeenCalled()
487
+ expect(readStored(storageArea)).toBeNull()
488
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
489
+ })
490
+
491
+ it('clears tokens and logs out on an unrecoverable (4xx) refresh failure', async () => {
492
+ const invalidGrant = new ClientError({
493
+ statusCode: 400,
494
+ headers: {},
495
+ body: {error: 'invalid_grant'},
496
+ })
497
+ const request = vi.fn().mockRejectedValue(invalidGrant)
498
+ const {storageArea} = setup({
499
+ request,
500
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
501
+ })
502
+
503
+ await expect(refreshOAuthTokens(instance!)).rejects.toBe(invalidGrant)
504
+ expect(readStored(storageArea)).toBeNull()
505
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
506
+ })
507
+
508
+ it('keeps the session on a transient refresh failure', async () => {
509
+ const networkError = new Error('network down')
510
+ const request = vi.fn().mockRejectedValue(networkError)
511
+ const {storageArea} = setup({
512
+ request,
513
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
514
+ })
515
+
516
+ await expect(refreshOAuthTokens(instance!)).rejects.toBe(networkError)
517
+ // Tokens are preserved and the user stays logged in for a retry.
518
+ expect(readStored(storageArea)).toMatchObject({accessToken: 'stored-access'})
519
+ expect(getOAuthTokensState(instance!).getCurrent()).toMatchObject({
520
+ accessToken: 'stored-access',
521
+ })
522
+ })
523
+
524
+ it('keeps the session on a 429 rate-limit refresh failure', async () => {
525
+ const rateLimited = new ClientError({
526
+ statusCode: 429,
527
+ headers: {},
528
+ body: {error: 'rate_limited'},
529
+ })
530
+ const request = vi.fn().mockRejectedValue(rateLimited)
531
+ const {storageArea} = setup({
532
+ request,
533
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
534
+ })
535
+
536
+ await expect(refreshOAuthTokens(instance!)).rejects.toBe(rateLimited)
537
+ expect(readStored(storageArea)).toMatchObject({accessToken: 'stored-access'})
538
+ })
539
+ })
540
+
541
+ describe('revokeOAuthTokens', () => {
542
+ it('revokes with token and client_id, then clears local state', async () => {
543
+ const {request, storageArea} = setup({
544
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
545
+ })
546
+
547
+ await revokeOAuthTokens(instance!)
548
+
549
+ expect(request).toHaveBeenCalledTimes(1)
550
+ const call = request.mock.calls[0][0]
551
+ expect(call).toMatchObject({method: 'POST', url: '/auth/oauth/revoke', tag: 'oauth.revoke'})
552
+ const body = parseBody(call.body)
553
+ expect(body.get('token')).toBe('stored-refresh')
554
+ expect(body.get('client_id')).toBe('client-abc')
555
+
556
+ expect(readStored(storageArea)).toBeNull()
557
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
558
+ })
559
+
560
+ it('clears local state even when the revoke request fails', async () => {
561
+ const request = vi.fn().mockRejectedValue(new Error('revoke failed'))
562
+ const {storageArea} = setup({
563
+ request,
564
+ storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
565
+ })
566
+
567
+ await expect(revokeOAuthTokens(instance!)).resolves.toBeUndefined()
568
+ expect(readStored(storageArea)).toBeNull()
569
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
570
+ })
571
+
572
+ it('clears state without calling the endpoint when there is no token', async () => {
573
+ const {request, storageArea} = setup()
574
+
575
+ await revokeOAuthTokens(instance!)
576
+
577
+ expect(request).not.toHaveBeenCalled()
578
+ expect(readStored(storageArea)).toBeNull()
579
+ expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
580
+ })
581
+ })
582
+
583
+ describe('getOAuthTokensState', () => {
584
+ it('exposes the current OAuth tokens', () => {
585
+ setup({storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)}})
586
+ expect(getOAuthTokensState(instance!).getCurrent()).toEqual(seededTokens)
587
+ })
588
+
589
+ it('returns null when there are no OAuth tokens', () => {
590
+ setup()
591
+ expect(getOAuthTokensState(instance!).getCurrent()).toBeNull()
592
+ })
593
+ })