@sanity/sdk 3.0.0 → 3.2.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 (132) hide show
  1. package/dist/_exports/_internal.d.ts +72 -19
  2. package/dist/_exports/_internal.d.ts.map +1 -1
  3. package/dist/_exports/_internal.js +130 -11
  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 +2 -2
  11. package/dist/_exports/dashboard.d.ts +2 -0
  12. package/dist/_exports/dashboard.js +2 -0
  13. package/dist/{authStore-C0o1NOxW.d.ts → authStore-BO2vch3I.d.ts} +516 -63
  14. package/dist/authStore-BO2vch3I.d.ts.map +1 -0
  15. package/dist/{authStore-CtE9bLHW.js → clientStore-BI4D8HhJ.js} +390 -15
  16. package/dist/clientStore-BI4D8HhJ.js.map +1 -0
  17. package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-BQIqyGLX.d.ts} +17 -2
  18. package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
  19. package/dist/{createStateSourceAction-BOKtqFNm.js → createStateSourceAction-DdAhEfnZ.js} +7 -3
  20. package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
  21. package/dist/defineProperty-Bho9Ngsi.js +31 -0
  22. package/dist/{getNodeState-CR3__B2D.js → getNodeState-B4nG0sjm.js} +2 -2
  23. package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +370 -90
  26. package/dist/index.js.map +1 -1
  27. package/dist/topicStore-Ct7IFNtY.js +565 -0
  28. package/dist/topicStore-Ct7IFNtY.js.map +1 -0
  29. package/dist/topicStore-D0WqhQGF.d.ts +435 -0
  30. package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
  31. package/dist/{queryStore-C7yMHPUI.js → version-BcRmCiYi.js} +48 -16
  32. package/dist/version-BcRmCiYi.js.map +1 -0
  33. package/package.json +19 -18
  34. package/src/_exports/_internal.ts +13 -0
  35. package/src/_exports/dashboard.ts +36 -0
  36. package/src/_exports/index.ts +17 -1
  37. package/src/access/checkPermissions.test.ts +1 -1
  38. package/src/access/checkPermissions.ts +1 -1
  39. package/src/applications/applications.test-d.ts +28 -4
  40. package/src/applications/applications.test.ts +4 -4
  41. package/src/applications/applications.ts +43 -18
  42. package/src/auth/authMode.test.ts +24 -0
  43. package/src/auth/authMode.ts +8 -3
  44. package/src/auth/authStore.ts +12 -0
  45. package/src/auth/authStrategy.ts +2 -0
  46. package/src/auth/handleAuthCallback.test.ts +2 -2
  47. package/src/auth/handleAuthCallback.ts +1 -1
  48. package/src/auth/logout.test.ts +46 -1
  49. package/src/auth/logout.ts +2 -1
  50. package/src/auth/oauth/oauthActions.test.ts +457 -0
  51. package/src/auth/oauth/oauthActions.ts +389 -0
  52. package/src/auth/oauth/oauthAuth.test.ts +206 -0
  53. package/src/auth/oauth/oauthAuth.ts +168 -0
  54. package/src/auth/oauth/pkce.test.ts +44 -0
  55. package/src/auth/oauth/pkce.ts +60 -0
  56. package/src/auth/oauth/types.ts +12 -0
  57. package/src/auth/refreshStampedToken.ts +1 -1
  58. package/src/auth/studioModeAuth.test.ts +1 -1
  59. package/src/auth/studioModeAuth.ts +1 -1
  60. package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
  61. package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
  62. package/src/comments/addonDatasetStore.test.ts +2 -2
  63. package/src/comments/addonDatasetStore.ts +2 -2
  64. package/src/comments/commentActions.ts +6 -5
  65. package/src/comments/commentsStore.ts +4 -4
  66. package/src/config/authConfig.ts +18 -0
  67. package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
  68. package/src/dashboard/messageBus/bus.test.ts +1033 -0
  69. package/src/dashboard/messageBus/bus.ts +1246 -0
  70. package/src/dashboard/messageBus/store.test.ts +142 -0
  71. package/src/dashboard/messageBus/store.ts +74 -0
  72. package/src/dashboard/messageBus/topicStore.test.ts +172 -0
  73. package/src/dashboard/messageBus/topicStore.ts +184 -0
  74. package/src/dashboard/messageBus/topics.test.ts +71 -0
  75. package/src/dashboard/messageBus/topics.ts +260 -0
  76. package/src/document/actions.ts +3 -3
  77. package/src/document/applyDocumentActions.ts +5 -9
  78. package/src/document/documentStore.concurrency.test.ts +2 -1
  79. package/src/document/documentStore.test.ts +19 -18
  80. package/src/document/documentStore.ts +17 -17
  81. package/src/document/permissions.ts +2 -1
  82. package/src/document/processActions.test.ts +2 -1
  83. package/src/document/processMutations.ts +61 -24
  84. package/src/document/reducers.ts +2 -2
  85. package/src/installations/installations.test-d.ts +3 -3
  86. package/src/installations/installations.test.ts +2 -2
  87. package/src/installations/installations.ts +4 -21
  88. package/src/organization/organization.test.ts +2 -2
  89. package/src/organization/organization.ts +1 -1
  90. package/src/organizations/organizations.test.ts +2 -2
  91. package/src/organizations/organizations.ts +1 -1
  92. package/src/presence/presenceStore.test.ts +15 -22
  93. package/src/presence/presenceStore.ts +3 -2
  94. package/src/project/project.test.ts +3 -3
  95. package/src/project/project.ts +1 -1
  96. package/src/projection/getProjectionState.test.ts +6 -4
  97. package/src/projection/getProjectionState.ts +4 -4
  98. package/src/projection/resolveProjection.ts +2 -2
  99. package/src/projects/projects.test.ts +2 -2
  100. package/src/projects/projects.ts +1 -1
  101. package/src/query/queryStore.ts +6 -6
  102. package/src/store/createSanityInstance.test.ts +1 -1
  103. package/src/store/createSanityInstance.ts +4 -4
  104. package/src/store/createStoreInstance.test.ts +0 -5
  105. package/src/telemetry/telemetryManager.test.ts +1 -1
  106. package/src/telemetry/telemetryManager.ts +2 -2
  107. package/src/typegen/defineProjection.test.ts +17 -0
  108. package/src/typegen/defineProjection.ts +19 -0
  109. package/src/typegen/groqCompat.ts +122 -0
  110. package/src/typegen/resolve.ts +37 -0
  111. package/src/users/usersStore.test.ts +1 -1
  112. package/src/users/usersStore.ts +5 -5
  113. package/src/utils/createGroqSearchFilter.test.ts +8 -0
  114. package/src/utils/createGroqSearchFilter.ts +6 -3
  115. package/src/utils/ids.test.ts +40 -15
  116. package/src/utils/ids.ts +47 -2
  117. package/dist/authStore-C0o1NOxW.d.ts.map +0 -1
  118. package/dist/authStore-CtE9bLHW.js.map +0 -1
  119. package/dist/clientStore-BtE2-q2i.js +0 -90
  120. package/dist/clientStore-BtE2-q2i.js.map +0 -1
  121. package/dist/createSanityInstance-DUI1bGIg.d.ts.map +0 -1
  122. package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
  123. package/dist/getEnv-D5RGkQLu.js +0 -8
  124. package/dist/getEnv-D5RGkQLu.js.map +0 -1
  125. package/dist/logger-DAtJb5RQ.js +0 -173
  126. package/dist/logger-DAtJb5RQ.js.map +0 -1
  127. package/dist/queryStore-C7yMHPUI.js.map +0 -1
  128. package/dist/rolldown-runtime-vyAXikos.js +0 -9
  129. package/dist/telemetryManager-UIEiMtC0.js +0 -127
  130. package/dist/telemetryManager-UIEiMtC0.js.map +0 -1
  131. package/dist/version-D8RP_Q59.js +0 -9
  132. package/dist/version-D8RP_Q59.js.map +0 -1
@@ -0,0 +1,389 @@
1
+ import {ClientError, type SanityClient} from '@sanity/client'
2
+
3
+ import {bindActionGlobally} from '../../store/createActionBinder'
4
+ import {createStateSourceAction} from '../../store/createStateSourceAction'
5
+ import {type StoreContext} from '../../store/defineStore'
6
+ import {DEFAULT_BASE, REQUEST_TAG_PREFIX} from '../authConstants'
7
+ import {getAuthLogger} from '../authLogger'
8
+ import {AuthStateType} from '../authStateType'
9
+ import {authStore, type AuthStoreState} from '../authStore'
10
+ import {createLoggedInAuthState, getDefaultLocation} from '../utils'
11
+ import {generateCodeChallenge, generateCodeVerifier, generateState} from './pkce'
12
+ import {type OAuthTokens} from './types'
13
+
14
+ /** sessionStorage key for the PKCE `code_verifier`. */
15
+ export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
16
+
17
+ /** sessionStorage key for the CSRF `state` value. */
18
+ export const OAUTH_STATE_KEY = '__sanity_oauth_state'
19
+
20
+ interface TokenEndpointResponse {
21
+ access_token: string
22
+ token_type: string
23
+ expires_in: number
24
+ refresh_token?: string
25
+ }
26
+
27
+ /** Builds the RFC 8707 resource indicator for an organisation. */
28
+ function getResourceIndicator(organizationId: string): string {
29
+ return `urn:io.sanity:organization:${organizationId}`
30
+ }
31
+
32
+ /**
33
+ * Serialises tokens for storage, converting `expiresAt` to an ISO string.
34
+ *
35
+ * @internal
36
+ */
37
+ export function serializeTokens(tokens: OAuthTokens): string {
38
+ return JSON.stringify({
39
+ accessToken: tokens.accessToken,
40
+ tokenType: tokens.tokenType,
41
+ expiresIn: tokens.expiresIn,
42
+ expiresAt: tokens.expiresAt.toISOString(),
43
+ ...(tokens.refreshToken !== undefined && {refreshToken: tokens.refreshToken}),
44
+ })
45
+ }
46
+
47
+ type AuthOptions = AuthStoreState['options']
48
+ type ConfiguredOAuthOptions = AuthOptions & {oauth: NonNullable<AuthOptions['oauth']>}
49
+
50
+ /**
51
+ * Reads the store options, throwing when the instance was not configured for
52
+ * OAuth.
53
+ */
54
+ function getOAuthOptions(state: AuthStoreState): ConfiguredOAuthOptions {
55
+ const {options} = state
56
+ if (!options.oauth) {
57
+ throw new Error('OAuth is not configured on this instance (missing `auth.oauth`).')
58
+ }
59
+ return options as ConfiguredOAuthOptions
60
+ }
61
+
62
+ /** Creates a client for the (unauthenticated) public OAuth token/revoke calls. */
63
+ function createOAuthClient(options: AuthOptions): SanityClient {
64
+ return options.clientFactory({
65
+ apiVersion: 'v1',
66
+ requestTagPrefix: REQUEST_TAG_PREFIX,
67
+ useProjectHostname: false,
68
+ useCdn: false,
69
+ ...(options.apiHost && {apiHost: options.apiHost}),
70
+ })
71
+ }
72
+
73
+ /** Converts a token endpoint response to the {@link OAuthTokens} shape. */
74
+ function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
75
+ return {
76
+ accessToken: response.access_token,
77
+ tokenType: 'bearer',
78
+ expiresIn: response.expires_in,
79
+ expiresAt: new Date(Date.now() + response.expires_in * 1000),
80
+ ...(response.refresh_token !== undefined && {refreshToken: response.refresh_token}),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Starts the OAuth authorization-code + PKCE flow: generates a `code_verifier`,
86
+ * `code_challenge` and `state`, persists the verifier and state to
87
+ * `sessionStorage`, then navigates the browser to the authorize endpoint.
88
+ *
89
+ * @public
90
+ */
91
+ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({state, instance}) => {
92
+ const logger = getAuthLogger(instance)
93
+ const options = getOAuthOptions(state.get())
94
+
95
+ const codeVerifier = generateCodeVerifier()
96
+ const oauthState = generateState()
97
+ const codeChallenge = await generateCodeChallenge(codeVerifier)
98
+
99
+ const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
100
+ session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
101
+ session?.setItem(OAUTH_STATE_KEY, oauthState)
102
+
103
+ const authorizeUrl = new URL(
104
+ '/v1/auth/oauth/authorize',
105
+ options.apiHost ?? 'https://api.sanity.io',
106
+ )
107
+ authorizeUrl.searchParams.set('response_type', 'code')
108
+ authorizeUrl.searchParams.set('client_id', options.oauth.clientId)
109
+ authorizeUrl.searchParams.set('redirect_uri', options.oauth.redirectUri)
110
+ authorizeUrl.searchParams.set('state', oauthState)
111
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge)
112
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256')
113
+ authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
114
+
115
+ logger.info('Starting OAuth authorization')
116
+ if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
117
+ window.location.assign(authorizeUrl.toString())
118
+ }
119
+ })
120
+
121
+ /**
122
+ * Handles the OAuth redirect callback: validates `state`, surfaces `error`
123
+ * responses, exchanges the authorization `code` for tokens, persists them, and
124
+ * transitions to `LOGGED_IN`.
125
+ *
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).
129
+ *
130
+ * @public
131
+ */
132
+ export const handleOAuthCallback = bindActionGlobally(
133
+ authStore,
134
+ async (
135
+ {state, instance},
136
+ locationHref: string = getDefaultLocation(),
137
+ ): Promise<string | false> => {
138
+ const logger = getAuthLogger(instance)
139
+ const options = getOAuthOptions(state.get())
140
+
141
+ const {authState} = state.get()
142
+ if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
143
+ logger.debug('Skipping OAuth callback - token exchange already in progress')
144
+ return false
145
+ }
146
+
147
+ const callbackUrl = new URL(locationHref, DEFAULT_BASE)
148
+ const code = callbackUrl.searchParams.get('code')
149
+ const returnedState = callbackUrl.searchParams.get('state')
150
+ const error = callbackUrl.searchParams.get('error')
151
+ const errorDescription = callbackUrl.searchParams.get('error_description')
152
+
153
+ const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
154
+
155
+ const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
156
+ for (const param of ['code', 'state', 'error', 'error_description']) {
157
+ cleanedUrlObj.searchParams.delete(param)
158
+ }
159
+ const cleanedUrl = cleanedUrlObj.toString()
160
+
161
+ 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
171
+ }
172
+
173
+ if (!code) {
174
+ logger.debug('No OAuth code found in callback URL')
175
+ return false
176
+ }
177
+
178
+ const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
179
+ 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
186
+ }
187
+
188
+ const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
189
+ 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
196
+ }
197
+
198
+ logger.info('Exchanging OAuth code for tokens')
199
+ state.set('oauthExchange', {
200
+ authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: true},
201
+ })
202
+
203
+ try {
204
+ const client = createOAuthClient(options)
205
+ const params = new URLSearchParams({
206
+ grant_type: 'authorization_code',
207
+ code,
208
+ code_verifier: codeVerifier,
209
+ redirect_uri: options.oauth.redirectUri,
210
+ client_id: options.oauth.clientId,
211
+ resource: getResourceIndicator(options.oauth.organizationId),
212
+ })
213
+ const response = await client.request<TokenEndpointResponse>({
214
+ method: 'POST',
215
+ url: '/auth/oauth/token',
216
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
217
+ body: params.toString(),
218
+ tag: 'oauth.token',
219
+ })
220
+
221
+ const tokens = toOAuthTokens(response)
222
+ options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
223
+ clearOAuthArtifacts(session)
224
+
225
+ logger.info('OAuth tokens obtained, user logged in')
226
+ state.set('oauthLoggedIn', {
227
+ authState: createLoggedInAuthState(tokens.accessToken, null),
228
+ oauthTokens: tokens,
229
+ })
230
+ return cleanedUrl
231
+ } catch (exchangeError) {
232
+ logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
233
+ clearOAuthArtifacts(session)
234
+ state.set('oauthExchangeError', {
235
+ authState: {type: AuthStateType.ERROR, error: exchangeError},
236
+ })
237
+ return cleanedUrl
238
+ }
239
+ },
240
+ )
241
+
242
+ /**
243
+ * A refresh failure is unrecoverable only when the token endpoint rejects the
244
+ * refresh token itself (a 4xx). Rate-limit (429) and request-timeout (408)
245
+ * responses, 5xx errors and network failures are transient, so the session is
246
+ * kept intact for the caller to retry rather than forcing a logout.
247
+ */
248
+ function isUnrecoverableRefreshError(error: unknown): boolean {
249
+ return error instanceof ClientError && error.statusCode !== 408 && error.statusCode !== 429
250
+ }
251
+
252
+ // Single-flight refresh shared across concurrent callers. Safe as a module
253
+ // singleton because `authStore` is a global store (one shared state).
254
+ // ponytail: module-level single-flight; upgrade to per-store keying only if
255
+ // the auth store ever stops being global.
256
+ let refreshInFlight: Promise<OAuthTokens | null> | null = null
257
+
258
+ /**
259
+ * Refreshes the OAuth tokens using the `refresh_token` grant. Concurrent
260
+ * callers share a single in-flight request. An unrecoverable failure (a 4xx
261
+ * rejecting the refresh token) clears the tokens and transitions to
262
+ * `LOGGED_OUT`; transient failures (network, 5xx, rate limits) leave the
263
+ * session intact and rethrow so the caller can retry.
264
+ *
265
+ * @public
266
+ */
267
+ export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => {
268
+ if (refreshInFlight) return refreshInFlight
269
+ refreshInFlight = doRefreshOAuthTokens(context).finally(() => {
270
+ refreshInFlight = null
271
+ })
272
+ return refreshInFlight
273
+ })
274
+
275
+ async function doRefreshOAuthTokens({
276
+ state,
277
+ instance,
278
+ }: StoreContext<AuthStoreState>): Promise<OAuthTokens | null> {
279
+ const logger = getAuthLogger(instance)
280
+ const options = getOAuthOptions(state.get())
281
+
282
+ const current = state.get().oauthTokens
283
+ if (!current?.refreshToken) {
284
+ logger.warn('No refresh token available — logging out')
285
+ options.storageArea?.removeItem(options.storageKey)
286
+ state.set('oauthRefreshNoToken', {
287
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
288
+ oauthTokens: undefined,
289
+ })
290
+ return null
291
+ }
292
+
293
+ try {
294
+ const client = createOAuthClient(options)
295
+ const params = new URLSearchParams({
296
+ grant_type: 'refresh_token',
297
+ refresh_token: current.refreshToken,
298
+ client_id: options.oauth.clientId,
299
+ resource: getResourceIndicator(options.oauth.organizationId),
300
+ })
301
+ const response = await client.request<TokenEndpointResponse>({
302
+ method: 'POST',
303
+ url: '/auth/oauth/token',
304
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
305
+ body: params.toString(),
306
+ tag: 'oauth.refresh',
307
+ })
308
+
309
+ const tokens = toOAuthTokens(response)
310
+ if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
311
+
312
+ options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
313
+ logger.info('OAuth tokens refreshed')
314
+ state.set('oauthRefreshed', {
315
+ authState: createLoggedInAuthState(tokens.accessToken, null),
316
+ oauthTokens: tokens,
317
+ })
318
+ return tokens
319
+ } catch (error) {
320
+ if (!isUnrecoverableRefreshError(error)) {
321
+ // Transient (network / 5xx / rate limit) — keep the session so the
322
+ // caller can retry instead of forcing a logout.
323
+ logger.warn('OAuth token refresh failed — keeping session for retry', {error})
324
+ throw error
325
+ }
326
+ logger.error('OAuth token refresh failed — logging out', {error})
327
+ options.storageArea?.removeItem(options.storageKey)
328
+ state.set('oauthRefreshFailed', {
329
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
330
+ oauthTokens: undefined,
331
+ })
332
+ throw error
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Revokes the OAuth tokens then clears local storage and transitions to `LOGGED_OUT`.
338
+ * Local state is cleared even if the revoke request fails.
339
+ *
340
+ * @public
341
+ */
342
+ export const revokeOAuthTokens = bindActionGlobally(authStore, async ({state, instance}) => {
343
+ const logger = getAuthLogger(instance)
344
+ const options = getOAuthOptions(state.get())
345
+
346
+ const current = state.get().oauthTokens
347
+ const token = current?.refreshToken ?? current?.accessToken
348
+
349
+ try {
350
+ if (token) {
351
+ const client = createOAuthClient(options)
352
+ const params = new URLSearchParams({token, client_id: options.oauth.clientId})
353
+ await client.request<void>({
354
+ method: 'POST',
355
+ url: '/auth/oauth/revoke',
356
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
357
+ body: params.toString(),
358
+ tag: 'oauth.revoke',
359
+ })
360
+ logger.info('OAuth tokens revoked')
361
+ }
362
+ } catch (error) {
363
+ // Revocation is best-effort — local state is cleared regardless.
364
+ logger.warn('OAuth revoke request failed — clearing local state anyway', {error})
365
+ } finally {
366
+ options.storageArea?.removeItem(options.storageKey)
367
+ state.set('oauthRevoked', {
368
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
369
+ oauthTokens: undefined,
370
+ })
371
+ }
372
+ })
373
+
374
+ /**
375
+ * A state source exposing the current OAuth tokens (including expiry), or
376
+ * `null` when not logged in via OAuth.
377
+ *
378
+ * @public
379
+ */
380
+ export const getOAuthTokensState = bindActionGlobally(
381
+ authStore,
382
+ createStateSourceAction(({state}) => state.oauthTokens ?? null),
383
+ )
384
+
385
+ /** Removes the transient PKCE artifacts from session storage. */
386
+ function clearOAuthArtifacts(session: Storage | undefined): void {
387
+ session?.removeItem(OAUTH_VERIFIER_KEY)
388
+ session?.removeItem(OAUTH_STATE_KEY)
389
+ }
@@ -0,0 +1,206 @@
1
+ import {Subject} from 'rxjs'
2
+ import {beforeEach, describe, expect, it, vi} from 'vitest'
3
+
4
+ import {type SanityInstance} from '../../store/createSanityInstance'
5
+ import {type StoreContext} from '../../store/defineStore'
6
+ import {AuthStateType} from '../authStateType'
7
+ import {type AuthStoreState} from '../authStore'
8
+ import {type AuthStrategyOptions} from '../authStrategy'
9
+ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCurrentUser'
10
+ import {getStorageEvents} from '../utils'
11
+ import {serializeTokens} from './oauthActions'
12
+ import {
13
+ deserializeTokens,
14
+ getOauthInitialState,
15
+ initializeOauthAuth,
16
+ OAUTH_TOKENS_KEY,
17
+ subscribeToOAuthStorageEvents,
18
+ } from './oauthAuth'
19
+ import {type OAuthTokens} from './types'
20
+
21
+ vi.mock('../subscribeToStateAndFetchCurrentUser')
22
+ vi.mock('../utils', async (importOriginal) => {
23
+ const original = await importOriginal<typeof import('../utils')>()
24
+ return {...original, getStorageEvents: vi.fn(() => new Subject())}
25
+ })
26
+
27
+ const tokens: OAuthTokens = {
28
+ accessToken: 'access-1',
29
+ tokenType: 'bearer',
30
+ expiresIn: 3600,
31
+ expiresAt: new Date('2030-01-01T00:00:00.000Z'),
32
+ refreshToken: 'refresh-1',
33
+ }
34
+
35
+ function createMemoryStorage(seed?: Record<string, string>): Storage {
36
+ const map = new Map<string, string>(Object.entries(seed ?? {}))
37
+ return {
38
+ getItem: (k) => map.get(k) ?? null,
39
+ setItem: (k, v) => void map.set(k, v),
40
+ removeItem: (k) => void map.delete(k),
41
+ clear: () => map.clear(),
42
+ key: (i) => [...map.keys()][i] ?? null,
43
+ get length() {
44
+ return map.size
45
+ },
46
+ } as Storage
47
+ }
48
+
49
+ const baseOptions = (
50
+ overrides: Partial<AuthStrategyOptions> & {
51
+ oauthConfig?: {clientId: string; redirectUri: string; organizationId: string}
52
+ storageArea?: Storage
53
+ } = {},
54
+ ): AuthStrategyOptions => ({
55
+ authConfig: {
56
+ storageArea: overrides.storageArea,
57
+ oauth: overrides.oauthConfig ?? {
58
+ clientId: 'c',
59
+ redirectUri: 'https://app/callback',
60
+ organizationId: 'org',
61
+ },
62
+ },
63
+ projectId: 'p',
64
+ initialLocationHref: overrides.initialLocationHref ?? 'https://app/',
65
+ clientFactory: vi.fn(),
66
+ })
67
+
68
+ describe('serialize/deserialize tokens', () => {
69
+ it('round-trips tokens with expiresAt as an ISO string', () => {
70
+ const raw = serializeTokens(tokens)
71
+ expect(JSON.parse(raw).expiresAt).toBe('2030-01-01T00:00:00.000Z')
72
+ expect(deserializeTokens(raw)).toEqual(tokens)
73
+ })
74
+
75
+ it('omits refreshToken when absent', () => {
76
+ const {refreshToken: _omit, ...withoutRefresh} = tokens
77
+ expect(deserializeTokens(serializeTokens(withoutRefresh))).not.toHaveProperty('refreshToken')
78
+ })
79
+
80
+ it('returns null for missing or malformed values', () => {
81
+ expect(deserializeTokens(null)).toBeNull()
82
+ expect(deserializeTokens('not json')).toBeNull()
83
+ expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
84
+ expect(deserializeTokens('123')).toBeNull()
85
+ })
86
+ })
87
+
88
+ describe('getOauthInitialState', () => {
89
+ it('returns LOGGED_IN with tokens when persisted tokens exist', () => {
90
+ const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
91
+ const result = getOauthInitialState(baseOptions({storageArea}))
92
+ expect(result.authState).toMatchObject({type: AuthStateType.LOGGED_IN, token: 'access-1'})
93
+ expect(result.oauthTokens).toEqual(tokens)
94
+ expect(result.authMethod).toBe('localstorage')
95
+ })
96
+
97
+ it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
98
+ const storageArea = createMemoryStorage()
99
+ const result = getOauthInitialState(
100
+ baseOptions({storageArea, initialLocationHref: 'https://app/callback?code=c&state=s'}),
101
+ )
102
+ expect(result.authState).toEqual({type: AuthStateType.LOGGING_IN, isExchangingToken: false})
103
+ })
104
+
105
+ it('returns LOGGED_OUT when a callback URL does not match the redirect URI', () => {
106
+ const storageArea = createMemoryStorage()
107
+ const result = getOauthInitialState(
108
+ baseOptions({storageArea, initialLocationHref: 'https://other/callback?code=c&state=s'}),
109
+ )
110
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
111
+ })
112
+
113
+ it('returns LOGGED_OUT when there are no tokens and no callback', () => {
114
+ const storageArea = createMemoryStorage()
115
+ const result = getOauthInitialState(baseOptions({storageArea}))
116
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
117
+ })
118
+ })
119
+
120
+ describe('subscribeToOAuthStorageEvents', () => {
121
+ let events: Subject<StorageEvent>
122
+
123
+ beforeEach(() => {
124
+ events = new Subject<StorageEvent>()
125
+ vi.mocked(getStorageEvents).mockReturnValue(events)
126
+ })
127
+
128
+ function makeContext(storageArea: Storage): {
129
+ context: StoreContext<AuthStoreState>
130
+ set: ReturnType<typeof vi.fn>
131
+ } {
132
+ const set = vi.fn()
133
+ const context = {
134
+ state: {get: () => ({options: {storageArea}}), set},
135
+ instance: {config: {}} as SanityInstance,
136
+ key: null,
137
+ } as unknown as StoreContext<AuthStoreState>
138
+ return {context, set}
139
+ }
140
+
141
+ it('sets LOGGED_IN when tokens appear in another tab', () => {
142
+ const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
143
+ const {context, set} = makeContext(storageArea)
144
+ subscribeToOAuthStorageEvents(context)
145
+
146
+ events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
147
+
148
+ expect(set).toHaveBeenCalledWith(
149
+ 'updateOAuthTokensFromStorageEvent',
150
+ expect.objectContaining({
151
+ authState: expect.objectContaining({type: AuthStateType.LOGGED_IN, token: 'access-1'}),
152
+ oauthTokens: tokens,
153
+ }),
154
+ )
155
+ })
156
+
157
+ it('sets LOGGED_OUT when tokens are cleared in another tab', () => {
158
+ const storageArea = createMemoryStorage()
159
+ const {context, set} = makeContext(storageArea)
160
+ subscribeToOAuthStorageEvents(context)
161
+
162
+ events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
163
+
164
+ expect(set).toHaveBeenCalledWith('updateOAuthTokensFromStorageEvent', {
165
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
166
+ oauthTokens: undefined,
167
+ })
168
+ })
169
+
170
+ it('ignores events for other keys or storage areas', () => {
171
+ const storageArea = createMemoryStorage()
172
+ const {context, set} = makeContext(storageArea)
173
+ subscribeToOAuthStorageEvents(context)
174
+
175
+ events.next({storageArea, key: 'other'} as StorageEvent)
176
+ events.next({storageArea: createMemoryStorage(), key: OAUTH_TOKENS_KEY} as StorageEvent)
177
+
178
+ expect(set).not.toHaveBeenCalled()
179
+ })
180
+ })
181
+
182
+ describe('initializeOauthAuth', () => {
183
+ beforeEach(() => {
184
+ vi.mocked(getStorageEvents).mockReturnValue(new Subject())
185
+ vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(new Subject().subscribe())
186
+ })
187
+
188
+ function makeContext(storageArea: Storage | undefined): StoreContext<AuthStoreState> {
189
+ return {
190
+ state: {get: () => ({options: {storageArea}}), set: vi.fn()},
191
+ instance: {config: {}} as SanityInstance,
192
+ key: null,
193
+ } as unknown as StoreContext<AuthStoreState>
194
+ }
195
+
196
+ it('does not start the stamped-token refresher', () => {
197
+ const result = initializeOauthAuth(makeContext(createMemoryStorage()))
198
+ expect(result.tokenRefresherStarted).toBe(false)
199
+ result.dispose()
200
+ })
201
+
202
+ it('subscribes without a storage area without throwing', () => {
203
+ const result = initializeOauthAuth(makeContext(undefined))
204
+ expect(() => result.dispose()).not.toThrow()
205
+ })
206
+ })