@sanity/sdk 3.0.0 → 3.2.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 +72 -19
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +130 -11
- 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 +2 -2
- package/dist/_exports/dashboard.d.ts +2 -0
- package/dist/_exports/dashboard.js +2 -0
- package/dist/{authStore-C0o1NOxW.d.ts → authStore-BO2vch3I.d.ts} +516 -63
- package/dist/authStore-BO2vch3I.d.ts.map +1 -0
- package/dist/{authStore-CtE9bLHW.js → clientStore-BI4D8HhJ.js} +390 -15
- package/dist/clientStore-BI4D8HhJ.js.map +1 -0
- package/dist/{createSanityInstance-DUI1bGIg.d.ts → createSanityInstance-BQIqyGLX.d.ts} +17 -2
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/{createStateSourceAction-BOKtqFNm.js → createStateSourceAction-DdAhEfnZ.js} +7 -3
- package/dist/createStateSourceAction-DdAhEfnZ.js.map +1 -0
- package/dist/defineProperty-Bho9Ngsi.js +31 -0
- package/dist/{getNodeState-CR3__B2D.js → getNodeState-B4nG0sjm.js} +2 -2
- package/dist/{getNodeState-CR3__B2D.js.map → getNodeState-B4nG0sjm.js.map} +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +370 -90
- package/dist/index.js.map +1 -1
- package/dist/topicStore-Ct7IFNtY.js +565 -0
- package/dist/topicStore-Ct7IFNtY.js.map +1 -0
- package/dist/topicStore-D0WqhQGF.d.ts +435 -0
- package/dist/topicStore-D0WqhQGF.d.ts.map +1 -0
- package/dist/{queryStore-C7yMHPUI.js → version-BcRmCiYi.js} +48 -16
- package/dist/version-BcRmCiYi.js.map +1 -0
- package/package.json +19 -18
- package/src/_exports/_internal.ts +13 -0
- package/src/_exports/dashboard.ts +36 -0
- package/src/_exports/index.ts +17 -1
- package/src/access/checkPermissions.test.ts +1 -1
- package/src/access/checkPermissions.ts +1 -1
- package/src/applications/applications.test-d.ts +28 -4
- package/src/applications/applications.test.ts +4 -4
- package/src/applications/applications.ts +43 -18
- package/src/auth/authMode.test.ts +24 -0
- package/src/auth/authMode.ts +8 -3
- package/src/auth/authStore.ts +12 -0
- package/src/auth/authStrategy.ts +2 -0
- package/src/auth/handleAuthCallback.test.ts +2 -2
- package/src/auth/handleAuthCallback.ts +1 -1
- package/src/auth/logout.test.ts +46 -1
- package/src/auth/logout.ts +2 -1
- package/src/auth/oauth/oauthActions.test.ts +457 -0
- package/src/auth/oauth/oauthActions.ts +389 -0
- package/src/auth/oauth/oauthAuth.test.ts +206 -0
- package/src/auth/oauth/oauthAuth.ts +168 -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/auth/refreshStampedToken.ts +1 -1
- package/src/auth/studioModeAuth.test.ts +1 -1
- package/src/auth/studioModeAuth.ts +1 -1
- package/src/auth/subscribeToStateAndFetchCurrentUser.test.ts +2 -2
- package/src/auth/subscribeToStateAndFetchCurrentUser.ts +1 -1
- package/src/comments/addonDatasetStore.test.ts +2 -2
- package/src/comments/addonDatasetStore.ts +2 -2
- package/src/comments/commentActions.ts +6 -5
- package/src/comments/commentsStore.ts +4 -4
- 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 +5 -9
- package/src/document/documentStore.concurrency.test.ts +2 -1
- package/src/document/documentStore.test.ts +19 -18
- package/src/document/documentStore.ts +17 -17
- package/src/document/permissions.ts +2 -1
- package/src/document/processActions.test.ts +2 -1
- package/src/document/processMutations.ts +61 -24
- package/src/document/reducers.ts +2 -2
- package/src/installations/installations.test-d.ts +3 -3
- package/src/installations/installations.test.ts +2 -2
- package/src/installations/installations.ts +4 -21
- package/src/organization/organization.test.ts +2 -2
- package/src/organization/organization.ts +1 -1
- package/src/organizations/organizations.test.ts +2 -2
- package/src/organizations/organizations.ts +1 -1
- package/src/presence/presenceStore.test.ts +15 -22
- package/src/presence/presenceStore.ts +3 -2
- package/src/project/project.test.ts +3 -3
- package/src/project/project.ts +1 -1
- package/src/projection/getProjectionState.test.ts +6 -4
- package/src/projection/getProjectionState.ts +4 -4
- package/src/projection/resolveProjection.ts +2 -2
- package/src/projects/projects.test.ts +2 -2
- package/src/projects/projects.ts +1 -1
- package/src/query/queryStore.ts +6 -6
- package/src/store/createSanityInstance.test.ts +1 -1
- package/src/store/createSanityInstance.ts +4 -4
- package/src/store/createStoreInstance.test.ts +0 -5
- package/src/telemetry/telemetryManager.test.ts +1 -1
- package/src/telemetry/telemetryManager.ts +2 -2
- 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/users/usersStore.test.ts +1 -1
- package/src/users/usersStore.ts +5 -5
- package/src/utils/createGroqSearchFilter.test.ts +8 -0
- package/src/utils/createGroqSearchFilter.ts +6 -3
- package/src/utils/ids.test.ts +40 -15
- package/src/utils/ids.ts +47 -2
- package/dist/authStore-C0o1NOxW.d.ts.map +0 -1
- package/dist/authStore-CtE9bLHW.js.map +0 -1
- package/dist/clientStore-BtE2-q2i.js +0 -90
- package/dist/clientStore-BtE2-q2i.js.map +0 -1
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +0 -1
- package/dist/createStateSourceAction-BOKtqFNm.js.map +0 -1
- package/dist/getEnv-D5RGkQLu.js +0 -8
- package/dist/getEnv-D5RGkQLu.js.map +0 -1
- package/dist/logger-DAtJb5RQ.js +0 -173
- package/dist/logger-DAtJb5RQ.js.map +0 -1
- package/dist/queryStore-C7yMHPUI.js.map +0 -1
- package/dist/rolldown-runtime-vyAXikos.js +0 -9
- package/dist/telemetryManager-UIEiMtC0.js +0 -127
- package/dist/telemetryManager-UIEiMtC0.js.map +0 -1
- package/dist/version-D8RP_Q59.js +0 -9
- package/dist/version-D8RP_Q59.js.map +0 -1
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
interface TokenEndpointResponse {
|
|
21
|
+
access_token: string
|
|
22
|
+
token_type: string
|
|
23
|
+
expires_in: number
|
|
24
|
+
refresh_token?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Builds the RFC 8707 resource indicator for an organisation. */
|
|
28
|
+
function getResourceIndicator(organizationId: string): string {
|
|
29
|
+
return `urn:io.sanity:organization:${organizationId}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Serialises tokens for storage, converting `expiresAt` to an ISO string.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
export function serializeTokens(tokens: OAuthTokens): string {
|
|
38
|
+
return JSON.stringify({
|
|
39
|
+
accessToken: tokens.accessToken,
|
|
40
|
+
tokenType: tokens.tokenType,
|
|
41
|
+
expiresIn: tokens.expiresIn,
|
|
42
|
+
expiresAt: tokens.expiresAt.toISOString(),
|
|
43
|
+
...(tokens.refreshToken !== undefined && {refreshToken: tokens.refreshToken}),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type AuthOptions = AuthStoreState['options']
|
|
48
|
+
type ConfiguredOAuthOptions = AuthOptions & {oauth: NonNullable<AuthOptions['oauth']>}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads the store options, throwing when the instance was not configured for
|
|
52
|
+
* OAuth.
|
|
53
|
+
*/
|
|
54
|
+
function getOAuthOptions(state: AuthStoreState): ConfiguredOAuthOptions {
|
|
55
|
+
const {options} = state
|
|
56
|
+
if (!options.oauth) {
|
|
57
|
+
throw new Error('OAuth is not configured on this instance (missing `auth.oauth`).')
|
|
58
|
+
}
|
|
59
|
+
return options as ConfiguredOAuthOptions
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Creates a client for the (unauthenticated) public OAuth token/revoke calls. */
|
|
63
|
+
function createOAuthClient(options: AuthOptions): SanityClient {
|
|
64
|
+
return options.clientFactory({
|
|
65
|
+
apiVersion: 'v1',
|
|
66
|
+
requestTagPrefix: REQUEST_TAG_PREFIX,
|
|
67
|
+
useProjectHostname: false,
|
|
68
|
+
useCdn: false,
|
|
69
|
+
...(options.apiHost && {apiHost: options.apiHost}),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Converts a token endpoint response to the {@link OAuthTokens} shape. */
|
|
74
|
+
function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
|
|
75
|
+
return {
|
|
76
|
+
accessToken: response.access_token,
|
|
77
|
+
tokenType: 'bearer',
|
|
78
|
+
expiresIn: response.expires_in,
|
|
79
|
+
expiresAt: new Date(Date.now() + response.expires_in * 1000),
|
|
80
|
+
...(response.refresh_token !== undefined && {refreshToken: response.refresh_token}),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Starts the OAuth authorization-code + PKCE flow: generates a `code_verifier`,
|
|
86
|
+
* `code_challenge` and `state`, persists the verifier and state to
|
|
87
|
+
* `sessionStorage`, then navigates the browser to the authorize endpoint.
|
|
88
|
+
*
|
|
89
|
+
* @public
|
|
90
|
+
*/
|
|
91
|
+
export const startOAuthAuthorization = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
92
|
+
const logger = getAuthLogger(instance)
|
|
93
|
+
const options = getOAuthOptions(state.get())
|
|
94
|
+
|
|
95
|
+
const codeVerifier = generateCodeVerifier()
|
|
96
|
+
const oauthState = generateState()
|
|
97
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
|
98
|
+
|
|
99
|
+
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
100
|
+
session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
|
|
101
|
+
session?.setItem(OAUTH_STATE_KEY, oauthState)
|
|
102
|
+
|
|
103
|
+
const authorizeUrl = new URL(
|
|
104
|
+
'/v1/auth/oauth/authorize',
|
|
105
|
+
options.apiHost ?? 'https://api.sanity.io',
|
|
106
|
+
)
|
|
107
|
+
authorizeUrl.searchParams.set('response_type', 'code')
|
|
108
|
+
authorizeUrl.searchParams.set('client_id', options.oauth.clientId)
|
|
109
|
+
authorizeUrl.searchParams.set('redirect_uri', options.oauth.redirectUri)
|
|
110
|
+
authorizeUrl.searchParams.set('state', oauthState)
|
|
111
|
+
authorizeUrl.searchParams.set('code_challenge', codeChallenge)
|
|
112
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
113
|
+
authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
|
|
114
|
+
|
|
115
|
+
logger.info('Starting OAuth authorization')
|
|
116
|
+
if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
|
|
117
|
+
window.location.assign(authorizeUrl.toString())
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handles the OAuth redirect callback: validates `state`, surfaces `error`
|
|
123
|
+
* responses, exchanges the authorization `code` for tokens, persists them, and
|
|
124
|
+
* transitions to `LOGGED_IN`.
|
|
125
|
+
*
|
|
126
|
+
* Returns the callback URL cleaned of OAuth params (for the caller to
|
|
127
|
+
* `history.replaceState`) when a callback was processed, or `false` when there
|
|
128
|
+
* was nothing to handle (no code, or an exchange already in progress).
|
|
129
|
+
*
|
|
130
|
+
* @public
|
|
131
|
+
*/
|
|
132
|
+
export const handleOAuthCallback = bindActionGlobally(
|
|
133
|
+
authStore,
|
|
134
|
+
async (
|
|
135
|
+
{state, instance},
|
|
136
|
+
locationHref: string = getDefaultLocation(),
|
|
137
|
+
): Promise<string | false> => {
|
|
138
|
+
const logger = getAuthLogger(instance)
|
|
139
|
+
const options = getOAuthOptions(state.get())
|
|
140
|
+
|
|
141
|
+
const {authState} = state.get()
|
|
142
|
+
if (authState.type === AuthStateType.LOGGING_IN && authState.isExchangingToken) {
|
|
143
|
+
logger.debug('Skipping OAuth callback - token exchange already in progress')
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const callbackUrl = new URL(locationHref, DEFAULT_BASE)
|
|
148
|
+
const code = callbackUrl.searchParams.get('code')
|
|
149
|
+
const returnedState = callbackUrl.searchParams.get('state')
|
|
150
|
+
const error = callbackUrl.searchParams.get('error')
|
|
151
|
+
const errorDescription = callbackUrl.searchParams.get('error_description')
|
|
152
|
+
|
|
153
|
+
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
154
|
+
|
|
155
|
+
const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
|
|
156
|
+
for (const param of ['code', 'state', 'error', 'error_description']) {
|
|
157
|
+
cleanedUrlObj.searchParams.delete(param)
|
|
158
|
+
}
|
|
159
|
+
const cleanedUrl = cleanedUrlObj.toString()
|
|
160
|
+
|
|
161
|
+
if (error) {
|
|
162
|
+
logger.warn('OAuth callback returned an error', {error, errorDescription})
|
|
163
|
+
clearOAuthArtifacts(session)
|
|
164
|
+
state.set('oauthCallbackError', {
|
|
165
|
+
authState: {
|
|
166
|
+
type: AuthStateType.ERROR,
|
|
167
|
+
error: new Error(errorDescription ? `${error}: ${errorDescription}` : error),
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
return cleanedUrl
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!code) {
|
|
174
|
+
logger.debug('No OAuth code found in callback URL')
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
|
|
179
|
+
if (!returnedState || !storedState || returnedState !== storedState) {
|
|
180
|
+
logger.error('OAuth state mismatch — rejecting callback')
|
|
181
|
+
clearOAuthArtifacts(session)
|
|
182
|
+
state.set('oauthStateMismatch', {
|
|
183
|
+
authState: {type: AuthStateType.ERROR, error: new Error('OAuth state mismatch')},
|
|
184
|
+
})
|
|
185
|
+
return cleanedUrl
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
|
|
189
|
+
if (!codeVerifier) {
|
|
190
|
+
logger.error('OAuth code verifier missing — cannot exchange code')
|
|
191
|
+
clearOAuthArtifacts(session)
|
|
192
|
+
state.set('oauthVerifierMissing', {
|
|
193
|
+
authState: {type: AuthStateType.ERROR, error: new Error('OAuth code verifier missing')},
|
|
194
|
+
})
|
|
195
|
+
return cleanedUrl
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
logger.info('Exchanging OAuth code for tokens')
|
|
199
|
+
state.set('oauthExchange', {
|
|
200
|
+
authState: {type: AuthStateType.LOGGING_IN, isExchangingToken: true},
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const client = createOAuthClient(options)
|
|
205
|
+
const params = new URLSearchParams({
|
|
206
|
+
grant_type: 'authorization_code',
|
|
207
|
+
code,
|
|
208
|
+
code_verifier: codeVerifier,
|
|
209
|
+
redirect_uri: options.oauth.redirectUri,
|
|
210
|
+
client_id: options.oauth.clientId,
|
|
211
|
+
resource: getResourceIndicator(options.oauth.organizationId),
|
|
212
|
+
})
|
|
213
|
+
const response = await client.request<TokenEndpointResponse>({
|
|
214
|
+
method: 'POST',
|
|
215
|
+
url: '/auth/oauth/token',
|
|
216
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
217
|
+
body: params.toString(),
|
|
218
|
+
tag: 'oauth.token',
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const tokens = toOAuthTokens(response)
|
|
222
|
+
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
223
|
+
clearOAuthArtifacts(session)
|
|
224
|
+
|
|
225
|
+
logger.info('OAuth tokens obtained, user logged in')
|
|
226
|
+
state.set('oauthLoggedIn', {
|
|
227
|
+
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
228
|
+
oauthTokens: tokens,
|
|
229
|
+
})
|
|
230
|
+
return cleanedUrl
|
|
231
|
+
} catch (exchangeError) {
|
|
232
|
+
logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
|
|
233
|
+
clearOAuthArtifacts(session)
|
|
234
|
+
state.set('oauthExchangeError', {
|
|
235
|
+
authState: {type: AuthStateType.ERROR, error: exchangeError},
|
|
236
|
+
})
|
|
237
|
+
return cleanedUrl
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A refresh failure is unrecoverable only when the token endpoint rejects the
|
|
244
|
+
* refresh token itself (a 4xx). Rate-limit (429) and request-timeout (408)
|
|
245
|
+
* responses, 5xx errors and network failures are transient, so the session is
|
|
246
|
+
* kept intact for the caller to retry rather than forcing a logout.
|
|
247
|
+
*/
|
|
248
|
+
function isUnrecoverableRefreshError(error: unknown): boolean {
|
|
249
|
+
return error instanceof ClientError && error.statusCode !== 408 && error.statusCode !== 429
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Single-flight refresh shared across concurrent callers. Safe as a module
|
|
253
|
+
// singleton because `authStore` is a global store (one shared state).
|
|
254
|
+
// ponytail: module-level single-flight; upgrade to per-store keying only if
|
|
255
|
+
// the auth store ever stops being global.
|
|
256
|
+
let refreshInFlight: Promise<OAuthTokens | null> | null = null
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Refreshes the OAuth tokens using the `refresh_token` grant. Concurrent
|
|
260
|
+
* callers share a single in-flight request. An unrecoverable failure (a 4xx
|
|
261
|
+
* rejecting the refresh token) clears the tokens and transitions to
|
|
262
|
+
* `LOGGED_OUT`; transient failures (network, 5xx, rate limits) leave the
|
|
263
|
+
* session intact and rethrow so the caller can retry.
|
|
264
|
+
*
|
|
265
|
+
* @public
|
|
266
|
+
*/
|
|
267
|
+
export const refreshOAuthTokens = bindActionGlobally(authStore, (context) => {
|
|
268
|
+
if (refreshInFlight) return refreshInFlight
|
|
269
|
+
refreshInFlight = doRefreshOAuthTokens(context).finally(() => {
|
|
270
|
+
refreshInFlight = null
|
|
271
|
+
})
|
|
272
|
+
return refreshInFlight
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
async function doRefreshOAuthTokens({
|
|
276
|
+
state,
|
|
277
|
+
instance,
|
|
278
|
+
}: StoreContext<AuthStoreState>): Promise<OAuthTokens | null> {
|
|
279
|
+
const logger = getAuthLogger(instance)
|
|
280
|
+
const options = getOAuthOptions(state.get())
|
|
281
|
+
|
|
282
|
+
const current = state.get().oauthTokens
|
|
283
|
+
if (!current?.refreshToken) {
|
|
284
|
+
logger.warn('No refresh token available — logging out')
|
|
285
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
286
|
+
state.set('oauthRefreshNoToken', {
|
|
287
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
288
|
+
oauthTokens: undefined,
|
|
289
|
+
})
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const client = createOAuthClient(options)
|
|
295
|
+
const params = new URLSearchParams({
|
|
296
|
+
grant_type: 'refresh_token',
|
|
297
|
+
refresh_token: current.refreshToken,
|
|
298
|
+
client_id: options.oauth.clientId,
|
|
299
|
+
resource: getResourceIndicator(options.oauth.organizationId),
|
|
300
|
+
})
|
|
301
|
+
const response = await client.request<TokenEndpointResponse>({
|
|
302
|
+
method: 'POST',
|
|
303
|
+
url: '/auth/oauth/token',
|
|
304
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
305
|
+
body: params.toString(),
|
|
306
|
+
tag: 'oauth.refresh',
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
const tokens = toOAuthTokens(response)
|
|
310
|
+
if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
|
|
311
|
+
|
|
312
|
+
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
313
|
+
logger.info('OAuth tokens refreshed')
|
|
314
|
+
state.set('oauthRefreshed', {
|
|
315
|
+
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
316
|
+
oauthTokens: tokens,
|
|
317
|
+
})
|
|
318
|
+
return tokens
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (!isUnrecoverableRefreshError(error)) {
|
|
321
|
+
// Transient (network / 5xx / rate limit) — keep the session so the
|
|
322
|
+
// caller can retry instead of forcing a logout.
|
|
323
|
+
logger.warn('OAuth token refresh failed — keeping session for retry', {error})
|
|
324
|
+
throw error
|
|
325
|
+
}
|
|
326
|
+
logger.error('OAuth token refresh failed — logging out', {error})
|
|
327
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
328
|
+
state.set('oauthRefreshFailed', {
|
|
329
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
330
|
+
oauthTokens: undefined,
|
|
331
|
+
})
|
|
332
|
+
throw error
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Revokes the OAuth tokens then clears local storage and transitions to `LOGGED_OUT`.
|
|
338
|
+
* Local state is cleared even if the revoke request fails.
|
|
339
|
+
*
|
|
340
|
+
* @public
|
|
341
|
+
*/
|
|
342
|
+
export const revokeOAuthTokens = bindActionGlobally(authStore, async ({state, instance}) => {
|
|
343
|
+
const logger = getAuthLogger(instance)
|
|
344
|
+
const options = getOAuthOptions(state.get())
|
|
345
|
+
|
|
346
|
+
const current = state.get().oauthTokens
|
|
347
|
+
const token = current?.refreshToken ?? current?.accessToken
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
if (token) {
|
|
351
|
+
const client = createOAuthClient(options)
|
|
352
|
+
const params = new URLSearchParams({token, client_id: options.oauth.clientId})
|
|
353
|
+
await client.request<void>({
|
|
354
|
+
method: 'POST',
|
|
355
|
+
url: '/auth/oauth/revoke',
|
|
356
|
+
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
357
|
+
body: params.toString(),
|
|
358
|
+
tag: 'oauth.revoke',
|
|
359
|
+
})
|
|
360
|
+
logger.info('OAuth tokens revoked')
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
// Revocation is best-effort — local state is cleared regardless.
|
|
364
|
+
logger.warn('OAuth revoke request failed — clearing local state anyway', {error})
|
|
365
|
+
} finally {
|
|
366
|
+
options.storageArea?.removeItem(options.storageKey)
|
|
367
|
+
state.set('oauthRevoked', {
|
|
368
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
369
|
+
oauthTokens: undefined,
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* A state source exposing the current OAuth tokens (including expiry), or
|
|
376
|
+
* `null` when not logged in via OAuth.
|
|
377
|
+
*
|
|
378
|
+
* @public
|
|
379
|
+
*/
|
|
380
|
+
export const getOAuthTokensState = bindActionGlobally(
|
|
381
|
+
authStore,
|
|
382
|
+
createStateSourceAction(({state}) => state.oauthTokens ?? null),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
/** Removes the transient PKCE artifacts from session storage. */
|
|
386
|
+
function clearOAuthArtifacts(session: Storage | undefined): void {
|
|
387
|
+
session?.removeItem(OAUTH_VERIFIER_KEY)
|
|
388
|
+
session?.removeItem(OAUTH_STATE_KEY)
|
|
389
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
|
|
88
|
+
describe('getOauthInitialState', () => {
|
|
89
|
+
it('returns LOGGED_IN with tokens when persisted tokens exist', () => {
|
|
90
|
+
const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
|
|
91
|
+
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
92
|
+
expect(result.authState).toMatchObject({type: AuthStateType.LOGGED_IN, token: 'access-1'})
|
|
93
|
+
expect(result.oauthTokens).toEqual(tokens)
|
|
94
|
+
expect(result.authMethod).toBe('localstorage')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
|
|
98
|
+
const storageArea = createMemoryStorage()
|
|
99
|
+
const result = getOauthInitialState(
|
|
100
|
+
baseOptions({storageArea, initialLocationHref: 'https://app/callback?code=c&state=s'}),
|
|
101
|
+
)
|
|
102
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGING_IN, isExchangingToken: false})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns LOGGED_OUT when a callback URL does not match the redirect URI', () => {
|
|
106
|
+
const storageArea = createMemoryStorage()
|
|
107
|
+
const result = getOauthInitialState(
|
|
108
|
+
baseOptions({storageArea, initialLocationHref: 'https://other/callback?code=c&state=s'}),
|
|
109
|
+
)
|
|
110
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns LOGGED_OUT when there are no tokens and no callback', () => {
|
|
114
|
+
const storageArea = createMemoryStorage()
|
|
115
|
+
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
116
|
+
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('subscribeToOAuthStorageEvents', () => {
|
|
121
|
+
let events: Subject<StorageEvent>
|
|
122
|
+
|
|
123
|
+
beforeEach(() => {
|
|
124
|
+
events = new Subject<StorageEvent>()
|
|
125
|
+
vi.mocked(getStorageEvents).mockReturnValue(events)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
function makeContext(storageArea: Storage): {
|
|
129
|
+
context: StoreContext<AuthStoreState>
|
|
130
|
+
set: ReturnType<typeof vi.fn>
|
|
131
|
+
} {
|
|
132
|
+
const set = vi.fn()
|
|
133
|
+
const context = {
|
|
134
|
+
state: {get: () => ({options: {storageArea}}), set},
|
|
135
|
+
instance: {config: {}} as SanityInstance,
|
|
136
|
+
key: null,
|
|
137
|
+
} as unknown as StoreContext<AuthStoreState>
|
|
138
|
+
return {context, set}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it('sets LOGGED_IN when tokens appear in another tab', () => {
|
|
142
|
+
const storageArea = createMemoryStorage({[OAUTH_TOKENS_KEY]: serializeTokens(tokens)})
|
|
143
|
+
const {context, set} = makeContext(storageArea)
|
|
144
|
+
subscribeToOAuthStorageEvents(context)
|
|
145
|
+
|
|
146
|
+
events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
147
|
+
|
|
148
|
+
expect(set).toHaveBeenCalledWith(
|
|
149
|
+
'updateOAuthTokensFromStorageEvent',
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
authState: expect.objectContaining({type: AuthStateType.LOGGED_IN, token: 'access-1'}),
|
|
152
|
+
oauthTokens: tokens,
|
|
153
|
+
}),
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('sets LOGGED_OUT when tokens are cleared in another tab', () => {
|
|
158
|
+
const storageArea = createMemoryStorage()
|
|
159
|
+
const {context, set} = makeContext(storageArea)
|
|
160
|
+
subscribeToOAuthStorageEvents(context)
|
|
161
|
+
|
|
162
|
+
events.next({storageArea, key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
163
|
+
|
|
164
|
+
expect(set).toHaveBeenCalledWith('updateOAuthTokensFromStorageEvent', {
|
|
165
|
+
authState: {type: AuthStateType.LOGGED_OUT, isDestroyingSession: false},
|
|
166
|
+
oauthTokens: undefined,
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('ignores events for other keys or storage areas', () => {
|
|
171
|
+
const storageArea = createMemoryStorage()
|
|
172
|
+
const {context, set} = makeContext(storageArea)
|
|
173
|
+
subscribeToOAuthStorageEvents(context)
|
|
174
|
+
|
|
175
|
+
events.next({storageArea, key: 'other'} as StorageEvent)
|
|
176
|
+
events.next({storageArea: createMemoryStorage(), key: OAUTH_TOKENS_KEY} as StorageEvent)
|
|
177
|
+
|
|
178
|
+
expect(set).not.toHaveBeenCalled()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('initializeOauthAuth', () => {
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
vi.mocked(getStorageEvents).mockReturnValue(new Subject())
|
|
185
|
+
vi.mocked(subscribeToStateAndFetchCurrentUser).mockReturnValue(new Subject().subscribe())
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
function makeContext(storageArea: Storage | undefined): StoreContext<AuthStoreState> {
|
|
189
|
+
return {
|
|
190
|
+
state: {get: () => ({options: {storageArea}}), set: vi.fn()},
|
|
191
|
+
instance: {config: {}} as SanityInstance,
|
|
192
|
+
key: null,
|
|
193
|
+
} as unknown as StoreContext<AuthStoreState>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
it('does not start the stamped-token refresher', () => {
|
|
197
|
+
const result = initializeOauthAuth(makeContext(createMemoryStorage()))
|
|
198
|
+
expect(result.tokenRefresherStarted).toBe(false)
|
|
199
|
+
result.dispose()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('subscribes without a storage area without throwing', () => {
|
|
203
|
+
const result = initializeOauthAuth(makeContext(undefined))
|
|
204
|
+
expect(() => result.dispose()).not.toThrow()
|
|
205
|
+
})
|
|
206
|
+
})
|