@sanity/sdk 3.2.0 → 3.3.0-rc.1
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 +3 -3
- package/dist/_exports/_internal.js +4 -4
- package/dist/_exports/agent.d.ts +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/{authStore-BO2vch3I.d.ts → authStore-7dZCfKUq.d.ts} +20 -5
- package/dist/authStore-7dZCfKUq.d.ts.map +1 -0
- package/dist/{clientStore-BI4D8HhJ.js → clientStore-QDrJNTM4.js} +14 -10
- package/dist/clientStore-QDrJNTM4.js.map +1 -0
- package/dist/{createSanityInstance-BQIqyGLX.d.ts → createSanityInstance--Lrldto8.d.ts} +6 -3
- package/dist/createSanityInstance--Lrldto8.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +182 -142
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-D0WqhQGF.d.ts → topicStore-CAnJsiqI.d.ts} +2 -2
- package/dist/{topicStore-D0WqhQGF.d.ts.map → topicStore-CAnJsiqI.d.ts.map} +1 -1
- package/dist/{version-BcRmCiYi.js → version-CYs30C7v.js} +3 -3
- package/dist/{version-BcRmCiYi.js.map → version-CYs30C7v.js.map} +1 -1
- package/package.json +3 -3
- package/src/auth/authStore.test.ts +18 -0
- package/src/auth/authStore.ts +4 -1
- package/src/auth/handleAuthCallback.test.ts +33 -0
- package/src/auth/handleAuthCallback.ts +11 -1
- package/src/auth/logout.test.ts +9 -1
- package/src/auth/logout.ts +18 -1
- package/src/auth/oauth/oauthActions.test.ts +178 -1
- package/src/auth/oauth/oauthActions.ts +111 -46
- package/src/auth/oauth/oauthAuth.test.ts +23 -0
- package/src/auth/oauth/oauthAuth.ts +18 -8
- package/src/config/authConfig.ts +5 -2
- package/dist/authStore-BO2vch3I.d.ts.map +0 -1
- package/dist/clientStore-BI4D8HhJ.js.map +0 -1
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0-rc.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK",
|
|
6
6
|
"keywords": [
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"@sanity/image-url": "^2.1.1",
|
|
54
54
|
"@sanity/json-match": "^1.0.5",
|
|
55
55
|
"@sanity/message-protocol": "^0.24.0",
|
|
56
|
-
"@sanity/mutate": "^0.18.
|
|
56
|
+
"@sanity/mutate": "^0.18.2",
|
|
57
57
|
"@sanity/telemetry": "^1.1.0",
|
|
58
|
-
"@sanity/types": "^6.13.
|
|
58
|
+
"@sanity/types": "^6.13.2",
|
|
59
59
|
"groq": "^6.13.2",
|
|
60
60
|
"groq-js": "^2.0.0",
|
|
61
61
|
"reselect": "^5.1.1",
|
|
@@ -339,6 +339,24 @@ 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
|
+
|
|
342
360
|
it('logs auth initialized in logged out state when no token available', () => {
|
|
343
361
|
instance = createSanityInstance({
|
|
344
362
|
projectId: 'p',
|
package/src/auth/authStore.ts
CHANGED
|
@@ -191,7 +191,10 @@ export const authStore = defineStore<AuthStoreState>({
|
|
|
191
191
|
storageKey: result.storageKey,
|
|
192
192
|
storageArea: result.storageArea,
|
|
193
193
|
authMethod: result.authMethod,
|
|
194
|
-
oauth
|
|
194
|
+
// Only expose oauth options when the oauth strategy is actually in use,
|
|
195
|
+
// so mode-gated actions (logout, handleAuthCallback) stay consistent
|
|
196
|
+
// with the resolved mode (e.g. `studio` takes precedence over `oauth`).
|
|
197
|
+
oauth: mode === 'oauth' ? authConfig.oauth : undefined,
|
|
195
198
|
},
|
|
196
199
|
}
|
|
197
200
|
},
|
|
@@ -5,6 +5,7 @@ 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'
|
|
8
9
|
import {subscribeToStateAndFetchCurrentUser} from './subscribeToStateAndFetchCurrentUser'
|
|
9
10
|
import {subscribeToStorageEventsAndSetToken} from './subscribeToStorageEventsAndSetToken'
|
|
10
11
|
import {getAuthCode, getTokenFromLocation, getTokenFromStorage} from './utils'
|
|
@@ -22,6 +23,11 @@ vi.mock('./utils', async (importOriginal) => {
|
|
|
22
23
|
vi.mock('./subscribeToStateAndFetchCurrentUser')
|
|
23
24
|
vi.mock('./subscribeToStorageEventsAndSetToken')
|
|
24
25
|
|
|
26
|
+
vi.mock('./oauth/oauthActions', async (importOriginal) => {
|
|
27
|
+
const original = await importOriginal<typeof import('./oauth/oauthActions')>()
|
|
28
|
+
return {...original, handleOAuthCallback: vi.fn()}
|
|
29
|
+
})
|
|
30
|
+
|
|
25
31
|
// Mock logger to prevent actual logging during tests
|
|
26
32
|
vi.mock('../utils/logger', async (importOriginal) => {
|
|
27
33
|
const original = await importOriginal<typeof import('../utils/logger')>()
|
|
@@ -277,4 +283,31 @@ describe('handleCallback', () => {
|
|
|
277
283
|
// Verify logger was called
|
|
278
284
|
expect(createLogger).toHaveBeenCalled()
|
|
279
285
|
})
|
|
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
|
+
})
|
|
280
313
|
})
|
|
@@ -3,6 +3,7 @@ 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'
|
|
6
7
|
import {
|
|
7
8
|
createLoggedInAuthState,
|
|
8
9
|
getAuthCode,
|
|
@@ -12,6 +13,10 @@ import {
|
|
|
12
13
|
} from './utils'
|
|
13
14
|
|
|
14
15
|
/**
|
|
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
|
+
*
|
|
15
20
|
* @public
|
|
16
21
|
*/
|
|
17
22
|
export const handleAuthCallback = bindActionGlobally(
|
|
@@ -19,7 +24,7 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
19
24
|
async ({state, instance}, locationHref: string = getDefaultLocation()) => {
|
|
20
25
|
const logger = getAuthLogger(instance)
|
|
21
26
|
|
|
22
|
-
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey} =
|
|
27
|
+
const {providedToken, callbackUrl, clientFactory, apiHost, storageArea, storageKey, oauth} =
|
|
23
28
|
state.get().options
|
|
24
29
|
|
|
25
30
|
// If a token is provided, no need to handle callback
|
|
@@ -28,6 +33,11 @@ export const handleAuthCallback = bindActionGlobally(
|
|
|
28
33
|
return false
|
|
29
34
|
}
|
|
30
35
|
|
|
36
|
+
if (oauth) {
|
|
37
|
+
logger.debug('OAuth mode - delegating to handleOAuthCallback')
|
|
38
|
+
return handleOAuthCallback(instance, locationHref)
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// Don't handle the callback if already in flight.
|
|
32
42
|
const {authState} = state.get()
|
|
33
43
|
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('revokes OAuth tokens instead of calling /auth/logout in OAuth mode', async () => {
|
|
173
173
|
const oauthTokens: OAuthTokens = {
|
|
174
174
|
accessToken: 'oauth-access',
|
|
175
175
|
tokenType: 'bearer',
|
|
@@ -204,8 +204,16 @@ 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})
|
|
207
210
|
await logout(instance)
|
|
211
|
+
await first
|
|
208
212
|
|
|
213
|
+
expect(mockRequest).toHaveBeenCalledTimes(1)
|
|
214
|
+
expect(mockRequest).toHaveBeenCalledWith(
|
|
215
|
+
expect.objectContaining({url: '/auth/oauth/revoke', tag: 'oauth.revoke'}),
|
|
216
|
+
)
|
|
209
217
|
expect(getOAuthTokensState(instance).getCurrent()).toBeNull()
|
|
210
218
|
expect(getAuthState(instance).getCurrent()).toMatchObject({type: AuthStateType.LOGGED_OUT})
|
|
211
219
|
expect(map.get(OAUTH_TOKENS_KEY)).toBeUndefined()
|
package/src/auth/logout.ts
CHANGED
|
@@ -3,14 +3,22 @@ import {DEFAULT_API_VERSION, REQUEST_TAG_PREFIX} from './authConstants'
|
|
|
3
3
|
import {getAuthLogger} from './authLogger'
|
|
4
4
|
import {AuthStateType} from './authStateType'
|
|
5
5
|
import {authStore} from './authStore'
|
|
6
|
+
import {revokeOAuthTokens} from './oauth/oauthActions'
|
|
6
7
|
|
|
7
8
|
/**
|
|
9
|
+
* Logs the current user out. In OAuth mode this revokes the OAuth tokens
|
|
10
|
+
* instead of calling the legacy `/auth/logout` endpoint; revocation is
|
|
11
|
+
* best-effort, so the OAuth path resolves even if the revoke request fails,
|
|
12
|
+
* whereas the legacy path rejects when `/auth/logout` fails. Local state and
|
|
13
|
+
* storage are cleared in both cases.
|
|
14
|
+
*
|
|
8
15
|
* @public
|
|
9
16
|
*/
|
|
10
17
|
export const logout = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
11
18
|
const logger = getAuthLogger(instance)
|
|
12
19
|
|
|
13
|
-
const {clientFactory, apiHost, providedToken, storageArea, storageKey} =
|
|
20
|
+
const {clientFactory, apiHost, providedToken, storageArea, storageKey, oauth} =
|
|
21
|
+
state.get().options
|
|
14
22
|
|
|
15
23
|
// If a token is statically provided, logout does nothing
|
|
16
24
|
if (providedToken) {
|
|
@@ -25,6 +33,15 @@ export const logout = bindActionGlobally(authStore, async ({state, instance}) =>
|
|
|
25
33
|
logger.debug('Skipping logout - already in progress')
|
|
26
34
|
return
|
|
27
35
|
}
|
|
36
|
+
|
|
37
|
+
if (oauth) {
|
|
38
|
+
logger.debug('OAuth mode - revoking tokens instead of calling /auth/logout')
|
|
39
|
+
state.set('loggingOut', {
|
|
40
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: true},
|
|
41
|
+
})
|
|
42
|
+
await revokeOAuthTokens(instance)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
28
45
|
const token = authState.type === AuthStateType.LOGGED_IN && authState.token
|
|
29
46
|
|
|
30
47
|
try {
|
|
@@ -9,6 +9,7 @@ import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCu
|
|
|
9
9
|
import {
|
|
10
10
|
getOAuthTokensState,
|
|
11
11
|
handleOAuthCallback,
|
|
12
|
+
OAUTH_RETURN_TO_KEY,
|
|
12
13
|
OAUTH_STATE_KEY,
|
|
13
14
|
OAUTH_VERIFIER_KEY,
|
|
14
15
|
refreshOAuthTokens,
|
|
@@ -42,6 +43,11 @@ const oauthConfig = {
|
|
|
42
43
|
organizationId: 'org123',
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
const oauthConfigNoOrg = {
|
|
47
|
+
clientId: 'client-abc',
|
|
48
|
+
redirectUri: 'https://app.example.com/callback',
|
|
49
|
+
}
|
|
50
|
+
|
|
45
51
|
const seededTokens: OAuthTokens = {
|
|
46
52
|
accessToken: 'stored-access',
|
|
47
53
|
tokenType: 'bearer',
|
|
@@ -93,6 +99,7 @@ interface SetupOptions {
|
|
|
93
99
|
sessionSeed?: Record<string, string>
|
|
94
100
|
initialLocationHref?: string
|
|
95
101
|
withOAuthConfig?: boolean
|
|
102
|
+
oauthConfig?: {clientId: string; redirectUri: string; organizationId?: string}
|
|
96
103
|
apiHost?: string
|
|
97
104
|
}
|
|
98
105
|
|
|
@@ -111,7 +118,7 @@ function setup(options: SetupOptions = {}) {
|
|
|
111
118
|
storageArea,
|
|
112
119
|
initialLocationHref: options.initialLocationHref ?? 'https://app.example.com/',
|
|
113
120
|
...(options.apiHost && {apiHost: options.apiHost}),
|
|
114
|
-
...(options.withOAuthConfig === false ? {} : {oauth: oauthConfig}),
|
|
121
|
+
...(options.withOAuthConfig === false ? {} : {oauth: options.oauthConfig ?? oauthConfig}),
|
|
115
122
|
},
|
|
116
123
|
})
|
|
117
124
|
|
|
@@ -156,6 +163,39 @@ describe('startOAuthAuthorization', () => {
|
|
|
156
163
|
expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
|
|
157
164
|
})
|
|
158
165
|
|
|
166
|
+
it('stashes the current location to return to, keeping app params intact', async () => {
|
|
167
|
+
const href = 'https://app.example.com/documents/abc?state=draft&code=x#h'
|
|
168
|
+
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
169
|
+
vi.stubGlobal('location', {href})
|
|
170
|
+
const {session} = setup()
|
|
171
|
+
|
|
172
|
+
await startOAuthAuthorization(instance!)
|
|
173
|
+
|
|
174
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe(href)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('strips stale OAuth params from the stashed location when already on the callback route', async () => {
|
|
178
|
+
const href = 'https://app.example.com/callback?x=1&code=old&state=old'
|
|
179
|
+
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
180
|
+
vi.stubGlobal('location', {href})
|
|
181
|
+
const {session} = setup()
|
|
182
|
+
|
|
183
|
+
await startOAuthAuthorization(instance!)
|
|
184
|
+
|
|
185
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe('https://app.example.com/callback?x=1')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('omits the resource param when no organizationId is configured', async () => {
|
|
189
|
+
const assign = vi.fn()
|
|
190
|
+
vi.stubGlobal('window', {location: {assign}})
|
|
191
|
+
setup({initialLocationHref: 'https://app.example.com/', oauthConfig: oauthConfigNoOrg})
|
|
192
|
+
|
|
193
|
+
await startOAuthAuthorization(instance!)
|
|
194
|
+
|
|
195
|
+
const url = new URL(assign.mock.calls[0][0])
|
|
196
|
+
expect(url.searchParams.has('resource')).toBe(false)
|
|
197
|
+
})
|
|
198
|
+
|
|
159
199
|
it('throws when OAuth is not configured', async () => {
|
|
160
200
|
setup({withOAuthConfig: false})
|
|
161
201
|
await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
|
|
@@ -203,6 +243,75 @@ describe('handleOAuthCallback', () => {
|
|
|
203
243
|
})
|
|
204
244
|
})
|
|
205
245
|
|
|
246
|
+
it('returns the stashed same-origin location after a successful exchange', async () => {
|
|
247
|
+
const returnTo = 'https://app.example.com/documents/abc?x=1'
|
|
248
|
+
const {session} = setup({
|
|
249
|
+
sessionSeed: {
|
|
250
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
251
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
252
|
+
[OAUTH_RETURN_TO_KEY]: returnTo,
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(returnTo)
|
|
257
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('ignores a stashed location on a different origin', async () => {
|
|
261
|
+
setup({
|
|
262
|
+
sessionSeed: {
|
|
263
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
264
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
265
|
+
[OAUTH_RETURN_TO_KEY]: 'https://evil.example.com/',
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
270
|
+
'https://app.example.com/callback',
|
|
271
|
+
)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('ignores a stashed location that is not a URL', async () => {
|
|
275
|
+
setup({
|
|
276
|
+
sessionSeed: {
|
|
277
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
278
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
279
|
+
[OAUTH_RETURN_TO_KEY]: 'not a url',
|
|
280
|
+
},
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
284
|
+
'https://app.example.com/callback',
|
|
285
|
+
)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('does not restore the stashed location on a state mismatch', async () => {
|
|
289
|
+
const {session} = setup({
|
|
290
|
+
sessionSeed: {
|
|
291
|
+
[OAUTH_STATE_KEY]: 'different',
|
|
292
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
293
|
+
[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep',
|
|
294
|
+
},
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
298
|
+
'https://app.example.com/callback',
|
|
299
|
+
)
|
|
300
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('does not restore the stashed location on an ?error= callback', async () => {
|
|
304
|
+
const {session} = setup({sessionSeed: {[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep'}})
|
|
305
|
+
|
|
306
|
+
const result = await handleOAuthCallback(
|
|
307
|
+
instance!,
|
|
308
|
+
'https://app.example.com/callback?error=access_denied',
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
312
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
313
|
+
})
|
|
314
|
+
|
|
206
315
|
it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
|
|
207
316
|
const pending = deferred<typeof tokenResponse>()
|
|
208
317
|
const request = vi.fn().mockReturnValue(pending.promise)
|
|
@@ -221,6 +330,31 @@ describe('handleOAuthCallback', () => {
|
|
|
221
330
|
expect(await first).toBe('https://app.example.com/callback')
|
|
222
331
|
})
|
|
223
332
|
|
|
333
|
+
it('does not clobber LOGGED_IN when called again with a stale callback URL', async () => {
|
|
334
|
+
const {request} = setup({
|
|
335
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
339
|
+
'https://app.example.com/callback',
|
|
340
|
+
)
|
|
341
|
+
// Artifacts are now cleared; a repeat with the same URL fails state validation.
|
|
342
|
+
// Seed fresh artifacts as if a re-authorization started in this tab: the
|
|
343
|
+
// ignored stale callback must not wipe them.
|
|
344
|
+
sessionStorage.setItem(OAUTH_STATE_KEY, 'state-next')
|
|
345
|
+
sessionStorage.setItem(OAUTH_VERIFIER_KEY, 'verifier-next')
|
|
346
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
347
|
+
|
|
348
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
349
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
350
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
351
|
+
type: AuthStateType.LOGGED_IN,
|
|
352
|
+
token: 'new-access',
|
|
353
|
+
})
|
|
354
|
+
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
355
|
+
expect(sessionStorage.getItem(OAUTH_VERIFIER_KEY)).toBe('verifier-next')
|
|
356
|
+
})
|
|
357
|
+
|
|
224
358
|
it('surfaces an ?error= callback as ERROR without exchanging', async () => {
|
|
225
359
|
const {request} = setup()
|
|
226
360
|
|
|
@@ -234,6 +368,25 @@ describe('handleOAuthCallback', () => {
|
|
|
234
368
|
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
235
369
|
})
|
|
236
370
|
|
|
371
|
+
it('ignores an ?error= callback when a session is already established', async () => {
|
|
372
|
+
setup({
|
|
373
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
374
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-next', [OAUTH_VERIFIER_KEY]: 'verifier-next'},
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
const result = await handleOAuthCallback(
|
|
378
|
+
instance!,
|
|
379
|
+
'https://app.example.com/callback?error=access_denied',
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
383
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
384
|
+
type: AuthStateType.LOGGED_IN,
|
|
385
|
+
token: seededTokens.accessToken,
|
|
386
|
+
})
|
|
387
|
+
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
388
|
+
})
|
|
389
|
+
|
|
237
390
|
it('surfaces an ?error= callback without a description', async () => {
|
|
238
391
|
setup()
|
|
239
392
|
await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
|
|
@@ -269,6 +422,18 @@ describe('handleOAuthCallback', () => {
|
|
|
269
422
|
expect(request).not.toHaveBeenCalled()
|
|
270
423
|
})
|
|
271
424
|
|
|
425
|
+
it('omits the resource param when no organizationId is configured', async () => {
|
|
426
|
+
const {request} = setup({
|
|
427
|
+
oauthConfig: oauthConfigNoOrg,
|
|
428
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
await handleOAuthCallback(instance!, callbackHref)
|
|
432
|
+
|
|
433
|
+
const body = parseBody(request.mock.calls[0][0].body)
|
|
434
|
+
expect(body.has('resource')).toBe(false)
|
|
435
|
+
})
|
|
436
|
+
|
|
272
437
|
it('sets ERROR when the token exchange fails', async () => {
|
|
273
438
|
const request = vi.fn().mockRejectedValue(new Error('boom'))
|
|
274
439
|
const {session} = setup({
|
|
@@ -303,6 +468,18 @@ describe('refreshOAuthTokens', () => {
|
|
|
303
468
|
expect(readStored(storageArea)).toMatchObject({accessToken: 'new-access'})
|
|
304
469
|
})
|
|
305
470
|
|
|
471
|
+
it('omits the resource param when no organizationId is configured', async () => {
|
|
472
|
+
const {request} = setup({
|
|
473
|
+
oauthConfig: oauthConfigNoOrg,
|
|
474
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
await refreshOAuthTokens(instance!)
|
|
478
|
+
|
|
479
|
+
const body = parseBody(request.mock.calls[0][0].body)
|
|
480
|
+
expect(body.has('resource')).toBe(false)
|
|
481
|
+
})
|
|
482
|
+
|
|
306
483
|
it('shares a single in-flight request across concurrent callers', async () => {
|
|
307
484
|
const pending = deferred<typeof tokenResponse>()
|
|
308
485
|
const request = vi.fn().mockReturnValue(pending.promise)
|