@sanity/sdk 3.3.0-rc.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/_internal.d.ts +12 -4
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +24 -6
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/dashboard.d.ts +1 -1
- package/dist/_exports/dashboard.js +1 -1
- package/dist/{authStore-BGoEGxOq.d.ts → applications-pRS8tuC2.d.ts} +349 -362
- package/dist/applications-pRS8tuC2.d.ts.map +1 -0
- package/dist/{clientStore-QDrJNTM4.js → clientStore-BI4D8HhJ.js} +10 -14
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +123 -156
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-Ct7IFNtY.js → topicStore-BJ54_X6H.js} +3 -1
- package/dist/topicStore-BJ54_X6H.js.map +1 -0
- package/dist/{topicStore-DXvuH9h4.d.ts → topicStore-D-jEwzlX.d.ts} +54 -25
- package/dist/topicStore-D-jEwzlX.d.ts.map +1 -0
- package/dist/{version-CIhX5ppJ.js → version-CD4aBigm.js} +3 -3
- package/dist/{version-CIhX5ppJ.js.map → version-CD4aBigm.js.map} +1 -1
- package/package.json +7 -7
- package/src/_exports/_internal.ts +2 -0
- package/src/applications/applicationOrigin.test.ts +51 -0
- package/src/applications/applicationOrigin.ts +26 -0
- package/src/auth/authStore.test.ts +0 -18
- package/src/auth/authStore.ts +1 -4
- package/src/auth/handleAuthCallback.test.ts +0 -33
- package/src/auth/handleAuthCallback.ts +1 -11
- package/src/auth/logout.test.ts +1 -9
- package/src/auth/logout.ts +1 -18
- package/src/auth/oauth/oauthActions.test.ts +0 -136
- package/src/auth/oauth/oauthActions.ts +29 -78
- package/src/auth/oauth/oauthAuth.test.ts +0 -23
- package/src/auth/oauth/oauthAuth.ts +8 -18
- package/src/dashboard/messageBus/bus.test.ts +39 -0
- package/src/dashboard/messageBus/bus.ts +32 -22
- package/src/dashboard/messageBus/topics.test.ts +36 -2
- package/src/dashboard/messageBus/topics.ts +51 -16
- package/src/installations/installations.test-d.ts +11 -0
- package/src/installations/installations.test.ts +6 -4
- package/src/installations/installations.ts +13 -2
- package/dist/authStore-BGoEGxOq.d.ts.map +0 -1
- package/dist/clientStore-QDrJNTM4.js.map +0 -1
- package/dist/topicStore-Ct7IFNtY.js.map +0 -1
- package/dist/topicStore-DXvuH9h4.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk",
|
|
3
|
-
"version": "3.3.0
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"@sanity/bifur-client": "^1.0.0",
|
|
48
|
-
"@sanity/client": "^8.6.
|
|
48
|
+
"@sanity/client": "^8.6.2",
|
|
49
49
|
"@sanity/comlink": "^4.0.3",
|
|
50
50
|
"@sanity/diff-match-patch": "^3.2.0",
|
|
51
51
|
"@sanity/diff-patch": "^6.0.0",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@sanity/message-protocol": "^0.24.0",
|
|
56
56
|
"@sanity/mutate": "^0.18.2",
|
|
57
57
|
"@sanity/telemetry": "^1.1.0",
|
|
58
|
-
"@sanity/types": "^6.
|
|
58
|
+
"@sanity/types": "^6.14.1",
|
|
59
59
|
"groq": "^6.13.2",
|
|
60
60
|
"groq-js": "^2.0.0",
|
|
61
61
|
"reselect": "^5.1.1",
|
|
@@ -70,14 +70,14 @@
|
|
|
70
70
|
"@repo/tsconfig": "0.0.1",
|
|
71
71
|
"@sanity/browserslist-config": "^1.0.5",
|
|
72
72
|
"@sanity/pkg-utils": "^13.0.0",
|
|
73
|
-
"@types/node": "^24.13.
|
|
74
|
-
"@vitest/coverage-v8": "^5.0.
|
|
73
|
+
"@types/node": "^24.13.5",
|
|
74
|
+
"@vitest/coverage-v8": "^5.0.1",
|
|
75
75
|
"eslint": "^10.10.0",
|
|
76
|
-
"oxfmt": "^0.
|
|
76
|
+
"oxfmt": "^0.68.0",
|
|
77
77
|
"rolldown": "^1.2.8",
|
|
78
78
|
"typescript": "^6.0.3",
|
|
79
79
|
"vite": "^8.3.0",
|
|
80
|
-
"vitest": "^5.0.
|
|
80
|
+
"vitest": "^5.0.1"
|
|
81
81
|
},
|
|
82
82
|
"browserslist": "extends @sanity/browserslist-config",
|
|
83
83
|
"scripts": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export {getApplicationOrigin} from '../applications/applicationOrigin'
|
|
1
2
|
export {isStudioConfig} from '../auth/authMode'
|
|
2
3
|
export {
|
|
3
4
|
type ApiErrorBody,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
isDashboardEnvironment,
|
|
18
19
|
requireDashboardMessageBus,
|
|
19
20
|
} from '../dashboard/messageBus/store'
|
|
21
|
+
export type {ApplicationStatus, ApplicationStatusUpdate} from '../dashboard/messageBus/topics'
|
|
20
22
|
export {getTopicState, resolveTopic} from '../dashboard/messageBus/topicStore'
|
|
21
23
|
export {PREVIEW_PROJECTION} from '../preview/previewConstants'
|
|
22
24
|
export {transformProjectionToPreview} from '../preview/previewProjectionUtils'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {afterEach, describe, expect, it, vi} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {getApplicationOrigin} from './applicationOrigin'
|
|
4
|
+
import {type ApplicationBase} from './applications'
|
|
5
|
+
|
|
6
|
+
const application: ApplicationBase = {
|
|
7
|
+
id: 'application-1',
|
|
8
|
+
type: 'coreApp',
|
|
9
|
+
title: 'Inbox',
|
|
10
|
+
name: 'inbox',
|
|
11
|
+
reference: 'sanity/inbox',
|
|
12
|
+
icon: null,
|
|
13
|
+
isSingleton: true,
|
|
14
|
+
visibility: 'default',
|
|
15
|
+
slug: 'inbox',
|
|
16
|
+
externalUrl: null,
|
|
17
|
+
organizationId: 'organization-1',
|
|
18
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
19
|
+
updatedAt: '2026-01-02T00:00:00.000Z',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('getApplicationOrigin', () => {
|
|
23
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
24
|
+
|
|
25
|
+
it('derives singleton and organization origins from the slug', () => {
|
|
26
|
+
expect(getApplicationOrigin(application)).toBe('https://inbox-apps-organization-1.sanity.run')
|
|
27
|
+
expect(getApplicationOrigin({...application, isSingleton: false, slug: 'canvas'})).toBe(
|
|
28
|
+
'https://canvas.sanity.studio',
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses the staging domains when the host sets the runtime flag', () => {
|
|
33
|
+
vi.stubGlobal('__SANITY_STAGING__', true)
|
|
34
|
+
expect(getApplicationOrigin(application)).toBe(
|
|
35
|
+
'https://inbox-apps-organization-1.run.sanity.work',
|
|
36
|
+
)
|
|
37
|
+
expect(getApplicationOrigin({...application, isSingleton: false})).toBe(
|
|
38
|
+
'https://inbox.studio.sanity.work',
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('prefers the external URL origin and returns null without slug or URL', () => {
|
|
43
|
+
expect(
|
|
44
|
+
getApplicationOrigin({
|
|
45
|
+
...application,
|
|
46
|
+
externalUrl: 'https://apps.example.com/external/index.html',
|
|
47
|
+
}),
|
|
48
|
+
).toBe('https://apps.example.com')
|
|
49
|
+
expect(getApplicationOrigin({...application, slug: null})).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {type ApplicationBase} from './applications'
|
|
2
|
+
|
|
3
|
+
type SanityGlobal = typeof globalThis & {__SANITY_STAGING__?: boolean}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The origin an application is served from, or `null` when it has neither an external URL nor
|
|
7
|
+
* a slug to derive one from. Callers treat `null` as "nothing to load" rather than failing the
|
|
8
|
+
* whole list on one bad record.
|
|
9
|
+
*
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export function getApplicationOrigin(application: ApplicationBase): string | null {
|
|
13
|
+
if (application.externalUrl !== null) return new URL(application.externalUrl).origin
|
|
14
|
+
if (application.slug === null) return null
|
|
15
|
+
|
|
16
|
+
// Read at runtime, not via a bundler define: a remote is built once and runs in whichever
|
|
17
|
+
// host page loaded it, and the host sets this flag. Mirrors workbench's `getSanityEnv`.
|
|
18
|
+
const staging = (globalThis as SanityGlobal).__SANITY_STAGING__ === true
|
|
19
|
+
if (application.isSingleton) {
|
|
20
|
+
const domain = staging ? 'run.sanity.work' : 'sanity.run'
|
|
21
|
+
return `https://${application.slug}-apps-${application.organizationId}.${domain}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const domain = staging ? 'studio.sanity.work' : 'sanity.studio'
|
|
25
|
+
return `https://${application.slug}.${domain}`
|
|
26
|
+
}
|
|
@@ -339,24 +339,6 @@ describe('authStore', () => {
|
|
|
339
339
|
expect(options.authMethod).toBe('localstorage')
|
|
340
340
|
})
|
|
341
341
|
|
|
342
|
-
it('only exposes oauth options when the resolved mode is oauth', () => {
|
|
343
|
-
const oauth = {
|
|
344
|
-
clientId: 'client-abc',
|
|
345
|
-
redirectUri: 'https://app.example.com/callback',
|
|
346
|
-
organizationId: 'org123',
|
|
347
|
-
}
|
|
348
|
-
vi.mocked(getStudioTokenFromLocalStorage).mockReturnValue(null)
|
|
349
|
-
vi.mocked(getTokenFromStorage).mockReturnValue(null)
|
|
350
|
-
|
|
351
|
-
// studio takes precedence over oauth, so oauth options must be dropped
|
|
352
|
-
instance = createSanityInstance({projectId: 'p', dataset: 'd', studio: {}, auth: {oauth}})
|
|
353
|
-
expect(authStore.getInitialState(instance, null).options.oauth).toBeUndefined()
|
|
354
|
-
instance.dispose()
|
|
355
|
-
|
|
356
|
-
instance = createSanityInstance({projectId: 'p', dataset: 'd', auth: {oauth}})
|
|
357
|
-
expect(authStore.getInitialState(instance, null).options.oauth).toEqual(oauth)
|
|
358
|
-
})
|
|
359
|
-
|
|
360
342
|
it('logs auth initialized in logged out state when no token available', () => {
|
|
361
343
|
instance = createSanityInstance({
|
|
362
344
|
projectId: 'p',
|
package/src/auth/authStore.ts
CHANGED
|
@@ -191,10 +191,7 @@ export const authStore = defineStore<AuthStoreState>({
|
|
|
191
191
|
storageKey: result.storageKey,
|
|
192
192
|
storageArea: result.storageArea,
|
|
193
193
|
authMethod: result.authMethod,
|
|
194
|
-
|
|
195
|
-
// so mode-gated actions (logout, handleAuthCallback) stay consistent
|
|
196
|
-
// with the resolved mode (e.g. `studio` takes precedence over `oauth`).
|
|
197
|
-
oauth: mode === 'oauth' ? authConfig.oauth : undefined,
|
|
194
|
+
oauth: authConfig.oauth,
|
|
198
195
|
},
|
|
199
196
|
}
|
|
200
197
|
},
|
|
@@ -5,7 +5,6 @@ import {createSanityInstance, type SanityInstance} from '../store/createSanityIn
|
|
|
5
5
|
import {AuthStateType} from './authStateType'
|
|
6
6
|
import {getAuthState} from './authStore'
|
|
7
7
|
import {handleAuthCallback} from './handleAuthCallback'
|
|
8
|
-
import {handleOAuthCallback} from './oauth/oauthActions'
|
|
9
8
|
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
10
9
|
import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
|
|
11
10
|
import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
|
|
@@ -23,11 +22,6 @@ vi.mock('./utils', async (importOriginal) => {
|
|
|
23
22
|
vi.mock('./subscribeToStateAndFetchCurrentUser')
|
|
24
23
|
vi.mock('./subscribeToStorageEventsAndSetToken')
|
|
25
24
|
|
|
26
|
-
vi.mock('./oauth/oauthActions', async (importOriginal) => {
|
|
27
|
-
const original = await importOriginal<typeof import('./oauth/oauthActions')>()
|
|
28
|
-
return {...original, handleOAuthCallback: vi.fn()}
|
|
29
|
-
})
|
|
30
|
-
|
|
31
25
|
// Mock logger to prevent actual logging during tests
|
|
32
26
|
vi.mock('../utils/logger', async (importOriginal) => {
|
|
33
27
|
const original = await importOriginal<typeof import('../utils/logger')>()
|
|
@@ -283,31 +277,4 @@ describe('handleCallback', () => {
|
|
|
283
277
|
// Verify logger was called
|
|
284
278
|
expect(createLogger).toHaveBeenCalled()
|
|
285
279
|
})
|
|
286
|
-
|
|
287
|
-
it('delegates to handleOAuthCallback in OAuth mode', async () => {
|
|
288
|
-
const clientFactory = vi.fn()
|
|
289
|
-
vi.mocked(getTokenFromStorage).mockReturnValue(null)
|
|
290
|
-
vi.mocked(handleOAuthCallback).mockResolvedValue('https://app.example.com/callback')
|
|
291
|
-
|
|
292
|
-
instance = createSanityInstance({
|
|
293
|
-
projectId: 'p',
|
|
294
|
-
dataset: 'd',
|
|
295
|
-
auth: {
|
|
296
|
-
clientFactory,
|
|
297
|
-
oauth: {
|
|
298
|
-
clientId: 'client-abc',
|
|
299
|
-
redirectUri: 'https://app.example.com/callback',
|
|
300
|
-
organizationId: 'org123',
|
|
301
|
-
},
|
|
302
|
-
},
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
const href = 'https://app.example.com/callback?code=abc&state=xyz'
|
|
306
|
-
const result = await handleAuthCallback(instance, href)
|
|
307
|
-
|
|
308
|
-
expect(result).toBe('https://app.example.com/callback')
|
|
309
|
-
expect(handleOAuthCallback).toHaveBeenCalledWith(instance, href)
|
|
310
|
-
expect(getAuthCode).not.toHaveBeenCalled()
|
|
311
|
-
expect(clientFactory).not.toHaveBeenCalled()
|
|
312
|
-
})
|
|
313
280
|
})
|
|
@@ -3,7 +3,6 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
|
3
3
|
import {getAuthLogger} from './authLogger'
|
|
4
4
|
import {AuthStateType} from './authStateType'
|
|
5
5
|
import {authStore, type AuthStoreState, type DashboardContext} from './authStore'
|
|
6
|
-
import {handleOAuthCallback} from './oauth/oauthActions'
|
|
7
6
|
import {
|
|
8
7
|
createLoggedInAuthState,
|
|
9
8
|
getAuthCode,
|
|
@@ -13,10 +12,6 @@ import {
|
|
|
13
12
|
} from './utils'
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
|
-
* Handles the auth redirect callback. In OAuth mode this delegates to
|
|
17
|
-
* {@link handleOAuthCallback}; otherwise it exchanges the legacy `sid` for a
|
|
18
|
-
* long-lived token.
|
|
19
|
-
*
|
|
20
15
|
* @public
|
|
21
16
|
*/
|
|
22
17
|
export const handleAuthCallback = bindActionGlobally(
|
|
@@ -24,7 +19,7 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
24
19
|
async ({state, instance}, locationHref: string = getDefaultLocation()) => {
|
|
25
20
|
const logger = getAuthLogger(instance)
|
|
26
21
|
|
|
27
|
-
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey
|
|
22
|
+
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
|
|
28
23
|
state.get().options
|
|
29
24
|
|
|
30
25
|
// If a token is provided, no need to handle callback
|
|
@@ -33,11 +28,6 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
33
28
|
return false
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
if (oauth) {
|
|
37
|
-
logger.debug('OAuth mode - delegating to handleOAuthCallback')
|
|
38
|
-
return handleOAuthCallback(instance, locationHref)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
31
|
// Don't handle the callback if already in flight.
|
|
42
32
|
const {authState} = state.get()
|
|
43
33
|
if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
|
package/src/auth/logout.test.ts
CHANGED
|
@@ -169,7 +169,7 @@ describe('logout', () => {
|
|
|
169
169
|
expect(removeItem).toHaveBeenCalledWith('__sanity_auth_token')
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
-
it('
|
|
172
|
+
it('clears the OAuth tokens from the store on logout', async () => {
|
|
173
173
|
const oauthTokens: OAuthTokens = {
|
|
174
174
|
accessToken: 'oauth-access',
|
|
175
175
|
tokenType: 'bearer',
|
|
@@ -204,16 +204,8 @@ describe('logout', () => {
|
|
|
204
204
|
// Boots logged in with the persisted OAuth tokens exposed on the store.
|
|
205
205
|
expect(getOAuthTokensState(instance).getCurrent()).toMatchObject({accessToken: 'oauth-access'})
|
|
206
206
|
|
|
207
|
-
const first = logout(instance)
|
|
208
|
-
// Session is marked as destroying so a concurrent call is a no-op
|
|
209
|
-
expect(getAuthState(instance).getCurrent()).toMatchObject({isDestroyingSession: true})
|
|
210
207
|
await logout(instance)
|
|
211
|
-
await first
|
|
212
208
|
|
|
213
|
-
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
214
|
-
expect(mockRequest).toHaveBeenCalledWith(
|
|
215
|
-
expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
|
|
216
|
-
)
|
|
217
209
|
expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
|
|
218
210
|
expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
219
211
|
expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
|
package/src/auth/logout.ts
CHANGED
|
@@ -3,22 +3,14 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
|
3
3
|
import {getAuthLogger} from './authLogger'
|
|
4
4
|
import {AuthStateType} from './authStateType'
|
|
5
5
|
import {authStore} from './authStore'
|
|
6
|
-
import {revokeOAuthTokens} from './oauth/oauthActions'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
* Logs the current user out. In OAuth mode this revokes the OAuth tokens
|
|
10
|
-
* instead of calling the legacy `/auth/logout` endpoint; revocation is
|
|
11
|
-
* best-effort, so the OAuth path resolves even if the revoke request fails,
|
|
12
|
-
* whereas the legacy path rejects when `/auth/logout` fails. Local state and
|
|
13
|
-
* storage are cleared in both cases.
|
|
14
|
-
*
|
|
15
8
|
* @public
|
|
16
9
|
*/
|
|
17
10
|
export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
18
11
|
const logger = getAuthLogger(instance)
|
|
19
12
|
|
|
20
|
-
const {clientFactory, apiHost, providedToken, storageArea, storageKey
|
|
21
|
-
state.get().options
|
|
13
|
+
const {clientFactory, apiHost, providedToken, storageArea, storageKey} = state.get().options
|
|
22
14
|
|
|
23
15
|
// If a token is statically provided, logout does nothing
|
|
24
16
|
if (providedToken) {
|
|
@@ -33,15 +25,6 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
|
|
|
33
25
|
logger.debug('Skipping logout - already in progress')
|
|
34
26
|
return
|
|
35
27
|
}
|
|
36
|
-
|
|
37
|
-
if (oauth) {
|
|
38
|
-
logger.debug('OAuth mode - revoking tokens instead of calling /auth/logout')
|
|
39
|
-
state.set('loggingOut', {
|
|
40
|
-
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: true},
|
|
41
|
-
})
|
|
42
|
-
await revokeOAuthTokens(instance)
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
28
|
const token = authState.type === AuthStateType.LOGGED_IN && authState.token
|
|
46
29
|
|
|
47
30
|
try {
|
|
@@ -9,7 +9,6 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
|
|
|
9
9
|
import {
|
|
10
10
|
getOAuthTokensState,
|
|
11
11
|
handleOAuthCallback,
|
|
12
|
-
OAUTH_RETURN_TO_KEY,
|
|
13
12
|
OAUTH_STATE_KEY,
|
|
14
13
|
OAUTH_VERIFIER_KEY,
|
|
15
14
|
refreshOAuthTokens,
|
|
@@ -157,28 +156,6 @@ describe('startOAuthAuthorization', () => {
|
|
|
157
156
|
expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
|
|
158
157
|
})
|
|
159
158
|
|
|
160
|
-
it('stashes the current location to return to, keeping app params intact', async () => {
|
|
161
|
-
const href = 'https://app.example.com/documents/abc?state=draft&code=x#h'
|
|
162
|
-
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
163
|
-
vi.stubGlobal('location', {href})
|
|
164
|
-
const {session} = setup()
|
|
165
|
-
|
|
166
|
-
await startOAuthAuthorization(instance!)
|
|
167
|
-
|
|
168
|
-
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe(href)
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('strips stale OAuth params from the stashed location when already on the callback route', async () => {
|
|
172
|
-
const href = 'https://app.example.com/callback?x=1&code=old&state=old'
|
|
173
|
-
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
174
|
-
vi.stubGlobal('location', {href})
|
|
175
|
-
const {session} = setup()
|
|
176
|
-
|
|
177
|
-
await startOAuthAuthorization(instance!)
|
|
178
|
-
|
|
179
|
-
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe('https://app.example.com/callback?x=1')
|
|
180
|
-
})
|
|
181
|
-
|
|
182
159
|
it('throws when OAuth is not configured', async () => {
|
|
183
160
|
setup({withOAuthConfig: false})
|
|
184
161
|
await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
|
|
@@ -226,75 +203,6 @@ describe('handleOAuthCallback', () => {
|
|
|
226
203
|
})
|
|
227
204
|
})
|
|
228
205
|
|
|
229
|
-
it('returns the stashed same-origin location after a successful exchange', async () => {
|
|
230
|
-
const returnTo = 'https://app.example.com/documents/abc?x=1'
|
|
231
|
-
const {session} = setup({
|
|
232
|
-
sessionSeed: {
|
|
233
|
-
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
234
|
-
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
235
|
-
[OAUTH_RETURN_TO_KEY]: returnTo,
|
|
236
|
-
},
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(returnTo)
|
|
240
|
-
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
241
|
-
})
|
|
242
|
-
|
|
243
|
-
it('ignores a stashed location on a different origin', async () => {
|
|
244
|
-
setup({
|
|
245
|
-
sessionSeed: {
|
|
246
|
-
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
247
|
-
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
248
|
-
[OAUTH_RETURN_TO_KEY]: 'https://evil.example.com/',
|
|
249
|
-
},
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
253
|
-
'https://app.example.com/callback',
|
|
254
|
-
)
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
it('ignores a stashed location that is not a URL', async () => {
|
|
258
|
-
setup({
|
|
259
|
-
sessionSeed: {
|
|
260
|
-
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
261
|
-
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
262
|
-
[OAUTH_RETURN_TO_KEY]: 'not a url',
|
|
263
|
-
},
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
267
|
-
'https://app.example.com/callback',
|
|
268
|
-
)
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it('does not restore the stashed location on a state mismatch', async () => {
|
|
272
|
-
const {session} = setup({
|
|
273
|
-
sessionSeed: {
|
|
274
|
-
[OAUTH_STATE_KEY]: 'different',
|
|
275
|
-
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
276
|
-
[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep',
|
|
277
|
-
},
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
281
|
-
'https://app.example.com/callback',
|
|
282
|
-
)
|
|
283
|
-
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
it('does not restore the stashed location on an ?error= callback', async () => {
|
|
287
|
-
const {session} = setup({sessionSeed: {[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep'}})
|
|
288
|
-
|
|
289
|
-
const result = await handleOAuthCallback(
|
|
290
|
-
instance!,
|
|
291
|
-
'https://app.example.com/callback?error=access_denied',
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
expect(result).toBe('https://app.example.com/callback')
|
|
295
|
-
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
296
|
-
})
|
|
297
|
-
|
|
298
206
|
it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
|
|
299
207
|
const pending = deferred<typeof tokenResponse>()
|
|
300
208
|
const request = vi.fn().mockReturnValue(pending.promise)
|
|
@@ -313,31 +221,6 @@ describe('handleOAuthCallback', () => {
|
|
|
313
221
|
expect(await first).toBe('https://app.example.com/callback')
|
|
314
222
|
})
|
|
315
223
|
|
|
316
|
-
it('does not clobber LOGGED_IN when called again with a stale callback URL', async () => {
|
|
317
|
-
const {request} = setup({
|
|
318
|
-
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
319
|
-
})
|
|
320
|
-
|
|
321
|
-
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
322
|
-
'https://app.example.com/callback',
|
|
323
|
-
)
|
|
324
|
-
// Artifacts are now cleared; a repeat with the same URL fails state validation.
|
|
325
|
-
// Seed fresh artifacts as if a re-authorization started in this tab: the
|
|
326
|
-
// ignored stale callback must not wipe them.
|
|
327
|
-
sessionStorage.setItem(OAUTH_STATE_KEY, 'state-next')
|
|
328
|
-
sessionStorage.setItem(OAUTH_VERIFIER_KEY, 'verifier-next')
|
|
329
|
-
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
330
|
-
|
|
331
|
-
expect(result).toBe('https://app.example.com/callback')
|
|
332
|
-
expect(request).toHaveBeenCalledTimes(1)
|
|
333
|
-
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
334
|
-
type: AuthStateType.LOGGED_IN,
|
|
335
|
-
token: 'new-access',
|
|
336
|
-
})
|
|
337
|
-
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
338
|
-
expect(sessionStorage.getItem(OAUTH_VERIFIER_KEY)).toBe('verifier-next')
|
|
339
|
-
})
|
|
340
|
-
|
|
341
224
|
it('surfaces an ?error= callback as ERROR without exchanging', async () => {
|
|
342
225
|
const {request} = setup()
|
|
343
226
|
|
|
@@ -351,25 +234,6 @@ describe('handleOAuthCallback', () => {
|
|
|
351
234
|
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
352
235
|
})
|
|
353
236
|
|
|
354
|
-
it('ignores an ?error= callback when a session is already established', async () => {
|
|
355
|
-
setup({
|
|
356
|
-
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
357
|
-
sessionSeed: {[OAUTH_STATE_KEY]: 'state-next', [OAUTH_VERIFIER_KEY]: 'verifier-next'},
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
const result = await handleOAuthCallback(
|
|
361
|
-
instance!,
|
|
362
|
-
'https://app.example.com/callback?error=access_denied',
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
expect(result).toBe('https://app.example.com/callback')
|
|
366
|
-
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
367
|
-
type: AuthStateType.LOGGED_IN,
|
|
368
|
-
token: seededTokens.accessToken,
|
|
369
|
-
})
|
|
370
|
-
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
371
|
-
})
|
|
372
|
-
|
|
373
237
|
it('surfaces an ?error= callback without a description', async () => {
|
|
374
238
|
setup()
|
|
375
239
|
await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
|
|
@@ -17,25 +17,6 @@ export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
|
|
|
17
17
|
/** sessionStorage key for the CSRF `state` value. */
|
|
18
18
|
export const OAUTH_STATE_KEY = '__sanity_oauth_state'
|
|
19
19
|
|
|
20
|
-
/** sessionStorage key for the location to return to after the callback. */
|
|
21
|
-
export const OAUTH_RETURN_TO_KEY = '__sanity_oauth_return_to'
|
|
22
|
-
|
|
23
|
-
const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error', 'error_description']
|
|
24
|
-
|
|
25
|
-
/** Strips the OAuth callback params from a URL. */
|
|
26
|
-
function stripOAuthParams(href: string): string {
|
|
27
|
-
const url = new URL(href, DEFAULT_BASE)
|
|
28
|
-
for (const param of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(param)
|
|
29
|
-
return url.toString()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Whether `href` is the registered redirect URI (origin + pathname; query and hash ignored). */
|
|
33
|
-
function isCallbackRoute(href: string, redirectUri: string): boolean {
|
|
34
|
-
const current = new URL(href, DEFAULT_BASE)
|
|
35
|
-
const redirect = new URL(redirectUri, DEFAULT_BASE)
|
|
36
|
-
return current.origin === redirect.origin && current.pathname === redirect.pathname
|
|
37
|
-
}
|
|
38
|
-
|
|
39
20
|
interface TokenEndpointResponse {
|
|
40
21
|
access_token: string
|
|
41
22
|
token_type: string
|
|
@@ -118,17 +99,6 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
118
99
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
119
100
|
session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
|
|
120
101
|
session?.setItem(OAUTH_STATE_KEY, oauthState)
|
|
121
|
-
// The redirect URI must match the registered one exactly, so the current
|
|
122
|
-
// location (deep link) is stashed here and restored by the callback. OAuth
|
|
123
|
-
// params are only stripped when we are already on the callback route, so
|
|
124
|
-
// legitimate app params like `?state=draft` survive the round trip.
|
|
125
|
-
const currentHref = getDefaultLocation()
|
|
126
|
-
if (currentHref !== DEFAULT_BASE) {
|
|
127
|
-
const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
|
|
128
|
-
? stripOAuthParams(currentHref)
|
|
129
|
-
: currentHref
|
|
130
|
-
session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
|
|
131
|
-
}
|
|
132
102
|
|
|
133
103
|
const authorizeUrl = new URL(
|
|
134
104
|
'/v1/auth/oauth/authorize',
|
|
@@ -153,14 +123,9 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
153
123
|
* responses, exchanges the authorization `code` for tokens, persists them, and
|
|
154
124
|
* transitions to `LOGGED_IN`.
|
|
155
125
|
*
|
|
156
|
-
* Returns the URL for the caller to
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* params. Returns `false` when there was nothing to handle (no code, or an
|
|
160
|
-
* exchange already in progress). A callback that fails validation while a
|
|
161
|
-
* session is already `LOGGED_IN` (e.g. a remount before the params were
|
|
162
|
-
* stripped) is ignored with a warning and still returns the cleaned URL, so a
|
|
163
|
-
* string result does not imply an exchange ran.
|
|
126
|
+
* Returns the callback URL cleaned of OAuth params (for the caller to
|
|
127
|
+
* `history.replaceState`) when a callback was processed, or `false` when there
|
|
128
|
+
* was nothing to handle (no code, or an exchange already in progress).
|
|
164
129
|
*
|
|
165
130
|
* @public
|
|
166
131
|
*/
|
|
@@ -187,29 +152,22 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
187
152
|
|
|
188
153
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
189
154
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
// established: a stale callback URL (e.g. remount before the params were
|
|
194
|
-
// stripped, after the artifacts were cleared) or a denied re-authorization
|
|
195
|
-
// must not clobber LOGGED_IN.
|
|
196
|
-
const rejectCallback = (name: string, message: string): string => {
|
|
197
|
-
if (authState.type === AuthStateType.LOGGED_IN) {
|
|
198
|
-
// Leave PKCE artifacts alone: a re-authorization may be in flight.
|
|
199
|
-
logger.warn(`${message} — ignoring callback, session already established`)
|
|
200
|
-
return cleanedUrl
|
|
201
|
-
}
|
|
202
|
-
logger.error(`${message} — rejecting callback`)
|
|
203
|
-
clearOAuthArtifacts(session)
|
|
204
|
-
state.set(name, {authState: {type: AuthStateType.ERROR, error: new Error(message)}})
|
|
205
|
-
return cleanedUrl
|
|
155
|
+
const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
|
|
156
|
+
for (const param of ['code', 'state', 'error', 'error_description']) {
|
|
157
|
+
cleanedUrlObj.searchParams.delete(param)
|
|
206
158
|
}
|
|
159
|
+
const cleanedUrl = cleanedUrlObj.toString()
|
|
207
160
|
|
|
208
161
|
if (error) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
162
|
+
logger.warn('OAuth callback returned an error', {error, errorDescription})
|
|
163
|
+
clearOAuthArtifacts(session)
|
|
164
|
+
state.set('oauthCallbackError', {
|
|
165
|
+
authState: {
|
|
166
|
+
type: AuthStateType.ERROR,
|
|
167
|
+
error: new Error(errorDescription ? `${error}: ${errorDescription}` : error),
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
return cleanedUrl
|
|
213
171
|
}
|
|
214
172
|
|
|
215
173
|
if (!code) {
|
|
@@ -219,12 +177,22 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
219
177
|
|
|
220
178
|
const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
|
|
221
179
|
if (!returnedState || !storedState || returnedState !== storedState) {
|
|
222
|
-
|
|
180
|
+
logger.error('OAuth state mismatch — rejecting callback')
|
|
181
|
+
clearOAuthArtifacts(session)
|
|
182
|
+
state.set('oauthStateMismatch', {
|
|
183
|
+
authState: {type: AuthStateType.ERROR, error: new Error('OAuth state mismatch')},
|
|
184
|
+
})
|
|
185
|
+
return cleanedUrl
|
|
223
186
|
}
|
|
224
187
|
|
|
225
188
|
const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
|
|
226
189
|
if (!codeVerifier) {
|
|
227
|
-
|
|
190
|
+
logger.error('OAuth code verifier missing — cannot exchange code')
|
|
191
|
+
clearOAuthArtifacts(session)
|
|
192
|
+
state.set('oauthVerifierMissing', {
|
|
193
|
+
authState: {type: AuthStateType.ERROR, error: new Error('OAuth code verifier missing')},
|
|
194
|
+
})
|
|
195
|
+
return cleanedUrl
|
|
228
196
|
}
|
|
229
197
|
|
|
230
198
|
logger.info('Exchanging OAuth code for tokens')
|
|
@@ -252,7 +220,6 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
252
220
|
|
|
253
221
|
const tokens = toOAuthTokens(response)
|
|
254
222
|
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
255
|
-
const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
|
|
256
223
|
clearOAuthArtifacts(session)
|
|
257
224
|
|
|
258
225
|
logger.info('OAuth tokens obtained, user logged in')
|
|
@@ -260,7 +227,7 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
260
227
|
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
261
228
|
oauthTokens: tokens,
|
|
262
229
|
})
|
|
263
|
-
return
|
|
230
|
+
return cleanedUrl
|
|
264
231
|
} catch (exchangeError) {
|
|
265
232
|
logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
|
|
266
233
|
clearOAuthArtifacts(session)
|
|
@@ -419,20 +386,4 @@ export const getOAuthTokensState = bindActionGlobally(
|
|
|
419
386
|
function clearOAuthArtifacts(session: Storage | undefined): void {
|
|
420
387
|
session?.removeItem(OAUTH_VERIFIER_KEY)
|
|
421
388
|
session?.removeItem(OAUTH_STATE_KEY)
|
|
422
|
-
session?.removeItem(OAUTH_RETURN_TO_KEY)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Reads the stashed pre-authorize location, only honouring it when it shares
|
|
427
|
-
* the registered redirect URI's origin so a tampered value cannot redirect
|
|
428
|
-
* off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
|
|
429
|
-
*/
|
|
430
|
-
function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
|
|
431
|
-
const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
|
|
432
|
-
if (!returnTo) return null
|
|
433
|
-
try {
|
|
434
|
-
return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
|
|
435
|
-
} catch {
|
|
436
|
-
return null
|
|
437
|
-
}
|
|
438
389
|
}
|