@sanity/sdk 3.1.0 → 3.3.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_exports/_internal.d.ts +52 -21
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +10 -9
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +7 -7
- package/dist/_exports/agent.d.ts.map +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +9 -9
- package/dist/_exports/comlink.d.ts.map +1 -1
- package/dist/_exports/comlink.js +3 -2
- package/dist/_exports/comlink.js.map +1 -1
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C-OEkGaM.d.ts → authStore-BGoEGxOq.d.ts} +531 -63
- package/dist/authStore-BGoEGxOq.d.ts.map +1 -0
- package/dist/{clientStore-gUALQ0qW.js → clientStore-QDrJNTM4.js} +128 -7
- package/dist/clientStore-QDrJNTM4.js.map +1 -0
- package/dist/{createSanityInstance-DCePpBYW.d.ts → createSanityInstance-BQIqyGLX.d.ts} +16 -1
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{object-BwynGPRZ.js → createStateSourceAction-DdAhEfnZ.js} +2 -82
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-E0hjqFOo.js → getNodeState-B4nG0sjm.js} +3 -2
- package/dist/{getNodeState-E0hjqFOo.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +371 -54
- package/dist/index.js.map +1 -1
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-DXvuH9h4.d.ts +435 -0
- package/dist/topicStore-DXvuH9h4.d.ts.map +1 -0
- package/dist/{version-C90rNWaX.js → version-CIhX5ppJ.js} +6 -6
- package/dist/{version-C90rNWaX.js.map → version-CIhX5ppJ.js.map} +1 -1
- package/package.json +20 -19
- package/src/_exports/_internal.ts +12 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.ts +39 -14
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.test.ts +18 -0
- package/src/auth/authStore.ts +15 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/handleAuthCallback.test.ts +33 -0
- package/src/auth/handleAuthCallback.ts +11 -1
- package/src/auth/logout.test.ts +53 -0
- package/src/auth/logout.ts +19 -1
- package/src/auth/oauth/oauthActions.test.ts +593 -0
- package/src/auth/oauth/oauthActions.ts +438 -0
- package/src/auth/oauth/oauthAuth.test.ts +229 -0
- package/src/auth/oauth/oauthAuth.ts +178 -0
- package/src/auth/oauth/pkce.test.ts +44 -0
- package/src/auth/oauth/pkce.ts +60 -0
- package/src/auth/oauth/types.ts +12 -0
- package/src/config/authConfig.ts +18 -0
- package/src/dashboard/messageBus/__fixtures__/test-topics.ts +18 -0
- package/src/dashboard/messageBus/bus.test.ts +1033 -0
- package/src/dashboard/messageBus/bus.ts +1246 -0
- package/src/dashboard/messageBus/store.test.ts +142 -0
- package/src/dashboard/messageBus/store.ts +74 -0
- package/src/dashboard/messageBus/topicStore.test.ts +172 -0
- package/src/dashboard/messageBus/topicStore.ts +184 -0
- package/src/dashboard/messageBus/topics.test.ts +71 -0
- package/src/dashboard/messageBus/topics.ts +260 -0
- package/src/document/actions.ts +3 -3
- package/src/document/applyDocumentActions.ts +3 -3
- package/src/document/documentStore.ts +12 -12
- package/src/document/processMutations.ts +58 -22
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.ts +2 -19
- package/src/projection/getProjectionState.ts +2 -2
- package/src/projection/resolveProjection.ts +2 -2
- package/src/query/queryStore.ts +3 -3
- package/src/typegen/defineProjection.test.ts +17 -0
- package/src/typegen/defineProjection.ts +19 -0
- package/src/typegen/groqCompat.ts +122 -0
- package/src/typegen/resolve.ts +37 -0
- package/src/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/dist/authStore-C-OEkGaM.d.ts.map +0 -1
- package/dist/clientStore-gUALQ0qW.js.map +0 -1
- package/dist/createSanityInstance-DCePpBYW.d.ts.map +0 -1
- package/dist/object-BwynGPRZ.js.map +0 -1
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import {ClientError, type SanityClient} from '@sanity/client'
|
|
2
|
+
|
|
3
|
+
import {bindActionGlobally} from '../../store/createActionBinder'
|
|
4
|
+
import {createStateSourceAction} from '../../store/createStateSourceAction'
|
|
5
|
+
import {type StoreContext} from '../../store/defineStore'
|
|
6
|
+
import {DEFAULT_BASE, REQUEST_TAG_PREFIX} from '../authConstants'
|
|
7
|
+
import {getAuthLogger} from '../authLogger'
|
|
8
|
+
import {AuthStateType} from '../authStateType'
|
|
9
|
+
import {authStore, type AuthStoreState} from '../authStore'
|
|
10
|
+
import {createLoggedInAuthState, getDefaultLocation} from '../utils'
|
|
11
|
+
import {generateCodeChallenge, generateCodeVerifier, generateState} from './pkce'
|
|
12
|
+
import {type OAuthTokens} from './types'
|
|
13
|
+
|
|
14
|
+
/** sessionStorage key for the PKCE `code_verifier`. */
|
|
15
|
+
export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
|
|
16
|
+
|
|
17
|
+
/** sessionStorage key for the CSRF `state` value. */
|
|
18
|
+
export const OAUTH_STATE_KEY = '__sanity_oauth_state'
|
|
19
|
+
|
|
20
|
+
/** sessionStorage key for the location to return to after the callback. */
|
|
21
|
+
export const OAUTH_RETURN_TO_KEY = '__sanity_oauth_return_to'
|
|
22
|
+
|
|
23
|
+
const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error', 'error_description']
|
|
24
|
+
|
|
25
|
+
/** Strips the OAuth callback params from a URL. */
|
|
26
|
+
function stripOAuthParams(href: string): string {
|
|
27
|
+
const url = new URL(href, DEFAULT_BASE)
|
|
28
|
+
for (const param of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(param)
|
|
29
|
+
return url.toString()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether `href` is the registered redirect URI (origin + pathname; query and hash ignored). */
|
|
33
|
+
function isCallbackRoute(href: string, redirectUri: string): boolean {
|
|
34
|
+
const current = new URL(href, DEFAULT_BASE)
|
|
35
|
+
const redirect = new URL(redirectUri, DEFAULT_BASE)
|
|
36
|
+
return current.origin === redirect.origin && current.pathname === redirect.pathname
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TokenEndpointResponse {
|
|
40
|
+
access_token: string
|
|
41
|
+
token_type: string
|
|
42
|
+
expires_in: number
|
|
43
|
+
refresh_token?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Builds the RFC 8707 resource indicator for an organisation. */
|
|
47
|
+
function getResourceIndicator(organizationId: string): string {
|
|
48
|
+
return `urn:io.sanity:organization:${organizationId}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Serialises tokens for storage, converting `expiresAt` to an ISO string.
|
|
53
|
+
*
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export function serializeTokens(tokens: OAuthTokens): string {
|
|
57
|
+
return JSON.stringify({
|
|
58
|
+
accessToken: tokens.accessToken,
|
|
59
|
+
tokenType: tokens.tokenType,
|
|
60
|
+
expiresIn: tokens.expiresIn,
|
|
61
|
+
expiresAt: tokens.expiresAt.toISOString(),
|
|
62
|
+
...(tokens.refreshToken !== undefined && {refreshToken: tokens.refreshToken}),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type AuthOptions = AuthStoreState['options']
|
|
67
|
+
type ConfiguredOAuthOptions = AuthOptions & {oauth: NonNullable<AuthOptions['oauth']>}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reads the store options, throwing when the instance was not configured for
|
|
71
|
+
* OAuth.
|
|
72
|
+
*/
|
|
73
|
+
function getOAuthOptions(state: AuthStoreState): ConfiguredOAuthOptions {
|
|
74
|
+
const {options} = state
|
|
75
|
+
if (!options.oauth) {
|
|
76
|
+
throw new Error('OAuth is not configured on this instance (missing `auth.oauth`).')
|
|
77
|
+
}
|
|
78
|
+
return options as ConfiguredOAuthOptions
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Creates a client for the (unauthenticated) public OAuth token/revoke calls. */
|
|
82
|
+
function createOAuthClient(options: AuthOptions): SanityClient {
|
|
83
|
+
return options.clientFactory({
|
|
84
|
+
apiVersion: 'v1',
|
|
85
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
86
|
+
useProjectHostname: false,
|
|
87
|
+
useCdn: false,
|
|
88
|
+
...(options.apiHost && {apiHost: options.apiHost}),
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Converts a token endpoint response to the {@link OAuthTokens} shape. */
|
|
93
|
+
function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
|
|
94
|
+
return {
|
|
95
|
+
accessToken: response.access_token,
|
|
96
|
+
tokenType: 'bearer',
|
|
97
|
+
expiresIn: response.expires_in,
|
|
98
|
+
expiresAt: new Date(Date.now() + response.expires_in * 1000),
|
|
99
|
+
...(response.refresh_token !== undefined && {refreshToken: response.refresh_token}),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Starts the OAuth authorization-code + PKCE flow: generates a `code_verifier`,
|
|
105
|
+
* `code_challenge` and `state`, persists the verifier and state to
|
|
106
|
+
* `sessionStorage`, then navigates the browser to the authorize endpoint.
|
|
107
|
+
*
|
|
108
|
+
* @public
|
|
109
|
+
*/
|
|
110
|
+
export const startOAuthAuthorization = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
111
|
+
const logger = getAuthLogger(instance)
|
|
112
|
+
const options = getOAuthOptions(state.get())
|
|
113
|
+
|
|
114
|
+
const codeVerifier = generateCodeVerifier()
|
|
115
|
+
const oauthState = generateState()
|
|
116
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
117
|
+
|
|
118
|
+
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
119
|
+
session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
|
|
120
|
+
session?.setItem(OAUTH_STATE_KEY, oauthState)
|
|
121
|
+
// The redirect URI must match the registered one exactly, so the current
|
|
122
|
+
// location (deep link) is stashed here and restored by the callback. OAuth
|
|
123
|
+
// params are only stripped when we are already on the callback route, so
|
|
124
|
+
// legitimate app params like `?state=draft` survive the round trip.
|
|
125
|
+
const currentHref = getDefaultLocation()
|
|
126
|
+
if (currentHref !== DEFAULT_BASE) {
|
|
127
|
+
const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
|
|
128
|
+
? stripOAuthParams(currentHref)
|
|
129
|
+
: currentHref
|
|
130
|
+
session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const authorizeUrl = new URL(
|
|
134
|
+
'/v1/auth/oauth/authorize',
|
|
135
|
+
options.apiHost ?? 'https://api.sanity.io',
|
|
136
|
+
)
|
|
137
|
+
authorizeUrl.searchParams.set('response_type', 'code')
|
|
138
|
+
authorizeUrl.searchParams.set('client_id', options.oauth.clientId)
|
|
139
|
+
authorizeUrl.searchParams.set('redirect_uri', options.oauth.redirectUri)
|
|
140
|
+
authorizeUrl.searchParams.set('state', oauthState)
|
|
141
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge)
|
|
142
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
143
|
+
authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
|
|
144
|
+
|
|
145
|
+
logger.info('Starting OAuth authorization')
|
|
146
|
+
if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
|
|
147
|
+
window.location.assign(authorizeUrl.toString())
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Handles the OAuth redirect callback: validates `state`, surfaces `error`
|
|
153
|
+
* responses, exchanges the authorization `code` for tokens, persists them, and
|
|
154
|
+
* transitions to `LOGGED_IN`.
|
|
155
|
+
*
|
|
156
|
+
* Returns the URL for the caller to `history.replaceState` to when a callback
|
|
157
|
+
* was processed: on success, the same-origin location the user was on when
|
|
158
|
+
* the flow started (if any), otherwise the callback URL cleaned of OAuth
|
|
159
|
+
* params. Returns `false` when there was nothing to handle (no code, or an
|
|
160
|
+
* exchange already in progress). A callback that fails validation while a
|
|
161
|
+
* session is already `LOGGED_IN` (e.g. a remount before the params were
|
|
162
|
+
* stripped) is ignored with a warning and still returns the cleaned URL, so a
|
|
163
|
+
* string result does not imply an exchange ran.
|
|
164
|
+
*
|
|
165
|
+
* @public
|
|
166
|
+
*/
|
|
167
|
+
export const handleOAuthCallback = bindActionGlobally(
|
|
168
|
+
authStore,
|
|
169
|
+
async (
|
|
170
|
+
{state, instance},
|
|
171
|
+
locationHref: string = getDefaultLocation(),
|
|
172
|
+
): Promise<string | false> => {
|
|
173
|
+
const logger = getAuthLogger(instance)
|
|
174
|
+
const options = getOAuthOptions(state.get())
|
|
175
|
+
|
|
176
|
+
const {authState} = state.get()
|
|
177
|
+
if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
|
|
178
|
+
logger.debug('Skipping OAuth callback - token exchange already in progress')
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const callbackUrl = new URL(locationHref, DEFAULT_BASE)
|
|
183
|
+
const code = callbackUrl.searchParams.get('code')
|
|
184
|
+
const returnedState = callbackUrl.searchParams.get('state')
|
|
185
|
+
const error = callbackUrl.searchParams.get('error')
|
|
186
|
+
const errorDescription = callbackUrl.searchParams.get('error_description')
|
|
187
|
+
|
|
188
|
+
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
189
|
+
|
|
190
|
+
const cleanedUrl = stripOAuthParams(locationHref)
|
|
191
|
+
|
|
192
|
+
// Callback failures move to ERROR, unless a session is already
|
|
193
|
+
// established: a stale callback URL (e.g. remount before the params were
|
|
194
|
+
// stripped, after the artifacts were cleared) or a denied re-authorization
|
|
195
|
+
// must not clobber LOGGED_IN.
|
|
196
|
+
const rejectCallback = (name: string, message: string): string => {
|
|
197
|
+
if (authState.type === AuthStateType.LOGGED_IN) {
|
|
198
|
+
// Leave PKCE artifacts alone: a re-authorization may be in flight.
|
|
199
|
+
logger.warn(`${message} — ignoring callback, session already established`)
|
|
200
|
+
return cleanedUrl
|
|
201
|
+
}
|
|
202
|
+
logger.error(`${message} — rejecting callback`)
|
|
203
|
+
clearOAuthArtifacts(session)
|
|
204
|
+
state.set(name, {authState: {type: AuthStateType.ERROR, error: new Error(message)}})
|
|
205
|
+
return cleanedUrl
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (error) {
|
|
209
|
+
return rejectCallback(
|
|
210
|
+
'oauthCallbackError',
|
|
211
|
+
errorDescription ? `${error}: ${errorDescription}` : error,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!code) {
|
|
216
|
+
logger.debug('No OAuth code found in callback URL')
|
|
217
|
+
return false
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
|
|
221
|
+
if (!returnedState || !storedState || returnedState !== storedState) {
|
|
222
|
+
return rejectCallback('oauthStateMismatch', 'OAuth state mismatch')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
|
|
226
|
+
if (!codeVerifier) {
|
|
227
|
+
return rejectCallback('oauthVerifierMissing', 'OAuth code verifier missing')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
logger.info('Exchanging OAuth code for tokens')
|
|
231
|
+
state.set('oauthExchange', {
|
|
232
|
+
authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: true},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const client = createOAuthClient(options)
|
|
237
|
+
const params = new URLSearchParams({
|
|
238
|
+
grant_type: 'authorization_code',
|
|
239
|
+
code,
|
|
240
|
+
code_verifier: codeVerifier,
|
|
241
|
+
redirect_uri: options.oauth.redirectUri,
|
|
242
|
+
client_id: options.oauth.clientId,
|
|
243
|
+
resource: getResourceIndicator(options.oauth.organizationId),
|
|
244
|
+
})
|
|
245
|
+
const response = await client.request<TokenEndpointResponse>({
|
|
246
|
+
method: 'POST',
|
|
247
|
+
url: '/auth/oauth/token',
|
|
248
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
249
|
+
body: params.toString(),
|
|
250
|
+
tag: 'oauth.token',
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const tokens = toOAuthTokens(response)
|
|
254
|
+
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
255
|
+
const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
|
|
256
|
+
clearOAuthArtifacts(session)
|
|
257
|
+
|
|
258
|
+
logger.info('OAuth tokens obtained, user logged in')
|
|
259
|
+
state.set('oauthLoggedIn', {
|
|
260
|
+
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
261
|
+
oauthTokens: tokens,
|
|
262
|
+
})
|
|
263
|
+
return returnTo ?? cleanedUrl
|
|
264
|
+
} catch (exchangeError) {
|
|
265
|
+
logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
|
|
266
|
+
clearOAuthArtifacts(session)
|
|
267
|
+
state.set('oauthExchangeError', {
|
|
268
|
+
authState: {type: AuthStateType.ERROR, error: exchangeError},
|
|
269
|
+
})
|
|
270
|
+
return cleanedUrl
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A refresh failure is unrecoverable only when the token endpoint rejects the
|
|
277
|
+
* refresh token itself (a 4xx). Rate-limit (429) and request-timeout (408)
|
|
278
|
+
* responses, 5xx errors and network failures are transient, so the session is
|
|
279
|
+
* kept intact for the caller to retry rather than forcing a logout.
|
|
280
|
+
*/
|
|
281
|
+
function isUnrecoverableRefreshError(error: unknown): boolean {
|
|
282
|
+
return error instanceof ClientError && error.statusCode !== 408 && error.statusCode !== 429
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Single-flight refresh shared across concurrent callers. Safe as a module
|
|
286
|
+
// singleton because `authStore` is a global store (one shared state).
|
|
287
|
+
// ponytail: module-level single-flight; upgrade to per-store keying only if
|
|
288
|
+
// the auth store ever stops being global.
|
|
289
|
+
let refreshInFlight: Promise<OAuthTokens | null> | null = null
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Refreshes the OAuth tokens using the `refresh_token` grant. Concurrent
|
|
293
|
+
* callers share a single in-flight request. An unrecoverable failure (a 4xx
|
|
294
|
+
* rejecting the refresh token) clears the tokens and transitions to
|
|
295
|
+
* `LOGGED_OUT`; transient failures (network, 5xx, rate limits) leave the
|
|
296
|
+
* session intact and rethrow so the caller can retry.
|
|
297
|
+
*
|
|
298
|
+
* @public
|
|
299
|
+
*/
|
|
300
|
+
export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => {
|
|
301
|
+
if (refreshInFlight) return refreshInFlight
|
|
302
|
+
refreshInFlight = doRefreshOAuthTokens(context).finally(() => {
|
|
303
|
+
refreshInFlight = null
|
|
304
|
+
})
|
|
305
|
+
return refreshInFlight
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
async function doRefreshOAuthTokens({
|
|
309
|
+
state,
|
|
310
|
+
instance,
|
|
311
|
+
}: StoreContext<AuthStoreState>): Promise<OAuthTokens | null> {
|
|
312
|
+
const logger = getAuthLogger(instance)
|
|
313
|
+
const options = getOAuthOptions(state.get())
|
|
314
|
+
|
|
315
|
+
const current = state.get().oauthTokens
|
|
316
|
+
if (!current?.refreshToken) {
|
|
317
|
+
logger.warn('No refresh token available — logging out')
|
|
318
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
319
|
+
state.set('oauthRefreshNoToken', {
|
|
320
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
321
|
+
oauthTokens: undefined,
|
|
322
|
+
})
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
const client = createOAuthClient(options)
|
|
328
|
+
const params = new URLSearchParams({
|
|
329
|
+
grant_type: 'refresh_token',
|
|
330
|
+
refresh_token: current.refreshToken,
|
|
331
|
+
client_id: options.oauth.clientId,
|
|
332
|
+
resource: getResourceIndicator(options.oauth.organizationId),
|
|
333
|
+
})
|
|
334
|
+
const response = await client.request<TokenEndpointResponse>({
|
|
335
|
+
method: 'POST',
|
|
336
|
+
url: '/auth/oauth/token',
|
|
337
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
338
|
+
body: params.toString(),
|
|
339
|
+
tag: 'oauth.refresh',
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const tokens = toOAuthTokens(response)
|
|
343
|
+
if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
|
|
344
|
+
|
|
345
|
+
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
346
|
+
logger.info('OAuth tokens refreshed')
|
|
347
|
+
state.set('oauthRefreshed', {
|
|
348
|
+
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
349
|
+
oauthTokens: tokens,
|
|
350
|
+
})
|
|
351
|
+
return tokens
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (!isUnrecoverableRefreshError(error)) {
|
|
354
|
+
// Transient (network / 5xx / rate limit) — keep the session so the
|
|
355
|
+
// caller can retry instead of forcing a logout.
|
|
356
|
+
logger.warn('OAuth token refresh failed — keeping session for retry', {error})
|
|
357
|
+
throw error
|
|
358
|
+
}
|
|
359
|
+
logger.error('OAuth token refresh failed — logging out', {error})
|
|
360
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
361
|
+
state.set('oauthRefreshFailed', {
|
|
362
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
363
|
+
oauthTokens: undefined,
|
|
364
|
+
})
|
|
365
|
+
throw error
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Revokes the OAuth tokens then clears local storage and transitions to `LOGGED_OUT`.
|
|
371
|
+
* Local state is cleared even if the revoke request fails.
|
|
372
|
+
*
|
|
373
|
+
* @public
|
|
374
|
+
*/
|
|
375
|
+
export const revokeOAuthTokens = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
376
|
+
const logger = getAuthLogger(instance)
|
|
377
|
+
const options = getOAuthOptions(state.get())
|
|
378
|
+
|
|
379
|
+
const current = state.get().oauthTokens
|
|
380
|
+
const token = current?.refreshToken ?? current?.accessToken
|
|
381
|
+
|
|
382
|
+
try {
|
|
383
|
+
if (token) {
|
|
384
|
+
const client = createOAuthClient(options)
|
|
385
|
+
const params = new URLSearchParams({token, client_id: options.oauth.clientId})
|
|
386
|
+
await client.request<void>({
|
|
387
|
+
method: 'POST',
|
|
388
|
+
url: '/auth/oauth/revoke',
|
|
389
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
390
|
+
body: params.toString(),
|
|
391
|
+
tag: 'oauth.revoke',
|
|
392
|
+
})
|
|
393
|
+
logger.info('OAuth tokens revoked')
|
|
394
|
+
}
|
|
395
|
+
} catch (error) {
|
|
396
|
+
// Revocation is best-effort — local state is cleared regardless.
|
|
397
|
+
logger.warn('OAuth revoke request failed — clearing local state anyway', {error})
|
|
398
|
+
} finally {
|
|
399
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
400
|
+
state.set('oauthRevoked', {
|
|
401
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
402
|
+
oauthTokens: undefined,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* A state source exposing the current OAuth tokens (including expiry), or
|
|
409
|
+
* `null` when not logged in via OAuth.
|
|
410
|
+
*
|
|
411
|
+
* @public
|
|
412
|
+
*/
|
|
413
|
+
export const getOAuthTokensState = bindActionGlobally(
|
|
414
|
+
authStore,
|
|
415
|
+
createStateSourceAction(({state}) => state.oauthTokens ?? null),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
/** Removes the transient PKCE artifacts from session storage. */
|
|
419
|
+
function clearOAuthArtifacts(session: Storage | undefined): void {
|
|
420
|
+
session?.removeItem(OAUTH_VERIFIER_KEY)
|
|
421
|
+
session?.removeItem(OAUTH_STATE_KEY)
|
|
422
|
+
session?.removeItem(OAUTH_RETURN_TO_KEY)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Reads the stashed pre-authorize location, only honouring it when it shares
|
|
427
|
+
* the registered redirect URI's origin so a tampered value cannot redirect
|
|
428
|
+
* off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
|
|
429
|
+
*/
|
|
430
|
+
function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
|
|
431
|
+
const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
|
|
432
|
+
if (!returnTo) return null
|
|
433
|
+
try {
|
|
434
|
+
return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
|
|
435
|
+
} catch {
|
|
436
|
+
return null
|
|
437
|
+
}
|
|
438
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {Subject} from 'rxjs'
|
|
2
|
+
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {type SanityInstance} from '../../store/createSanityInstance'
|
|
5
|
+
import {type StoreContext} from '../../store/defineStore'
|
|
6
|
+
import {AuthStateType} from '../authStateType'
|
|
7
|
+
import {type AuthStoreState} from '../authStore'
|
|
8
|
+
import {type AuthStrategyOptions} from '../authStrategy'
|
|
9
|
+
import {subscribeToStateAndFetchCurrentUser} from '../subscribeToStateAndFetchCurrentUser'
|
|
10
|
+
import {getStorageEvents} from '../utils'
|
|
11
|
+
import {serializeTokens} from './oauthActions'
|
|
12
|
+
import {
|
|
13
|
+
deserializeTokens,
|
|
14
|
+
getOauthInitialState,
|
|
15
|
+
initializeOauthAuth,
|
|
16
|
+
OAUTH_TOKENS_KEY,
|
|
17
|
+
subscribeToOAuthStorageEvents,
|
|
18
|
+
} from './oauthAuth'
|
|
19
|
+
import {type OAuthTokens} from './types'
|
|
20
|
+
|
|
21
|
+
vi.mock('../subscribeToStateAndFetchCurrentUser')
|
|
22
|
+
vi.mock('../utils', async (importOriginal) => {
|
|
23
|
+
const original = await importOriginal<typeof import('../utils')>()
|
|
24
|
+
return {...original, getStorageEvents: vi.fn(() => new Subject())}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const tokens: OAuthTokens = {
|
|
28
|
+
accessToken: 'access-1',
|
|
29
|
+
tokenType: 'bearer',
|
|
30
|
+
expiresIn: 3600,
|
|
31
|
+
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
|
32
|
+
refreshToken: 'refresh-1',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMemoryStorage(seed?: Record<string, string>): Storage {
|
|
36
|
+
const map = new Map<string, string>(Object.entries(seed ?? {}))
|
|
37
|
+
return {
|
|
38
|
+
getItem: (k) => map.get(k) ?? null,
|
|
39
|
+
setItem: (k, v) => void map.set(k, v),
|
|
40
|
+
removeItem: (k) => void map.delete(k),
|
|
41
|
+
clear: () => map.clear(),
|
|
42
|
+
key: (i) => [...map.keys()][i] ?? null,
|
|
43
|
+
get length() {
|
|
44
|
+
return map.size
|
|
45
|
+
},
|
|
46
|
+
} as Storage
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const baseOptions = (
|
|
50
|
+
overrides: Partial<AuthStrategyOptions> & {
|
|
51
|
+
oauthConfig?: {clientId: string; redirectUri: string; organizationId: string}
|
|
52
|
+
storageArea?: Storage
|
|
53
|
+
} = {},
|
|
54
|
+
): AuthStrategyOptions => ({
|
|
55
|
+
authConfig: {
|
|
56
|
+
storageArea: overrides.storageArea,
|
|
57
|
+
oauth: overrides.oauthConfig ?? {
|
|
58
|
+
clientId: 'c',
|
|
59
|
+
redirectUri: 'https://app/callback',
|
|
60
|
+
organizationId: 'org',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
projectId: 'p',
|
|
64
|
+
initialLocationHref: overrides.initialLocationHref ?? 'https://app/',
|
|
65
|
+
clientFactory: vi.fn(),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('serialize/deserialize tokens', () => {
|
|
69
|
+
it('round-trips tokens with expiresAt as an ISO string', () => {
|
|
70
|
+
const raw = serializeTokens(tokens)
|
|
71
|
+
expect(JSON.parse(raw).expiresAt).toBe('2030-01-01T00:00:00.000Z')
|
|
72
|
+
expect(deserializeTokens(raw)).toEqual(tokens)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('omits refreshToken when absent', () => {
|
|
76
|
+
const {refreshToken: _omit, ...withoutRefresh} = tokens
|
|
77
|
+
expect(deserializeTokens(serializeTokens(withoutRefresh))).not.toHaveProperty('refreshToken')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns null for missing or malformed values', () => {
|
|
81
|
+
expect(deserializeTokens(null)).toBeNull()
|
|
82
|
+
expect(deserializeTokens('not json')).toBeNull()
|
|
83
|
+
expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
|
|
84
|
+
expect(deserializeTokens('123')).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns null when expiresAt does not parse to a valid date', () => {
|
|
88
|
+
const raw = JSON.stringify({...JSON.parse(serializeTokens(tokens)), expiresAt: 'garbage'})
|
|
89
|
+
expect(deserializeTokens(raw)).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('getOauthInitialState', () => {
|
|
94
|
+
it('returns LOGGED_IN with tokens when persisted tokens exist', () => {
|
|
95
|
+
const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
|
|
96
|
+
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
97
|
+
expect(result.authState).toMatchObject({type: AuthStateType.LOGGED_IN, token: 'access-1'})
|
|
98
|
+
expect(result.oauthTokens).toEqual(tokens)
|
|
99
|
+
expect(result.authMethod).toBe('localstorage')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns LOGGED_OUT and clears storage when persisted tokens are corrupt', () => {
|
|
103
|
+
const storageArea = createMemoryStorage({
|
|
104
|
+
[OAUTH_TOKENS_KEY]: '{"accessToken":"a","expiresAt":"garbage"}',
|
|
105
|
+
})
|
|
106
|
+
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
107
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
108
|
+
expect(result.oauthTokens).toBeUndefined()
|
|
109
|
+
expect(storageArea.getItem(OAUTH_TOKENS_KEY)).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
|
|
113
|
+
const storageArea = createMemoryStorage()
|
|
114
|
+
const result = getOauthInitialState(
|
|
115
|
+
baseOptions({storageArea, initialLocationHref: 'https://app/callback?code=c&state=s'}),
|
|
116
|
+
)
|
|
117
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGING_IN, isExchangingToken: false})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('returns LOGGED_OUT when a callback URL does not match the redirect URI', () => {
|
|
121
|
+
const storageArea = createMemoryStorage()
|
|
122
|
+
const result = getOauthInitialState(
|
|
123
|
+
baseOptions({storageArea, initialLocationHref: 'https://other/callback?code=c&state=s'}),
|
|
124
|
+
)
|
|
125
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('returns LOGGED_OUT when the redirect URI only carries a stray state param', () => {
|
|
129
|
+
const storageArea = createMemoryStorage()
|
|
130
|
+
const result = getOauthInitialState(
|
|
131
|
+
baseOptions({storageArea, initialLocationHref: 'https://app/callback?state=s'}),
|
|
132
|
+
)
|
|
133
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('returns LOGGED_OUT when there are no tokens and no callback', () => {
|
|
137
|
+
const storageArea = createMemoryStorage()
|
|
138
|
+
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
139
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
describe('subscribeToOAuthStorageEvents', () => {
|
|
144
|
+
let events: Subject<StorageEvent>
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
events = new Subject<StorageEvent>()
|
|
148
|
+
vi.mocked(getStorageEvents).mockReturnValue(events)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
function makeContext(storageArea: Storage): {
|
|
152
|
+
context: StoreContext<AuthStoreState>
|
|
153
|
+
set: ReturnType<typeof vi.fn>
|
|
154
|
+
} {
|
|
155
|
+
const set = vi.fn()
|
|
156
|
+
const context = {
|
|
157
|
+
state: {get: () => ({options: {storageArea}}), set},
|
|
158
|
+
instance: {config: {}} as SanityInstance,
|
|
159
|
+
key: null,
|
|
160
|
+
} as unknown as StoreContext<AuthStoreState>
|
|
161
|
+
return {context, set}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it('sets LOGGED_IN when tokens appear in another tab', () => {
|
|
165
|
+
const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
|
|
166
|
+
const {context, set} = makeContext(storageArea)
|
|
167
|
+
subscribeToOAuthStorageEvents(context)
|
|
168
|
+
|
|
169
|
+
events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
170
|
+
|
|
171
|
+
expect(set).toHaveBeenCalledWith(
|
|
172
|
+
'updateOAuthTokensFromStorageEvent',
|
|
173
|
+
expect.objectContaining({
|
|
174
|
+
authState: expect.objectContaining({type: AuthStateType.LOGGED_IN, token: 'access-1'}),
|
|
175
|
+
oauthTokens: tokens,
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('sets LOGGED_OUT when tokens are cleared in another tab', () => {
|
|
181
|
+
const storageArea = createMemoryStorage()
|
|
182
|
+
const {context, set} = makeContext(storageArea)
|
|
183
|
+
subscribeToOAuthStorageEvents(context)
|
|
184
|
+
|
|
185
|
+
events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
186
|
+
|
|
187
|
+
expect(set).toHaveBeenCalledWith('updateOAuthTokensFromStorageEvent', {
|
|
188
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
189
|
+
oauthTokens: undefined,
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('ignores events for other keys or storage areas', () => {
|
|
194
|
+
const storageArea = createMemoryStorage()
|
|
195
|
+
const {context, set} = makeContext(storageArea)
|
|
196
|
+
subscribeToOAuthStorageEvents(context)
|
|
197
|
+
|
|
198
|
+
events.next({storageArea, key: 'other'} as StorageEvent)
|
|
199
|
+
events.next({storageArea: createMemoryStorage(), key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
200
|
+
|
|
201
|
+
expect(set).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('initializeOauthAuth', () => {
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
vi.mocked(getStorageEvents).mockReturnValue(new Subject())
|
|
208
|
+
vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(new Subject().subscribe())
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
function makeContext(storageArea: Storage | undefined): StoreContext<AuthStoreState> {
|
|
212
|
+
return {
|
|
213
|
+
state: {get: () => ({options: {storageArea}}), set: vi.fn()},
|
|
214
|
+
instance: {config: {}} as SanityInstance,
|
|
215
|
+
key: null,
|
|
216
|
+
} as unknown as StoreContext<AuthStoreState>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
it('does not start the stamped-token refresher', () => {
|
|
220
|
+
const result = initializeOauthAuth(makeContext(createMemoryStorage()))
|
|
221
|
+
expect(result.tokenRefresherStarted).toBe(false)
|
|
222
|
+
result.dispose()
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('subscribes without a storage area without throwing', () => {
|
|
226
|
+
const result = initializeOauthAuth(makeContext(undefined))
|
|
227
|
+
expect(() => result.dispose()).not.toThrow()
|
|
228
|
+
})
|
|
229
|
+
})
|