@sanity/sdk-react 3.3.0 → 3.4.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/index.d.ts +139 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -26
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/_exports/sdk-react.ts +3 -0
- package/src/components/auth/AuthBoundary.test.tsx +81 -2
- package/src/components/auth/AuthBoundary.tsx +17 -3
- package/src/components/auth/LoginCallback.test.tsx +46 -7
- package/src/components/auth/LoginCallback.tsx +22 -4
- package/src/hooks/auth/useHandleOAuthCallback.test.tsx +16 -0
- package/src/hooks/auth/useHandleOAuthCallback.tsx +49 -0
- package/src/hooks/auth/useOAuthAuthorize.test.tsx +16 -0
- package/src/hooks/auth/useOAuthAuthorize.tsx +28 -0
- package/src/hooks/auth/useOAuthTokens.test.tsx +240 -0
- package/src/hooks/auth/useOAuthTokens.tsx +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sanity/sdk-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0-rc.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Sanity SDK React toolkit for Content OS",
|
|
6
6
|
"keywords": [
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@module-federation/runtime": "^2.9.0",
|
|
46
46
|
"@sanity/client": "^8.6.2",
|
|
47
47
|
"@sanity/message-protocol": "^0.24.0",
|
|
48
|
-
"@sanity/sdk": "3.
|
|
48
|
+
"@sanity/sdk": "3.4.0-rc.0",
|
|
49
49
|
"@sanity/types": "^6.14.1",
|
|
50
50
|
"react-compiler-runtime": "^1.0.0",
|
|
51
51
|
"react-error-boundary": "^6.1.5",
|
|
@@ -29,8 +29,11 @@ export {useAuthState} from '../hooks/auth/useAuthState'
|
|
|
29
29
|
export {useAuthToken} from '../hooks/auth/useAuthToken'
|
|
30
30
|
export {useCurrentUser} from '../hooks/auth/useCurrentUser'
|
|
31
31
|
export {useHandleAuthCallback} from '../hooks/auth/useHandleAuthCallback'
|
|
32
|
+
export {useHandleOAuthCallback} from '../hooks/auth/useHandleOAuthCallback'
|
|
32
33
|
export {useLoginUrl} from '../hooks/auth/useLoginUrl'
|
|
33
34
|
export {useLogOut} from '../hooks/auth/useLogOut'
|
|
35
|
+
export {useOAuthAuthorize} from '../hooks/auth/useOAuthAuthorize'
|
|
36
|
+
export {useOAuthTokens, type UseOAuthTokensResult} from '../hooks/auth/useOAuthTokens'
|
|
34
37
|
export {useVerifyOrgProjects} from '../hooks/auth/useVerifyOrgProjects'
|
|
35
38
|
export {useClient} from '../hooks/client/useClient'
|
|
36
39
|
export {
|
|
@@ -9,6 +9,7 @@ import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh
|
|
|
9
9
|
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
10
10
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
11
11
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
12
|
+
import {useOAuthAuthorize} from '../../hooks/auth/useOAuthAuthorize'
|
|
12
13
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
13
14
|
import {AuthBoundary} from './AuthBoundary'
|
|
14
15
|
|
|
@@ -17,6 +18,9 @@ vi.mock('../../hooks/auth/useAuthState', () => ({
|
|
|
17
18
|
useAuthState: vi.fn(() => 'logged-out'),
|
|
18
19
|
}))
|
|
19
20
|
vi.mock('../../hooks/auth/useLoginUrl')
|
|
21
|
+
vi.mock('../../hooks/auth/useOAuthAuthorize', () => ({
|
|
22
|
+
useOAuthAuthorize: vi.fn(() => vi.fn().mockResolvedValue(undefined)),
|
|
23
|
+
}))
|
|
20
24
|
vi.mock('../../hooks/auth/useVerifyOrgProjects')
|
|
21
25
|
vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
22
26
|
useHandleAuthCallback: vi.fn(() => async () => {}),
|
|
@@ -34,8 +38,8 @@ vi.mock('./AuthError', async (importOriginal) => {
|
|
|
34
38
|
return {
|
|
35
39
|
...actual,
|
|
36
40
|
AuthError: class MockAuthError extends Error {
|
|
37
|
-
constructor(error:
|
|
38
|
-
super(error.message)
|
|
41
|
+
constructor(error: unknown) {
|
|
42
|
+
super(error instanceof Error ? error.message : undefined)
|
|
39
43
|
this.name = 'AuthError'
|
|
40
44
|
this.cause = error
|
|
41
45
|
}
|
|
@@ -176,6 +180,81 @@ describe('AuthBoundary', () => {
|
|
|
176
180
|
}
|
|
177
181
|
})
|
|
178
182
|
|
|
183
|
+
describe('oauth mode', () => {
|
|
184
|
+
const oauth = {
|
|
185
|
+
clientId: 'client-abc',
|
|
186
|
+
redirectUri: 'https://app.example.com/callback',
|
|
187
|
+
organizationId: 'org123',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('starts the OAuth authorization flow when authState="logged-out"', async () => {
|
|
191
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
192
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
193
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
194
|
+
type: AuthStateType.LOGGED_OUT,
|
|
195
|
+
isDestroyingSession: false,
|
|
196
|
+
})
|
|
197
|
+
render(
|
|
198
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
199
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
200
|
+
</ResourceProvider>,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
await waitFor(() => expect(authorize).toHaveBeenCalledTimes(1))
|
|
204
|
+
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('does not start the OAuth flow when logged out without oauth config', async () => {
|
|
208
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
209
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
210
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
211
|
+
type: AuthStateType.LOGGED_OUT,
|
|
212
|
+
isDestroyingSession: false,
|
|
213
|
+
})
|
|
214
|
+
render(
|
|
215
|
+
<ResourceProvider projectId="p" dataset="d" fallback={null}>
|
|
216
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
217
|
+
</ResourceProvider>,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
await waitFor(() => expect(screen.queryByText('Protected Content')).not.toBeInTheDocument())
|
|
221
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('renders the error fallback when starting the OAuth flow rejects', async () => {
|
|
225
|
+
// A falsy rejection reason must still surface as an error, not a blank screen.
|
|
226
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(vi.fn().mockRejectedValue(undefined))
|
|
227
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
228
|
+
type: AuthStateType.LOGGED_OUT,
|
|
229
|
+
isDestroyingSession: false,
|
|
230
|
+
})
|
|
231
|
+
render(
|
|
232
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
233
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
234
|
+
</ResourceProvider>,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
await waitFor(() => expect(screen.getByText('Authentication Error')).toBeInTheDocument())
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('renders the error fallback without restarting the flow when authState="error"', async () => {
|
|
241
|
+
const authorize = vi.fn().mockResolvedValue(undefined)
|
|
242
|
+
vi.mocked(useOAuthAuthorize).mockReturnValue(authorize)
|
|
243
|
+
vi.mocked(useAuthState).mockReturnValue({
|
|
244
|
+
type: AuthStateType.ERROR,
|
|
245
|
+
error: new Error('access_denied'),
|
|
246
|
+
})
|
|
247
|
+
render(
|
|
248
|
+
<ResourceProvider projectId="p" dataset="d" auth={{oauth}} fallback={null}>
|
|
249
|
+
<AuthBoundary projectIds={testProjectIds}>Protected Content</AuthBoundary>
|
|
250
|
+
</ResourceProvider>,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
await waitFor(() => expect(screen.getByText('Authentication Error')).toBeInTheDocument())
|
|
254
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
179
258
|
it('renders the empty LoginCallback component when authState="logging-in"', () => {
|
|
180
259
|
vi.mocked(useAuthState).mockReturnValue({
|
|
181
260
|
type: AuthStateType.LOGGING_IN,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import {CorsOriginError} from '@sanity/client'
|
|
2
2
|
import {AuthStateType, getCorsErrorProjectId, isImportError} from '@sanity/sdk'
|
|
3
3
|
import {isDashboardEnvironment, isStudioConfig} from '@sanity/sdk/_internal'
|
|
4
|
-
import {useEffect, useMemo} from 'react'
|
|
4
|
+
import {useEffect, useMemo, useState} from 'react'
|
|
5
5
|
import {ErrorBoundary, type FallbackProps} from 'react-error-boundary'
|
|
6
6
|
|
|
7
7
|
import {ComlinkTokenRefreshProvider} from '../../context/ComlinkTokenRefresh'
|
|
8
8
|
import {DashboardTokenRefreshProvider} from '../../context/DashboardTokenRefresh'
|
|
9
9
|
import {useAuthState} from '../../hooks/auth/useAuthState'
|
|
10
10
|
import {useLoginUrl} from '../../hooks/auth/useLoginUrl'
|
|
11
|
+
import {useOAuthAuthorize} from '../../hooks/auth/useOAuthAuthorize'
|
|
11
12
|
import {useVerifyOrgProjects} from '../../hooks/auth/useVerifyOrgProjects'
|
|
12
13
|
import {useSanityInstance} from '../../hooks/context/useSanityInstance'
|
|
13
14
|
import {ChunkLoadError} from '../errors/ChunkLoadError'
|
|
@@ -183,21 +184,34 @@ function AuthSwitch({
|
|
|
183
184
|
const orgError = useVerifyOrgProjects(disableVerifyOrg, projectIds)
|
|
184
185
|
|
|
185
186
|
const isLoggedOut = authState.type === AuthStateType.LOGGED_OUT && !authState.isDestroyingSession
|
|
187
|
+
const isOAuth = !!instance.config.auth?.oauth
|
|
186
188
|
const loginUrl = useLoginUrl()
|
|
189
|
+
const authorize = useOAuthAuthorize()
|
|
190
|
+
const [authorizeError, setAuthorizeError] = useState<{error: unknown} | null>(null)
|
|
187
191
|
|
|
188
192
|
useEffect(() => {
|
|
189
193
|
if (isLoggedOut && !isInIframe() && !isStudio && !isDashboardEnvironment()) {
|
|
190
194
|
// We don't want to redirect to login if we're in the Dashboard, in studio
|
|
191
195
|
// mode, or in the workbench (the OS owns the session and mints the token)
|
|
192
|
-
|
|
196
|
+
if (isOAuth) {
|
|
197
|
+
// PKCE params and navigation are owned by core. LOGGED_OUT renders
|
|
198
|
+
// null, so a rejection here must be surfaced or the user sees nothing.
|
|
199
|
+
authorize().catch((error) => setAuthorizeError({error}))
|
|
200
|
+
} else {
|
|
201
|
+
window.location.href = loginUrl
|
|
202
|
+
}
|
|
193
203
|
}
|
|
194
|
-
}, [isLoggedOut, loginUrl, isStudio])
|
|
204
|
+
}, [isLoggedOut, isOAuth, authorize, loginUrl, isStudio])
|
|
195
205
|
|
|
196
206
|
// Only check the error if verification is enabled
|
|
197
207
|
if (verifyOrganization && orgError) {
|
|
198
208
|
throw new ConfigurationError({message: orgError})
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
if (authorizeError) {
|
|
212
|
+
throw new AuthError(authorizeError.error)
|
|
213
|
+
}
|
|
214
|
+
|
|
201
215
|
switch (authState.type) {
|
|
202
216
|
case AuthStateType.ERROR: {
|
|
203
217
|
throw new AuthError(authState.error)
|
|
@@ -9,7 +9,14 @@ vi.mock('../../hooks/auth/useHandleAuthCallback', () => ({
|
|
|
9
9
|
const parsedUrl = new URL(url)
|
|
10
10
|
const sid = new URLSearchParams(parsedUrl.hash.slice(1)).get('sid')
|
|
11
11
|
if (sid === 'valid') {
|
|
12
|
-
|
|
12
|
+
// same document, hash stripped
|
|
13
|
+
return 'http://localhost/'
|
|
14
|
+
}
|
|
15
|
+
if (sid === 'deep-link') {
|
|
16
|
+
return 'http://localhost/documents/abc?x=1'
|
|
17
|
+
}
|
|
18
|
+
if (sid === 'cross-origin') {
|
|
19
|
+
return 'https://evil.example.com/'
|
|
13
20
|
}
|
|
14
21
|
return false
|
|
15
22
|
}),
|
|
@@ -48,7 +55,25 @@ describe('LoginCallback', () => {
|
|
|
48
55
|
|
|
49
56
|
it('handles a successful callback and calls history.replaceState', async () => {
|
|
50
57
|
// Simulate a valid `sid` in the location hash
|
|
51
|
-
vi.
|
|
58
|
+
const replace = vi.fn()
|
|
59
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=valid', replace})
|
|
60
|
+
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
<ResourceProvider fallback={null}>
|
|
64
|
+
<LoginCallback />
|
|
65
|
+
</ResourceProvider>,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(history.replaceState).toHaveBeenCalledWith(null, '', 'http://localhost/')
|
|
70
|
+
})
|
|
71
|
+
expect(replace).not.toHaveBeenCalled()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('navigates when the callback resolves to a different route', async () => {
|
|
75
|
+
const replace = vi.fn()
|
|
76
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=deep-link', replace})
|
|
52
77
|
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
53
78
|
|
|
54
79
|
render(
|
|
@@ -58,11 +83,25 @@ describe('LoginCallback', () => {
|
|
|
58
83
|
)
|
|
59
84
|
|
|
60
85
|
await waitFor(() => {
|
|
61
|
-
expect(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
expect(replace).toHaveBeenCalledWith('http://localhost/documents/abc?x=1')
|
|
87
|
+
})
|
|
88
|
+
expect(history.replaceState).not.toHaveBeenCalled()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('does not navigate when the callback resolves to a different origin', async () => {
|
|
92
|
+
const replace = vi.fn()
|
|
93
|
+
vi.stubGlobal('location', {href: 'http://localhost/#sid=cross-origin', replace})
|
|
94
|
+
const {LoginCallback} = await import('./LoginCallback') // Reload after resetModules
|
|
95
|
+
|
|
96
|
+
render(
|
|
97
|
+
<ResourceProvider fallback={null}>
|
|
98
|
+
<LoginCallback />
|
|
99
|
+
</ResourceProvider>,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(replace).not.toHaveBeenCalled()
|
|
104
|
+
expect(history.replaceState).not.toHaveBeenCalled()
|
|
66
105
|
})
|
|
67
106
|
})
|
|
68
107
|
|
|
@@ -5,7 +5,15 @@ import {useHandleAuthCallback} from '../../hooks/auth/useHandleAuthCallback'
|
|
|
5
5
|
/**
|
|
6
6
|
* Component shown during auth callback processing that handles login completion.
|
|
7
7
|
* Automatically processes the auth callback when mounted and updates the URL
|
|
8
|
-
* to remove callback parameters without triggering a page reload.
|
|
8
|
+
* to remove callback parameters without triggering a page reload. When the
|
|
9
|
+
* callback resolves to a different route (the OAuth flow returns the user to
|
|
10
|
+
* where they started), a real navigation is performed instead so the app's
|
|
11
|
+
* router picks it up.
|
|
12
|
+
*
|
|
13
|
+
* A different route is detected by pathname only, so apps that route in the
|
|
14
|
+
* hash (`#/documents/abc`) will not be navigated to the deep link. Those apps
|
|
15
|
+
* should build a custom callback component with `useHandleOAuthCallback` and
|
|
16
|
+
* their router's `navigate`.
|
|
9
17
|
*
|
|
10
18
|
* @alpha
|
|
11
19
|
*/
|
|
@@ -15,10 +23,20 @@ export function LoginCallback(): React.ReactNode {
|
|
|
15
23
|
useEffect(() => {
|
|
16
24
|
const url = new URL(location.href)
|
|
17
25
|
handleAuthCallback(url.toString()).then((replacementLocation) => {
|
|
18
|
-
if (replacementLocation)
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
if (!replacementLocation) return
|
|
27
|
+
const next = new URL(replacementLocation, url)
|
|
28
|
+
// Core only returns same-origin locations; guard here too since this is
|
|
29
|
+
// the code that navigates.
|
|
30
|
+
if (next.origin !== url.origin) return
|
|
31
|
+
if (next.pathname === url.pathname) {
|
|
32
|
+
// Same document: `replaceState` strips the callback params without a
|
|
33
|
+
// reload. Routers do not observe this, which is fine when only the
|
|
34
|
+
// query/hash changed. Caveat: a same-path return with different app
|
|
35
|
+
// query params won't re-run router search-param hooks until the next
|
|
36
|
+
// navigation.
|
|
21
37
|
history.replaceState(null, '', replacementLocation)
|
|
38
|
+
} else {
|
|
39
|
+
location.replace(replacementLocation)
|
|
22
40
|
}
|
|
23
41
|
})
|
|
24
42
|
}, [handleAuthCallback])
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {handleOAuthCallback} from '@sanity/sdk'
|
|
2
|
+
import {identity} from 'rxjs'
|
|
3
|
+
import {describe, it} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
6
|
+
|
|
7
|
+
vi.mock('../helpers/createCallbackHook', () => ({createCallbackHook: vi.fn(identity)}))
|
|
8
|
+
vi.mock('@sanity/sdk', () => ({handleOAuthCallback: vi.fn()}))
|
|
9
|
+
|
|
10
|
+
describe('useHandleOAuthCallback', () => {
|
|
11
|
+
it('calls `createCallbackHook` with `handleOAuthCallback`', async () => {
|
|
12
|
+
const {useHandleOAuthCallback} = await import('./useHandleOAuthCallback')
|
|
13
|
+
expect(createCallbackHook).toHaveBeenCalledWith(handleOAuthCallback)
|
|
14
|
+
expect(useHandleOAuthCallback).toBe(handleOAuthCallback)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {handleOAuthCallback} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A React hook that returns a function for handling the OAuth redirect callback.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* This is the OAuth counterpart to `useHandleAuthCallback`. The returned
|
|
10
|
+
* function invokes core's `handleOAuthCallback`, which validates the `state`
|
|
11
|
+
* parameter, surfaces `?error=` redirects, exchanges the authorization `code`
|
|
12
|
+
* for tokens, persists them, and transitions the auth state to `LOGGED_IN` —
|
|
13
|
+
* all in core. On success it resolves the same-origin location the user was on
|
|
14
|
+
* when the flow started (so deep links survive login), otherwise the callback
|
|
15
|
+
* URL cleaned of the OAuth params (`code`, `state`, `error`,
|
|
16
|
+
* `error_description`). It resolves `false` when there was nothing to handle.
|
|
17
|
+
* The resolved URL may be a different route, so navigate to it rather than
|
|
18
|
+
* only calling `history.replaceState`.
|
|
19
|
+
*
|
|
20
|
+
* `AuthBoundary` runs this for you when the app lands on the OAuth redirect
|
|
21
|
+
* URI. Reach for this hook only when building a custom callback component.
|
|
22
|
+
*
|
|
23
|
+
* Concurrent calls are single-flight in core, so React StrictMode's double
|
|
24
|
+
* invocation will not trigger a second code exchange, and a repeated call with
|
|
25
|
+
* a stale callback URL is ignored once a session is established.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* function OAuthCallback() {
|
|
30
|
+
* const handleCallback = useHandleOAuthCallback()
|
|
31
|
+
* const navigate = useNavigate() // your router's navigation
|
|
32
|
+
*
|
|
33
|
+
* useEffect(() => {
|
|
34
|
+
* handleCallback(window.location.href)
|
|
35
|
+
* .then((nextUrl) => {
|
|
36
|
+
* // Returns the user to where they started, with OAuth params removed
|
|
37
|
+
* if (nextUrl) navigate(nextUrl, {replace: true})
|
|
38
|
+
* })
|
|
39
|
+
* .catch(console.error)
|
|
40
|
+
* }, [handleCallback, navigate])
|
|
41
|
+
*
|
|
42
|
+
* return <div>Completing sign-in…</div>
|
|
43
|
+
* }
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @returns A callback handler that processes the OAuth redirect
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
export const useHandleOAuthCallback = createCallbackHook(handleOAuthCallback)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {startOAuthAuthorization} from '@sanity/sdk'
|
|
2
|
+
import {identity} from 'rxjs'
|
|
3
|
+
import {describe, it} from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
6
|
+
|
|
7
|
+
vi.mock('../helpers/createCallbackHook', () => ({createCallbackHook: vi.fn(identity)}))
|
|
8
|
+
vi.mock('@sanity/sdk', () => ({startOAuthAuthorization: vi.fn()}))
|
|
9
|
+
|
|
10
|
+
describe('useOAuthAuthorize', () => {
|
|
11
|
+
it('calls `createCallbackHook` with `startOAuthAuthorization`', async () => {
|
|
12
|
+
const {useOAuthAuthorize} = await import('./useOAuthAuthorize')
|
|
13
|
+
expect(createCallbackHook).toHaveBeenCalledWith(startOAuthAuthorization)
|
|
14
|
+
expect(useOAuthAuthorize).toBe(startOAuthAuthorization)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {startOAuthAuthorization} from '@sanity/sdk'
|
|
2
|
+
|
|
3
|
+
import {createCallbackHook} from '../helpers/createCallbackHook'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A React hook that returns a function for starting the OAuth authorization-code + PKCE flow.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The returned function invokes core's `startOAuthAuthorization`, which generates
|
|
10
|
+
* the PKCE `code_verifier`, `code_challenge` and `state`, persists the verifier and
|
|
11
|
+
* state to `sessionStorage`, and navigates the browser to the authorize endpoint.
|
|
12
|
+
* `clientId`, `redirectUri` and `organizationId` are read from the instance's
|
|
13
|
+
* `auth.oauth` config. The returned promise rejects if the instance has no `auth.oauth` config.
|
|
14
|
+
*
|
|
15
|
+
* Pair with {@link useHandleOAuthCallback} on the redirect URI to complete the flow.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* function LoginButton() {
|
|
20
|
+
* const authorize = useOAuthAuthorize()
|
|
21
|
+
* return <button onClick={() => authorize().catch(console.error)}>Sign in</button>
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @returns A function that starts the OAuth flow by navigating to the authorization URL
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
export const useOAuthAuthorize = createCallbackHook(startOAuthAuthorization)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getOAuthTokensState,
|
|
3
|
+
type OAuthTokens,
|
|
4
|
+
refreshOAuthTokens,
|
|
5
|
+
revokeOAuthTokens,
|
|
6
|
+
type StateSource,
|
|
7
|
+
} from '@sanity/sdk'
|
|
8
|
+
import {act, renderHook} from '@testing-library/react'
|
|
9
|
+
import {throwError} from 'rxjs'
|
|
10
|
+
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'
|
|
11
|
+
|
|
12
|
+
import {ResourceProvider} from '../../context/ResourceProvider'
|
|
13
|
+
import {useOAuthTokens} from './useOAuthTokens'
|
|
14
|
+
|
|
15
|
+
vi.mock('@sanity/sdk', async (importOriginal) => {
|
|
16
|
+
const original = await importOriginal<typeof import('@sanity/sdk')>()
|
|
17
|
+
return {
|
|
18
|
+
...original,
|
|
19
|
+
getOAuthTokensState: vi.fn(),
|
|
20
|
+
refreshOAuthTokens: vi.fn(),
|
|
21
|
+
revokeOAuthTokens: vi.fn(),
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A controllable stand-in for core's token state source. `set` mimics core
|
|
27
|
+
* updating the store (refresh/revoke or a cross-tab `storage` event) and
|
|
28
|
+
* notifies subscribers so the hook re-renders.
|
|
29
|
+
*/
|
|
30
|
+
function createFakeTokenSource(initial: OAuthTokens | null) {
|
|
31
|
+
let current = initial
|
|
32
|
+
const listeners = new Set<() => void>()
|
|
33
|
+
const source: StateSource<OAuthTokens | null> & {set: (next: OAuthTokens | null) => void} = {
|
|
34
|
+
subscribe: (onStoreChanged?: () => void) => {
|
|
35
|
+
if (onStoreChanged) listeners.add(onStoreChanged)
|
|
36
|
+
return () => {
|
|
37
|
+
if (onStoreChanged) listeners.delete(onStoreChanged)
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
getCurrent: () => current,
|
|
41
|
+
observable: throwError(() => new Error('unexpected usage of observable')),
|
|
42
|
+
set: (next) => {
|
|
43
|
+
current = next
|
|
44
|
+
for (const listener of listeners) listener()
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
return source
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const makeTokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
|
|
51
|
+
accessToken: 'access-token',
|
|
52
|
+
tokenType: 'bearer',
|
|
53
|
+
expiresIn: 3600,
|
|
54
|
+
expiresAt: new Date(Date.now() + 3600_000),
|
|
55
|
+
refreshToken: 'refresh-token',
|
|
56
|
+
...overrides,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const wrapper = ({children}: {children: React.ReactNode}) => (
|
|
60
|
+
<ResourceProvider projectId="test-project" dataset="test-dataset" fallback={null}>
|
|
61
|
+
{children}
|
|
62
|
+
</ResourceProvider>
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
describe('useOAuthTokens', () => {
|
|
66
|
+
const mockGetState = vi.mocked(getOAuthTokensState)
|
|
67
|
+
const mockRefresh = vi.mocked(refreshOAuthTokens)
|
|
68
|
+
const mockRevoke = vi.mocked(revokeOAuthTokens)
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.clearAllMocks()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
vi.useRealTimers()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns the stored tokens with isExpired=false when expiresAt is in the future', () => {
|
|
79
|
+
const tokens = makeTokens()
|
|
80
|
+
mockGetState.mockReturnValue(createFakeTokenSource(tokens))
|
|
81
|
+
|
|
82
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
83
|
+
|
|
84
|
+
expect(result.current.tokens).toEqual(tokens)
|
|
85
|
+
expect(result.current.isExpired()).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('derives isExpired=true when expiresAt is in the past', () => {
|
|
89
|
+
mockGetState.mockReturnValue(
|
|
90
|
+
createFakeTokenSource(makeTokens({expiresAt: new Date(Date.now() - 1000)})),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
94
|
+
|
|
95
|
+
expect(result.current.isExpired()).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('treats expiresAt exactly equal to now as expired (<= boundary)', () => {
|
|
99
|
+
vi.useFakeTimers()
|
|
100
|
+
const now = new Date('2030-01-01T00:00:00.000Z')
|
|
101
|
+
vi.setSystemTime(now)
|
|
102
|
+
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt: new Date(now)})))
|
|
103
|
+
|
|
104
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
105
|
+
|
|
106
|
+
expect(result.current.isExpired()).toBe(true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('re-evaluates isExpired against the clock at call time, with no re-render or token change', () => {
|
|
110
|
+
vi.useFakeTimers()
|
|
111
|
+
vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z'))
|
|
112
|
+
const expiresAt = new Date(Date.now() + 10_000)
|
|
113
|
+
mockGetState.mockReturnValue(createFakeTokenSource(makeTokens({expiresAt})))
|
|
114
|
+
|
|
115
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
116
|
+
const {isExpired} = result.current
|
|
117
|
+
expect(isExpired()).toBe(false)
|
|
118
|
+
|
|
119
|
+
// Advance past expiry. The same function reference must now report expired,
|
|
120
|
+
// proving the read happens at call time, not render time.
|
|
121
|
+
vi.setSystemTime(new Date(expiresAt.getTime() + 1000))
|
|
122
|
+
expect(isExpired()).toBe(true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('returns tokens=null and isExpired=false when there are no tokens', () => {
|
|
126
|
+
mockGetState.mockReturnValue(createFakeTokenSource(null))
|
|
127
|
+
|
|
128
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
129
|
+
|
|
130
|
+
expect(result.current.tokens).toBeNull()
|
|
131
|
+
expect(result.current.isExpired()).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('calls core refreshOAuthTokens and re-renders with the new tokens', async () => {
|
|
135
|
+
const source = createFakeTokenSource(makeTokens())
|
|
136
|
+
mockGetState.mockReturnValue(source)
|
|
137
|
+
const refreshed = makeTokens({accessToken: 'refreshed-token'})
|
|
138
|
+
mockRefresh.mockImplementation(() => {
|
|
139
|
+
source.set(refreshed)
|
|
140
|
+
return Promise.resolve(refreshed)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
144
|
+
|
|
145
|
+
let returned: OAuthTokens | null = null
|
|
146
|
+
await act(async () => {
|
|
147
|
+
returned = await result.current.refresh()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1)
|
|
151
|
+
expect(returned).toEqual(refreshed)
|
|
152
|
+
expect(result.current.tokens).toEqual(refreshed)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('resolves null from refresh when core has no refresh token, and tokens become null', async () => {
|
|
156
|
+
const existing = makeTokens({refreshToken: undefined})
|
|
157
|
+
const source = createFakeTokenSource(existing)
|
|
158
|
+
mockGetState.mockReturnValue(source)
|
|
159
|
+
// Core clears stored tokens and logs out on the no-refresh-token path.
|
|
160
|
+
mockRefresh.mockImplementation(() => {
|
|
161
|
+
source.set(null)
|
|
162
|
+
return Promise.resolve(null)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
166
|
+
|
|
167
|
+
let returned: OAuthTokens | null = existing
|
|
168
|
+
await act(async () => {
|
|
169
|
+
returned = await result.current.refresh()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
expect(returned).toBeNull()
|
|
173
|
+
expect(result.current.tokens).toBeNull()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('propagates a rejected refresh and leaves tokens unchanged', async () => {
|
|
177
|
+
const existing = makeTokens()
|
|
178
|
+
mockGetState.mockReturnValue(createFakeTokenSource(existing))
|
|
179
|
+
mockRefresh.mockRejectedValue(new Error('network'))
|
|
180
|
+
|
|
181
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
await expect(result.current.refresh()).rejects.toThrow('network')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
expect(result.current.tokens).toEqual(existing)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('propagates an unrecoverable refresh rejection after core has cleared tokens', async () => {
|
|
191
|
+
const source = createFakeTokenSource(makeTokens())
|
|
192
|
+
mockGetState.mockReturnValue(source)
|
|
193
|
+
// Core clears stored tokens and logs out before rethrowing a 4xx.
|
|
194
|
+
mockRefresh.mockImplementation(() => {
|
|
195
|
+
source.set(null)
|
|
196
|
+
return Promise.reject(new Error('invalid_grant'))
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
await expect(result.current.refresh()).rejects.toThrow('invalid_grant')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
expect(result.current.tokens).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('calls core revokeOAuthTokens and re-renders with tokens=null', async () => {
|
|
209
|
+
const source = createFakeTokenSource(makeTokens())
|
|
210
|
+
mockGetState.mockReturnValue(source)
|
|
211
|
+
mockRevoke.mockImplementation(() => {
|
|
212
|
+
source.set(null)
|
|
213
|
+
return Promise.resolve()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
217
|
+
|
|
218
|
+
await act(async () => {
|
|
219
|
+
await result.current.revoke()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(mockRevoke).toHaveBeenCalledTimes(1)
|
|
223
|
+
expect(result.current.tokens).toBeNull()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('re-renders when tokens change externally (e.g. another tab)', () => {
|
|
227
|
+
const source = createFakeTokenSource(null)
|
|
228
|
+
mockGetState.mockReturnValue(source)
|
|
229
|
+
|
|
230
|
+
const {result} = renderHook(() => useOAuthTokens(), {wrapper})
|
|
231
|
+
expect(result.current.tokens).toBeNull()
|
|
232
|
+
|
|
233
|
+
const otherTabTokens = makeTokens({accessToken: 'other-tab-token'})
|
|
234
|
+
act(() => {
|
|
235
|
+
source.set(otherTabTokens)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
expect(result.current.tokens).toEqual(otherTabTokens)
|
|
239
|
+
})
|
|
240
|
+
})
|