@sanity/sdk 3.2.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.
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.js +4 -4
- 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-BGoEGxOq.d.ts} +19 -4
- package/dist/authStore-BGoEGxOq.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/index.d.ts +1 -1
- package/dist/index.js +156 -123
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-D0WqhQGF.d.ts → topicStore-DXvuH9h4.d.ts} +2 -2
- package/dist/{topicStore-D0WqhQGF.d.ts.map → topicStore-DXvuH9h4.d.ts.map} +1 -1
- package/dist/{version-BcRmCiYi.js → version-CIhX5ppJ.js} +3 -3
- package/dist/{version-BcRmCiYi.js.map → version-CIhX5ppJ.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 +136 -0
- package/src/auth/oauth/oauthActions.ts +78 -29
- package/src/auth/oauth/oauthAuth.test.ts +23 -0
- package/src/auth/oauth/oauthAuth.ts +18 -8
- package/dist/authStore-BO2vch3I.d.ts.map +0 -1
- package/dist/clientStore-BI4D8HhJ.js.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.0",
|
|
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,
|
|
@@ -156,6 +157,28 @@ describe('startOAuthAuthorization', () => {
|
|
|
156
157
|
expect(url.searchParams.getAll('resource')).toEqual(['urn:io.sanity:organization:org123'])
|
|
157
158
|
})
|
|
158
159
|
|
|
160
|
+
it('stashes the current location to return to, keeping app params intact', async () => {
|
|
161
|
+
const href = 'https://app.example.com/documents/abc?state=draft&code=x#h'
|
|
162
|
+
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
163
|
+
vi.stubGlobal('location', {href})
|
|
164
|
+
const {session} = setup()
|
|
165
|
+
|
|
166
|
+
await startOAuthAuthorization(instance!)
|
|
167
|
+
|
|
168
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe(href)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('strips stale OAuth params from the stashed location when already on the callback route', async () => {
|
|
172
|
+
const href = 'https://app.example.com/callback?x=1&code=old&state=old'
|
|
173
|
+
vi.stubGlobal('window', {location: {assign: vi.fn(), href}})
|
|
174
|
+
vi.stubGlobal('location', {href})
|
|
175
|
+
const {session} = setup()
|
|
176
|
+
|
|
177
|
+
await startOAuthAuthorization(instance!)
|
|
178
|
+
|
|
179
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBe('https://app.example.com/callback?x=1')
|
|
180
|
+
})
|
|
181
|
+
|
|
159
182
|
it('throws when OAuth is not configured', async () => {
|
|
160
183
|
setup({withOAuthConfig: false})
|
|
161
184
|
await expect(startOAuthAuthorization(instance!)).rejects.toThrow(/OAuth is not configured/)
|
|
@@ -203,6 +226,75 @@ describe('handleOAuthCallback', () => {
|
|
|
203
226
|
})
|
|
204
227
|
})
|
|
205
228
|
|
|
229
|
+
it('returns the stashed same-origin location after a successful exchange', async () => {
|
|
230
|
+
const returnTo = 'https://app.example.com/documents/abc?x=1'
|
|
231
|
+
const {session} = setup({
|
|
232
|
+
sessionSeed: {
|
|
233
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
234
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
235
|
+
[OAUTH_RETURN_TO_KEY]: returnTo,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(returnTo)
|
|
240
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('ignores a stashed location on a different origin', async () => {
|
|
244
|
+
setup({
|
|
245
|
+
sessionSeed: {
|
|
246
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
247
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
248
|
+
[OAUTH_RETURN_TO_KEY]: 'https://evil.example.com/',
|
|
249
|
+
},
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
253
|
+
'https://app.example.com/callback',
|
|
254
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('ignores a stashed location that is not a URL', async () => {
|
|
258
|
+
setup({
|
|
259
|
+
sessionSeed: {
|
|
260
|
+
[OAUTH_STATE_KEY]: 'state-xyz',
|
|
261
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
262
|
+
[OAUTH_RETURN_TO_KEY]: 'not a url',
|
|
263
|
+
},
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
267
|
+
'https://app.example.com/callback',
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('does not restore the stashed location on a state mismatch', async () => {
|
|
272
|
+
const {session} = setup({
|
|
273
|
+
sessionSeed: {
|
|
274
|
+
[OAUTH_STATE_KEY]: 'different',
|
|
275
|
+
[OAUTH_VERIFIER_KEY]: 'verifier-1',
|
|
276
|
+
[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep',
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
281
|
+
'https://app.example.com/callback',
|
|
282
|
+
)
|
|
283
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('does not restore the stashed location on an ?error= callback', async () => {
|
|
287
|
+
const {session} = setup({sessionSeed: {[OAUTH_RETURN_TO_KEY]: 'https://app.example.com/deep'}})
|
|
288
|
+
|
|
289
|
+
const result = await handleOAuthCallback(
|
|
290
|
+
instance!,
|
|
291
|
+
'https://app.example.com/callback?error=access_denied',
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
295
|
+
expect(session.getItem(OAUTH_RETURN_TO_KEY)).toBeNull()
|
|
296
|
+
})
|
|
297
|
+
|
|
206
298
|
it('does not perform a second exchange on a duplicate concurrent invocation', async () => {
|
|
207
299
|
const pending = deferred<typeof tokenResponse>()
|
|
208
300
|
const request = vi.fn().mockReturnValue(pending.promise)
|
|
@@ -221,6 +313,31 @@ describe('handleOAuthCallback', () => {
|
|
|
221
313
|
expect(await first).toBe('https://app.example.com/callback')
|
|
222
314
|
})
|
|
223
315
|
|
|
316
|
+
it('does not clobber LOGGED_IN when called again with a stale callback URL', async () => {
|
|
317
|
+
const {request} = setup({
|
|
318
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-xyz', [OAUTH_VERIFIER_KEY]: 'verifier-1'},
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(await handleOAuthCallback(instance!, callbackHref)).toBe(
|
|
322
|
+
'https://app.example.com/callback',
|
|
323
|
+
)
|
|
324
|
+
// Artifacts are now cleared; a repeat with the same URL fails state validation.
|
|
325
|
+
// Seed fresh artifacts as if a re-authorization started in this tab: the
|
|
326
|
+
// ignored stale callback must not wipe them.
|
|
327
|
+
sessionStorage.setItem(OAUTH_STATE_KEY, 'state-next')
|
|
328
|
+
sessionStorage.setItem(OAUTH_VERIFIER_KEY, 'verifier-next')
|
|
329
|
+
const result = await handleOAuthCallback(instance!, callbackHref)
|
|
330
|
+
|
|
331
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
332
|
+
expect(request).toHaveBeenCalledTimes(1)
|
|
333
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
334
|
+
type: AuthStateType.LOGGED_IN,
|
|
335
|
+
token: 'new-access',
|
|
336
|
+
})
|
|
337
|
+
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
338
|
+
expect(sessionStorage.getItem(OAUTH_VERIFIER_KEY)).toBe('verifier-next')
|
|
339
|
+
})
|
|
340
|
+
|
|
224
341
|
it('surfaces an ?error= callback as ERROR without exchanging', async () => {
|
|
225
342
|
const {request} = setup()
|
|
226
343
|
|
|
@@ -234,6 +351,25 @@ describe('handleOAuthCallback', () => {
|
|
|
234
351
|
expect(getAuthState(instance!).getCurrent()).toMatchObject({type: AuthStateType.ERROR})
|
|
235
352
|
})
|
|
236
353
|
|
|
354
|
+
it('ignores an ?error= callback when a session is already established', async () => {
|
|
355
|
+
setup({
|
|
356
|
+
storageSeed: {[OAUTH_TOKENS_KEY]: serializeTokens(seededTokens)},
|
|
357
|
+
sessionSeed: {[OAUTH_STATE_KEY]: 'state-next', [OAUTH_VERIFIER_KEY]: 'verifier-next'},
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const result = await handleOAuthCallback(
|
|
361
|
+
instance!,
|
|
362
|
+
'https://app.example.com/callback?error=access_denied',
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
expect(result).toBe('https://app.example.com/callback')
|
|
366
|
+
expect(getAuthState(instance!).getCurrent()).toMatchObject({
|
|
367
|
+
type: AuthStateType.LOGGED_IN,
|
|
368
|
+
token: seededTokens.accessToken,
|
|
369
|
+
})
|
|
370
|
+
expect(sessionStorage.getItem(OAUTH_STATE_KEY)).toBe('state-next')
|
|
371
|
+
})
|
|
372
|
+
|
|
237
373
|
it('surfaces an ?error= callback without a description', async () => {
|
|
238
374
|
setup()
|
|
239
375
|
await handleOAuthCallback(instance!, 'https://app.example.com/callback?error=access_denied')
|
|
@@ -17,6 +17,25 @@ 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
|
+
|
|
20
39
|
interface TokenEndpointResponse {
|
|
21
40
|
access_token: string
|
|
22
41
|
token_type: string
|
|
@@ -99,6 +118,17 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
99
118
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
100
119
|
session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
|
|
101
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
|
+
}
|
|
102
132
|
|
|
103
133
|
const authorizeUrl = new URL(
|
|
104
134
|
'/v1/auth/oauth/authorize',
|
|
@@ -123,9 +153,14 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
123
153
|
* responses, exchanges the authorization `code` for tokens, persists them, and
|
|
124
154
|
* transitions to `LOGGED_IN`.
|
|
125
155
|
*
|
|
126
|
-
* Returns the
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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.
|
|
129
164
|
*
|
|
130
165
|
* @public
|
|
131
166
|
*/
|
|
@@ -152,22 +187,29 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
152
187
|
|
|
153
188
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
154
189
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
158
206
|
}
|
|
159
|
-
const cleanedUrl = cleanedUrlObj.toString()
|
|
160
207
|
|
|
161
208
|
if (error) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
type: AuthStateType.ERROR,
|
|
167
|
-
error: new Error(errorDescription ? `${error}: ${errorDescription}` : error),
|
|
168
|
-
},
|
|
169
|
-
})
|
|
170
|
-
return cleanedUrl
|
|
209
|
+
return rejectCallback(
|
|
210
|
+
'oauthCallbackError',
|
|
211
|
+
errorDescription ? `${error}: ${errorDescription}` : error,
|
|
212
|
+
)
|
|
171
213
|
}
|
|
172
214
|
|
|
173
215
|
if (!code) {
|
|
@@ -177,22 +219,12 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
177
219
|
|
|
178
220
|
const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
|
|
179
221
|
if (!returnedState || !storedState || returnedState !== storedState) {
|
|
180
|
-
|
|
181
|
-
clearOAuthArtifacts(session)
|
|
182
|
-
state.set('oauthStateMismatch', {
|
|
183
|
-
authState: {type: AuthStateType.ERROR, error: new Error('OAuth state mismatch')},
|
|
184
|
-
})
|
|
185
|
-
return cleanedUrl
|
|
222
|
+
return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
|
|
186
223
|
}
|
|
187
224
|
|
|
188
225
|
const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
|
|
189
226
|
if (!codeVerifier) {
|
|
190
|
-
|
|
191
|
-
clearOAuthArtifacts(session)
|
|
192
|
-
state.set('oauthVerifierMissing', {
|
|
193
|
-
authState: {type: AuthStateType.ERROR, error: new Error('OAuth code verifier missing')},
|
|
194
|
-
})
|
|
195
|
-
return cleanedUrl
|
|
227
|
+
return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
logger.info('Exchanging OAuth code for tokens')
|
|
@@ -220,6 +252,7 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
220
252
|
|
|
221
253
|
const tokens = toOAuthTokens(response)
|
|
222
254
|
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
255
|
+
const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
|
|
223
256
|
clearOAuthArtifacts(session)
|
|
224
257
|
|
|
225
258
|
logger.info('OAuth tokens obtained, user logged in')
|
|
@@ -227,7 +260,7 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
227
260
|
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
228
261
|
oauthTokens: tokens,
|
|
229
262
|
})
|
|
230
|
-
return cleanedUrl
|
|
263
|
+
return returnTo ?? cleanedUrl
|
|
231
264
|
} catch (exchangeError) {
|
|
232
265
|
logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
|
|
233
266
|
clearOAuthArtifacts(session)
|
|
@@ -386,4 +419,20 @@ export const getOAuthTokensState = bindActionGlobally(
|
|
|
386
419
|
function clearOAuthArtifacts(session: Storage | undefined): void {
|
|
387
420
|
session?.removeItem(OAUTH_VERIFIER_KEY)
|
|
388
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
|
+
}
|
|
389
438
|
}
|
|
@@ -83,6 +83,11 @@ describe('serialize/deserialize tokens', () => {
|
|
|
83
83
|
expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
|
|
84
84
|
expect(deserializeTokens('123')).toBeNull()
|
|
85
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
|
+
})
|
|
86
91
|
})
|
|
87
92
|
|
|
88
93
|
describe('getOauthInitialState', () => {
|
|
@@ -94,6 +99,16 @@ describe('getOauthInitialState', () => {
|
|
|
94
99
|
expect(result.authMethod).toBe('localstorage')
|
|
95
100
|
})
|
|
96
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
|
+
|
|
97
112
|
it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
|
|
98
113
|
const storageArea = createMemoryStorage()
|
|
99
114
|
const result = getOauthInitialState(
|
|
@@ -110,6 +125,14 @@ describe('getOauthInitialState', () => {
|
|
|
110
125
|
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
111
126
|
})
|
|
112
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
|
+
|
|
113
136
|
it('returns LOGGED_OUT when there are no tokens and no callback', () => {
|
|
114
137
|
const storageArea = createMemoryStorage()
|
|
115
138
|
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
@@ -19,7 +19,9 @@ interface SerializedOAuthTokens extends Omit<OAuthTokens, 'expiresAt'> {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Parses persisted token JSON back into {@link OAuthTokens}. Returns `null`
|
|
22
|
-
* when the value is missing or malformed
|
|
22
|
+
* when the value is missing or malformed, including an `expiresAt` that does
|
|
23
|
+
* not parse to a valid date (an `Invalid Date` would otherwise read as never
|
|
24
|
+
* expiring, since `NaN <= now` is always `false`).
|
|
23
25
|
*
|
|
24
26
|
* @internal
|
|
25
27
|
*/
|
|
@@ -38,11 +40,13 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
|
|
|
38
40
|
return null
|
|
39
41
|
}
|
|
40
42
|
const value = parsed as SerializedOAuthTokens
|
|
43
|
+
const expiresAt = new Date(value.expiresAt)
|
|
44
|
+
if (Number.isNaN(expiresAt.getTime())) return null
|
|
41
45
|
return {
|
|
42
46
|
accessToken: value.accessToken,
|
|
43
47
|
tokenType: 'bearer',
|
|
44
48
|
expiresIn: value.expiresIn,
|
|
45
|
-
expiresAt
|
|
49
|
+
expiresAt,
|
|
46
50
|
...(value.refreshToken !== undefined && {refreshToken: value.refreshToken}),
|
|
47
51
|
}
|
|
48
52
|
} catch {
|
|
@@ -55,7 +59,7 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
|
|
|
55
59
|
*
|
|
56
60
|
* State discovery order:
|
|
57
61
|
* 1. Persisted tokens in `__sanity_oauth_tokens` → `LOGGED_IN`
|
|
58
|
-
* 2. Callback URL (contains `code
|
|
62
|
+
* 2. Callback URL (contains `code` or `error` and matches `redirectUri`)
|
|
59
63
|
* → `LOGGING_IN`
|
|
60
64
|
* 3. Otherwise → `LOGGED_OUT`
|
|
61
65
|
*
|
|
@@ -68,7 +72,12 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
|
|
|
68
72
|
const redirectUri = authConfig.oauth?.redirectUri
|
|
69
73
|
|
|
70
74
|
// Persisted tokens win
|
|
71
|
-
const
|
|
75
|
+
const rawTokens = storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null
|
|
76
|
+
const tokens = deserializeTokens(rawTokens)
|
|
77
|
+
if (rawTokens && !tokens) {
|
|
78
|
+
// Corrupt entry: drop it so it cannot keep shadowing a fresh login
|
|
79
|
+
storageArea?.removeItem(OAUTH_TOKENS_KEY)
|
|
80
|
+
}
|
|
72
81
|
if (tokens) {
|
|
73
82
|
return {
|
|
74
83
|
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
@@ -80,11 +89,12 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
|
|
|
80
89
|
}
|
|
81
90
|
}
|
|
82
91
|
|
|
83
|
-
// Callback URL with code/
|
|
84
|
-
//
|
|
92
|
+
// Callback URL with code/error whose origin + pathname match our redirect
|
|
93
|
+
// URI (query and hash are ignored). `state` alone is not a callback: the
|
|
94
|
+
// server always sends `code` or `error`, and handleOAuthCallback would
|
|
95
|
+
// otherwise leave the app stuck in LOGGING_IN.
|
|
85
96
|
const {searchParams} = new URL(initialLocationHref, DEFAULT_BASE)
|
|
86
|
-
const isCallback =
|
|
87
|
-
searchParams.has('code') || searchParams.has('state') || searchParams.has('error')
|
|
97
|
+
const isCallback = searchParams.has('code') || searchParams.has('error')
|
|
88
98
|
if (redirectUri && isCallback) {
|
|
89
99
|
const loc = new URL(initialLocationHref, DEFAULT_BASE)
|
|
90
100
|
const redirect = new URL(redirectUri, DEFAULT_BASE)
|