@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
@@ -0,0 +1,438 @@
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
+ /** 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
+ interface TokenEndpointResponse {
40
+ access_token: string
41
+ token_type: string
42
+ expires_in: number
43
+ refresh_token?: string
44
+ }
45
+
46
+ /** Builds the RFC 8707 resource indicator for an organisation. */
47
+ function getResourceIndicator(organizationId: string): string {
48
+ return `urn:io.sanity:organization:${organizationId}`
49
+ }
50
+
51
+ /**
52
+ * Serialises tokens for storage, converting `expiresAt` to an ISO string.
53
+ *
54
+ * @internal
55
+ */
56
+ export function serializeTokens(tokens: OAuthTokens): string {
57
+ return JSON.stringify({
58
+ accessToken: tokens.accessToken,
59
+ tokenType: tokens.tokenType,
60
+ expiresIn: tokens.expiresIn,
61
+ expiresAt: tokens.expiresAt.toISOString(),
62
+ ...(tokens.refreshToken !== undefined && {refreshToken: tokens.refreshToken}),
63
+ })
64
+ }
65
+
66
+ type AuthOptions = AuthStoreState['options']
67
+ type ConfiguredOAuthOptions = AuthOptions & {oauth: NonNullable<AuthOptions['oauth']>}
68
+
69
+ /**
70
+ * Reads the store options, throwing when the instance was not configured for
71
+ * OAuth.
72
+ */
73
+ function getOAuthOptions(state: AuthStoreState): ConfiguredOAuthOptions {
74
+ const {options} = state
75
+ if (!options.oauth) {
76
+ throw new Error('OAuth is not configured on this instance (missing `auth.oauth`).')
77
+ }
78
+ return options as ConfiguredOAuthOptions
79
+ }
80
+
81
+ /** Creates a client for the (unauthenticated) public OAuth token/revoke calls. */
82
+ function createOAuthClient(options: AuthOptions): SanityClient {
83
+ return options.clientFactory({
84
+ apiVersion: 'v1',
85
+ requestTagPrefix: REQUEST_TAG_PREFIX,
86
+ useProjectHostname: false,
87
+ useCdn: false,
88
+ ...(options.apiHost && {apiHost: options.apiHost}),
89
+ })
90
+ }
91
+
92
+ /** Converts a token endpoint response to the {@link OAuthTokens} shape. */
93
+ function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
94
+ return {
95
+ accessToken: response.access_token,
96
+ tokenType: 'bearer',
97
+ expiresIn: response.expires_in,
98
+ expiresAt: new Date(Date.now() + response.expires_in * 1000),
99
+ ...(response.refresh_token !== undefined && {refreshToken: response.refresh_token}),
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Starts the OAuth authorization-code + PKCE flow: generates a `code_verifier`,
105
+ * `code_challenge` and `state`, persists the verifier and state to
106
+ * `sessionStorage`, then navigates the browser to the authorize endpoint.
107
+ *
108
+ * @public
109
+ */
110
+ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({state, instance}) => {
111
+ const logger = getAuthLogger(instance)
112
+ const options = getOAuthOptions(state.get())
113
+
114
+ const codeVerifier = generateCodeVerifier()
115
+ const oauthState = generateState()
116
+ const codeChallenge = await generateCodeChallenge(codeVerifier)
117
+
118
+ const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
119
+ session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
120
+ session?.setItem(OAUTH_STATE_KEY, oauthState)
121
+ // The redirect URI must match the registered one exactly, so the current
122
+ // location (deep link) is stashed here and restored by the callback. OAuth
123
+ // params are only stripped when we are already on the callback route, so
124
+ // legitimate app params like `?state=draft` survive the round trip.
125
+ const currentHref = getDefaultLocation()
126
+ if (currentHref !== DEFAULT_BASE) {
127
+ const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
128
+ ? stripOAuthParams(currentHref)
129
+ : currentHref
130
+ session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
131
+ }
132
+
133
+ const authorizeUrl = new URL(
134
+ '/v1/auth/oauth/authorize',
135
+ options.apiHost ?? 'https://api.sanity.io',
136
+ )
137
+ authorizeUrl.searchParams.set('response_type', 'code')
138
+ authorizeUrl.searchParams.set('client_id', options.oauth.clientId)
139
+ authorizeUrl.searchParams.set('redirect_uri', options.oauth.redirectUri)
140
+ authorizeUrl.searchParams.set('state', oauthState)
141
+ authorizeUrl.searchParams.set('code_challenge', codeChallenge)
142
+ authorizeUrl.searchParams.set('code_challenge_method', 'S256')
143
+ authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
144
+
145
+ logger.info('Starting OAuth authorization')
146
+ if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
147
+ window.location.assign(authorizeUrl.toString())
148
+ }
149
+ })
150
+
151
+ /**
152
+ * Handles the OAuth redirect callback: validates `state`, surfaces `error`
153
+ * responses, exchanges the authorization `code` for tokens, persists them, and
154
+ * transitions to `LOGGED_IN`.
155
+ *
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.
164
+ *
165
+ * @public
166
+ */
167
+ export const handleOAuthCallback = bindActionGlobally(
168
+ authStore,
169
+ async (
170
+ {state, instance},
171
+ locationHref: string = getDefaultLocation(),
172
+ ): Promise<string | false> => {
173
+ const logger = getAuthLogger(instance)
174
+ const options = getOAuthOptions(state.get())
175
+
176
+ const {authState} = state.get()
177
+ if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
178
+ logger.debug('Skipping OAuth callback - token exchange already in progress')
179
+ return false
180
+ }
181
+
182
+ const callbackUrl = new URL(locationHref, DEFAULT_BASE)
183
+ const code = callbackUrl.searchParams.get('code')
184
+ const returnedState = callbackUrl.searchParams.get('state')
185
+ const error = callbackUrl.searchParams.get('error')
186
+ const errorDescription = callbackUrl.searchParams.get('error_description')
187
+
188
+ const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
189
+
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
206
+ }
207
+
208
+ if (error) {
209
+ return rejectCallback(
210
+ 'oauthCallbackError',
211
+ errorDescription ? `${error}: ${errorDescription}` : error,
212
+ )
213
+ }
214
+
215
+ if (!code) {
216
+ logger.debug('No OAuth code found in callback URL')
217
+ return false
218
+ }
219
+
220
+ const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
221
+ if (!returnedState || !storedState || returnedState !== storedState) {
222
+ return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
223
+ }
224
+
225
+ const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
226
+ if (!codeVerifier) {
227
+ return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
228
+ }
229
+
230
+ logger.info('Exchanging OAuth code for tokens')
231
+ state.set('oauthExchange', {
232
+ authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: true},
233
+ })
234
+
235
+ try {
236
+ const client = createOAuthClient(options)
237
+ const params = new URLSearchParams({
238
+ grant_type: 'authorization_code',
239
+ code,
240
+ code_verifier: codeVerifier,
241
+ redirect_uri: options.oauth.redirectUri,
242
+ client_id: options.oauth.clientId,
243
+ resource: getResourceIndicator(options.oauth.organizationId),
244
+ })
245
+ const response = await client.request<TokenEndpointResponse>({
246
+ method: 'POST',
247
+ url: '/auth/oauth/token',
248
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
249
+ body: params.toString(),
250
+ tag: 'oauth.token',
251
+ })
252
+
253
+ const tokens = toOAuthTokens(response)
254
+ options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
255
+ const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
256
+ clearOAuthArtifacts(session)
257
+
258
+ logger.info('OAuth tokens obtained, user logged in')
259
+ state.set('oauthLoggedIn', {
260
+ authState: createLoggedInAuthState(tokens.accessToken, null),
261
+ oauthTokens: tokens,
262
+ })
263
+ return returnTo ?? cleanedUrl
264
+ } catch (exchangeError) {
265
+ logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
266
+ clearOAuthArtifacts(session)
267
+ state.set('oauthExchangeError', {
268
+ authState: {type: AuthStateType.ERROR, error: exchangeError},
269
+ })
270
+ return cleanedUrl
271
+ }
272
+ },
273
+ )
274
+
275
+ /**
276
+ * A refresh failure is unrecoverable only when the token endpoint rejects the
277
+ * refresh token itself (a 4xx). Rate-limit (429) and request-timeout (408)
278
+ * responses, 5xx errors and network failures are transient, so the session is
279
+ * kept intact for the caller to retry rather than forcing a logout.
280
+ */
281
+ function isUnrecoverableRefreshError(error: unknown): boolean {
282
+ return error instanceof ClientError && error.statusCode !== 408 && error.statusCode !== 429
283
+ }
284
+
285
+ // Single-flight refresh shared across concurrent callers. Safe as a module
286
+ // singleton because `authStore` is a global store (one shared state).
287
+ // ponytail: module-level single-flight; upgrade to per-store keying only if
288
+ // the auth store ever stops being global.
289
+ let refreshInFlight: Promise<OAuthTokens | null> | null = null
290
+
291
+ /**
292
+ * Refreshes the OAuth tokens using the `refresh_token` grant. Concurrent
293
+ * callers share a single in-flight request. An unrecoverable failure (a 4xx
294
+ * rejecting the refresh token) clears the tokens and transitions to
295
+ * `LOGGED_OUT`; transient failures (network, 5xx, rate limits) leave the
296
+ * session intact and rethrow so the caller can retry.
297
+ *
298
+ * @public
299
+ */
300
+ export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => {
301
+ if (refreshInFlight) return refreshInFlight
302
+ refreshInFlight = doRefreshOAuthTokens(context).finally(() => {
303
+ refreshInFlight = null
304
+ })
305
+ return refreshInFlight
306
+ })
307
+
308
+ async function doRefreshOAuthTokens({
309
+ state,
310
+ instance,
311
+ }: StoreContext<AuthStoreState>): Promise<OAuthTokens | null> {
312
+ const logger = getAuthLogger(instance)
313
+ const options = getOAuthOptions(state.get())
314
+
315
+ const current = state.get().oauthTokens
316
+ if (!current?.refreshToken) {
317
+ logger.warn('No refresh token available — logging out')
318
+ options.storageArea?.removeItem(options.storageKey)
319
+ state.set('oauthRefreshNoToken', {
320
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
321
+ oauthTokens: undefined,
322
+ })
323
+ return null
324
+ }
325
+
326
+ try {
327
+ const client = createOAuthClient(options)
328
+ const params = new URLSearchParams({
329
+ grant_type: 'refresh_token',
330
+ refresh_token: current.refreshToken,
331
+ client_id: options.oauth.clientId,
332
+ resource: getResourceIndicator(options.oauth.organizationId),
333
+ })
334
+ const response = await client.request<TokenEndpointResponse>({
335
+ method: 'POST',
336
+ url: '/auth/oauth/token',
337
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
338
+ body: params.toString(),
339
+ tag: 'oauth.refresh',
340
+ })
341
+
342
+ const tokens = toOAuthTokens(response)
343
+ if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
344
+
345
+ options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
346
+ logger.info('OAuth tokens refreshed')
347
+ state.set('oauthRefreshed', {
348
+ authState: createLoggedInAuthState(tokens.accessToken, null),
349
+ oauthTokens: tokens,
350
+ })
351
+ return tokens
352
+ } catch (error) {
353
+ if (!isUnrecoverableRefreshError(error)) {
354
+ // Transient (network / 5xx / rate limit) — keep the session so the
355
+ // caller can retry instead of forcing a logout.
356
+ logger.warn('OAuth token refresh failed — keeping session for retry', {error})
357
+ throw error
358
+ }
359
+ logger.error('OAuth token refresh failed — logging out', {error})
360
+ options.storageArea?.removeItem(options.storageKey)
361
+ state.set('oauthRefreshFailed', {
362
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
363
+ oauthTokens: undefined,
364
+ })
365
+ throw error
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Revokes the OAuth tokens then clears local storage and transitions to `LOGGED_OUT`.
371
+ * Local state is cleared even if the revoke request fails.
372
+ *
373
+ * @public
374
+ */
375
+ export const revokeOAuthTokens = bindActionGlobally(authStore, async ({state, instance}) => {
376
+ const logger = getAuthLogger(instance)
377
+ const options = getOAuthOptions(state.get())
378
+
379
+ const current = state.get().oauthTokens
380
+ const token = current?.refreshToken ?? current?.accessToken
381
+
382
+ try {
383
+ if (token) {
384
+ const client = createOAuthClient(options)
385
+ const params = new URLSearchParams({token, client_id: options.oauth.clientId})
386
+ await client.request<void>({
387
+ method: 'POST',
388
+ url: '/auth/oauth/revoke',
389
+ headers: {'content-type': 'application/x-www-form-urlencoded'},
390
+ body: params.toString(),
391
+ tag: 'oauth.revoke',
392
+ })
393
+ logger.info('OAuth tokens revoked')
394
+ }
395
+ } catch (error) {
396
+ // Revocation is best-effort — local state is cleared regardless.
397
+ logger.warn('OAuth revoke request failed — clearing local state anyway', {error})
398
+ } finally {
399
+ options.storageArea?.removeItem(options.storageKey)
400
+ state.set('oauthRevoked', {
401
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
402
+ oauthTokens: undefined,
403
+ })
404
+ }
405
+ })
406
+
407
+ /**
408
+ * A state source exposing the current OAuth tokens (including expiry), or
409
+ * `null` when not logged in via OAuth.
410
+ *
411
+ * @public
412
+ */
413
+ export const getOAuthTokensState = bindActionGlobally(
414
+ authStore,
415
+ createStateSourceAction(({state}) => state.oauthTokens ?? null),
416
+ )
417
+
418
+ /** Removes the transient PKCE artifacts from session storage. */
419
+ function clearOAuthArtifacts(session: Storage | undefined): void {
420
+ session?.removeItem(OAUTH_VERIFIER_KEY)
421
+ session?.removeItem(OAUTH_STATE_KEY)
422
+ session?.removeItem(OAUTH_RETURN_TO_KEY)
423
+ }
424
+
425
+ /**
426
+ * Reads the stashed pre-authorize location, only honouring it when it shares
427
+ * the registered redirect URI's origin so a tampered value cannot redirect
428
+ * off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
429
+ */
430
+ function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
431
+ const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
432
+ if (!returnTo) return null
433
+ try {
434
+ return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
435
+ } catch {
436
+ return null
437
+ }
438
+ }
@@ -0,0 +1,229 @@
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
+ it('returns null when expiresAt does not parse to a valid date', () => {
88
+ const raw = JSON.stringify({...JSON.parse(serializeTokens(tokens)), expiresAt: 'garbage'})
89
+ expect(deserializeTokens(raw)).toBeNull()
90
+ })
91
+ })
92
+
93
+ describe('getOauthInitialState', () => {
94
+ it('returns LOGGED_IN with tokens when persisted tokens exist', () => {
95
+ const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
96
+ const result = getOauthInitialState(baseOptions({storageArea}))
97
+ expect(result.authState).toMatchObject({type: AuthStateType.LOGGED_IN, token: 'access-1'})
98
+ expect(result.oauthTokens).toEqual(tokens)
99
+ expect(result.authMethod).toBe('localstorage')
100
+ })
101
+
102
+ it('returns LOGGED_OUT and clears storage when persisted tokens are corrupt', () => {
103
+ const storageArea = createMemoryStorage({
104
+ [OAUTH_TOKENS_KEY]: '{"accessToken":"a","expiresAt":"garbage"}',
105
+ })
106
+ const result = getOauthInitialState(baseOptions({storageArea}))
107
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
108
+ expect(result.oauthTokens).toBeUndefined()
109
+ expect(storageArea.getItem(OAUTH_TOKENS_KEY)).toBeNull()
110
+ })
111
+
112
+ it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
113
+ const storageArea = createMemoryStorage()
114
+ const result = getOauthInitialState(
115
+ baseOptions({storageArea, initialLocationHref: 'https://app/callback?code=c&state=s'}),
116
+ )
117
+ expect(result.authState).toEqual({type: AuthStateType.LOGGING_IN, isExchangingToken: false})
118
+ })
119
+
120
+ it('returns LOGGED_OUT when a callback URL does not match the redirect URI', () => {
121
+ const storageArea = createMemoryStorage()
122
+ const result = getOauthInitialState(
123
+ baseOptions({storageArea, initialLocationHref: 'https://other/callback?code=c&state=s'}),
124
+ )
125
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
126
+ })
127
+
128
+ it('returns LOGGED_OUT when the redirect URI only carries a stray state param', () => {
129
+ const storageArea = createMemoryStorage()
130
+ const result = getOauthInitialState(
131
+ baseOptions({storageArea, initialLocationHref: 'https://app/callback?state=s'}),
132
+ )
133
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
134
+ })
135
+
136
+ it('returns LOGGED_OUT when there are no tokens and no callback', () => {
137
+ const storageArea = createMemoryStorage()
138
+ const result = getOauthInitialState(baseOptions({storageArea}))
139
+ expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
140
+ })
141
+ })
142
+
143
+ describe('subscribeToOAuthStorageEvents', () => {
144
+ let events: Subject<StorageEvent>
145
+
146
+ beforeEach(() => {
147
+ events = new Subject<StorageEvent>()
148
+ vi.mocked(getStorageEvents).mockReturnValue(events)
149
+ })
150
+
151
+ function makeContext(storageArea: Storage): {
152
+ context: StoreContext<AuthStoreState>
153
+ set: ReturnType<typeof vi.fn>
154
+ } {
155
+ const set = vi.fn()
156
+ const context = {
157
+ state: {get: () => ({options: {storageArea}}), set},
158
+ instance: {config: {}} as SanityInstance,
159
+ key: null,
160
+ } as unknown as StoreContext<AuthStoreState>
161
+ return {context, set}
162
+ }
163
+
164
+ it('sets LOGGED_IN when tokens appear in another tab', () => {
165
+ const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
166
+ const {context, set} = makeContext(storageArea)
167
+ subscribeToOAuthStorageEvents(context)
168
+
169
+ events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
170
+
171
+ expect(set).toHaveBeenCalledWith(
172
+ 'updateOAuthTokensFromStorageEvent',
173
+ expect.objectContaining({
174
+ authState: expect.objectContaining({type: AuthStateType.LOGGED_IN, token: 'access-1'}),
175
+ oauthTokens: tokens,
176
+ }),
177
+ )
178
+ })
179
+
180
+ it('sets LOGGED_OUT when tokens are cleared in another tab', () => {
181
+ const storageArea = createMemoryStorage()
182
+ const {context, set} = makeContext(storageArea)
183
+ subscribeToOAuthStorageEvents(context)
184
+
185
+ events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
186
+
187
+ expect(set).toHaveBeenCalledWith('updateOAuthTokensFromStorageEvent', {
188
+ authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
189
+ oauthTokens: undefined,
190
+ })
191
+ })
192
+
193
+ it('ignores events for other keys or storage areas', () => {
194
+ const storageArea = createMemoryStorage()
195
+ const {context, set} = makeContext(storageArea)
196
+ subscribeToOAuthStorageEvents(context)
197
+
198
+ events.next({storageArea, key: 'other'} as StorageEvent)
199
+ events.next({storageArea: createMemoryStorage(), key: OAUTH_TOKENS_KEY} as StorageEvent)
200
+
201
+ expect(set).not.toHaveBeenCalled()
202
+ })
203
+ })
204
+
205
+ describe('initializeOauthAuth', () => {
206
+ beforeEach(() => {
207
+ vi.mocked(getStorageEvents).mockReturnValue(new Subject())
208
+ vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(new Subject().subscribe())
209
+ })
210
+
211
+ function makeContext(storageArea: Storage | undefined): StoreContext<AuthStoreState> {
212
+ return {
213
+ state: {get: () => ({options: {storageArea}}), set: vi.fn()},
214
+ instance: {config: {}} as SanityInstance,
215
+ key: null,
216
+ } as unknown as StoreContext<AuthStoreState>
217
+ }
218
+
219
+ it('does not start the stamped-token refresher', () => {
220
+ const result = initializeOauthAuth(makeContext(createMemoryStorage()))
221
+ expect(result.tokenRefresherStarted).toBe(false)
222
+ result.dispose()
223
+ })
224
+
225
+ it('subscribes without a storage area without throwing', () => {
226
+ const result = initializeOauthAuth(makeContext(undefined))
227
+ expect(() => result.dispose()).not.toThrow()
228
+ })
229
+ })