@sanity/sdk 3.4.0-rc.0 → 3.4.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 +3 -3
- package/dist/_exports/_internal.js +5 -5
- package/dist/_exports/agent.d.ts +1 -1
- package/dist/_exports/agent.js +1 -1
- package/dist/_exports/comlink.d.ts +1 -1
- package/dist/_exports/dashboard.d.ts +2 -2
- package/dist/_exports/dashboard.js +2 -2
- package/dist/{applications-903KQTrD.d.ts → applications-Bb-0Kgkc.d.ts} +6 -20
- package/dist/applications-Bb-0Kgkc.d.ts.map +1 -0
- package/dist/{clientStore-QDrJNTM4.js → clientStore-CD-USpIU.js} +11 -15
- package/dist/clientStore-CD-USpIU.js.map +1 -0
- package/dist/{createSanityInstance--Lrldto8.d.ts → createSanityInstance-BQIqyGLX.d.ts} +3 -6
- package/dist/createSanityInstance-BQIqyGLX.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +204 -211
- package/dist/index.js.map +1 -1
- package/dist/{topicStore-BJ54_X6H.js → topicStore-CPhGph6z.js} +53 -13
- package/dist/topicStore-CPhGph6z.js.map +1 -0
- package/dist/{topicStore-VQILEI1R.d.ts → topicStore-fPK2CfqH.d.ts} +91 -4
- package/dist/topicStore-fPK2CfqH.d.ts.map +1 -0
- package/dist/{version-CYqZgJX1.js → version-C7m7OAy9.js} +3 -3
- package/dist/{version-CYqZgJX1.js.map → version-C7m7OAy9.js.map} +1 -1
- package/package.json +6 -6
- package/src/_exports/dashboard.ts +6 -0
- package/src/auth/authStore.test.ts +0 -18
- package/src/auth/authStore.ts +1 -4
- package/src/auth/handleAuthCallback.test.ts +0 -33
- package/src/auth/handleAuthCallback.ts +1 -11
- package/src/auth/logout.test.ts +1 -9
- package/src/auth/logout.ts +1 -18
- package/src/auth/oauth/oauthActions.test.ts +1 -178
- package/src/auth/oauth/oauthActions.ts +46 -111
- package/src/auth/oauth/oauthAuth.test.ts +0 -23
- package/src/auth/oauth/oauthAuth.ts +8 -18
- package/src/config/authConfig.ts +2 -5
- package/src/dashboard/messageBus/bus.test.ts +118 -0
- package/src/dashboard/messageBus/bus.ts +81 -7
- package/src/dashboard/messageBus/topics.test.ts +20 -0
- package/src/dashboard/messageBus/topics.ts +74 -0
- package/src/document/documentConstants.ts +5 -2
- package/src/document/documentStore.test.ts +194 -0
- package/src/document/documentStore.ts +79 -39
- package/src/document/reducers.ts +2 -2
- package/dist/applications-903KQTrD.d.ts.map +0 -1
- package/dist/clientStore-QDrJNTM4.js.map +0 -1
- package/dist/createSanityInstance--Lrldto8.d.ts.map +0 -1
- package/dist/topicStore-BJ54_X6H.js.map +0 -1
- package/dist/topicStore-VQILEI1R.d.ts.map +0 -1
|
@@ -17,25 +17,6 @@ export const OAUTH_VERIFIER_KEY = '__sanity_oauth_verifier'
|
|
|
17
17
|
/** sessionStorage key for the CSRF `state` value. */
|
|
18
18
|
export const OAUTH_STATE_KEY = '__sanity_oauth_state'
|
|
19
19
|
|
|
20
|
-
/** sessionStorage key for the location to return to after the callback. */
|
|
21
|
-
export const OAUTH_RETURN_TO_KEY = '__sanity_oauth_return_to'
|
|
22
|
-
|
|
23
|
-
const OAUTH_CALLBACK_PARAMS = ['code', 'state', 'error', 'error_description']
|
|
24
|
-
|
|
25
|
-
/** Strips the OAuth callback params from a URL. */
|
|
26
|
-
function stripOAuthParams(href: string): string {
|
|
27
|
-
const url = new URL(href, DEFAULT_BASE)
|
|
28
|
-
for (const param of OAUTH_CALLBACK_PARAMS) url.searchParams.delete(param)
|
|
29
|
-
return url.toString()
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Whether `href` is the registered redirect URI (origin + pathname; query and hash ignored). */
|
|
33
|
-
function isCallbackRoute(href: string, redirectUri: string): boolean {
|
|
34
|
-
const current = new URL(href, DEFAULT_BASE)
|
|
35
|
-
const redirect = new URL(redirectUri, DEFAULT_BASE)
|
|
36
|
-
return current.origin === redirect.origin && current.pathname === redirect.pathname
|
|
37
|
-
}
|
|
38
|
-
|
|
39
20
|
interface TokenEndpointResponse {
|
|
40
21
|
access_token: string
|
|
41
22
|
token_type: string
|
|
@@ -48,19 +29,6 @@ function getResourceIndicator(organizationId: string): string {
|
|
|
48
29
|
return `urn:io.sanity:organization:${organizationId}`
|
|
49
30
|
}
|
|
50
31
|
|
|
51
|
-
/**
|
|
52
|
-
* Appends the RFC 8707 `resource` indicator to `params` when an organisation
|
|
53
|
-
* id is configured. Without one, the request is left unscoped.
|
|
54
|
-
*/
|
|
55
|
-
function appendResourceIndicator(
|
|
56
|
-
params: URLSearchParams,
|
|
57
|
-
oauth: NonNullable<AuthOptions['oauth']>,
|
|
58
|
-
): void {
|
|
59
|
-
if (oauth.organizationId) {
|
|
60
|
-
params.append('resource', getResourceIndicator(oauth.organizationId))
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
32
|
/**
|
|
65
33
|
* Serialises tokens for storage, converting `expiresAt` to an ISO string.
|
|
66
34
|
*
|
|
@@ -102,21 +70,6 @@ function createOAuthClient(options: AuthOptions): SanityClient {
|
|
|
102
70
|
})
|
|
103
71
|
}
|
|
104
72
|
|
|
105
|
-
/** POSTs form-encoded params to the OAuth token endpoint. */
|
|
106
|
-
function postTokenRequest(
|
|
107
|
-
client: SanityClient,
|
|
108
|
-
params: URLSearchParams,
|
|
109
|
-
tag: string,
|
|
110
|
-
): Promise<TokenEndpointResponse> {
|
|
111
|
-
return client.request<TokenEndpointResponse>({
|
|
112
|
-
method: 'POST',
|
|
113
|
-
url: '/auth/oauth/token',
|
|
114
|
-
headers: {'content-type': 'application/x-www-form-urlencoded'},
|
|
115
|
-
body: params.toString(),
|
|
116
|
-
tag,
|
|
117
|
-
})
|
|
118
|
-
}
|
|
119
|
-
|
|
120
73
|
/** Converts a token endpoint response to the {@link OAuthTokens} shape. */
|
|
121
74
|
function toOAuthTokens(response: TokenEndpointResponse): OAuthTokens {
|
|
122
75
|
return {
|
|
@@ -146,17 +99,6 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
146
99
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
147
100
|
session?.setItem(OAUTH_VERIFIER_KEY, codeVerifier)
|
|
148
101
|
session?.setItem(OAUTH_STATE_KEY, oauthState)
|
|
149
|
-
// The redirect URI must match the registered one exactly, so the current
|
|
150
|
-
// location (deep link) is stashed here and restored by the callback. OAuth
|
|
151
|
-
// params are only stripped when we are already on the callback route, so
|
|
152
|
-
// legitimate app params like `?state=draft` survive the round trip.
|
|
153
|
-
const currentHref = getDefaultLocation()
|
|
154
|
-
if (currentHref !== DEFAULT_BASE) {
|
|
155
|
-
const returnTo = isCallbackRoute(currentHref, options.oauth.redirectUri)
|
|
156
|
-
? stripOAuthParams(currentHref)
|
|
157
|
-
: currentHref
|
|
158
|
-
session?.setItem(OAUTH_RETURN_TO_KEY, returnTo)
|
|
159
|
-
}
|
|
160
102
|
|
|
161
103
|
const authorizeUrl = new URL(
|
|
162
104
|
'/v1/auth/oauth/authorize',
|
|
@@ -168,7 +110,7 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
168
110
|
authorizeUrl.searchParams.set('state', oauthState)
|
|
169
111
|
authorizeUrl.searchParams.set('code_challenge', codeChallenge)
|
|
170
112
|
authorizeUrl.searchParams.set('code_challenge_method', 'S256')
|
|
171
|
-
|
|
113
|
+
authorizeUrl.searchParams.append('resource', getResourceIndicator(options.oauth.organizationId))
|
|
172
114
|
|
|
173
115
|
logger.info('Starting OAuth authorization')
|
|
174
116
|
if (typeof window !== 'undefined' && typeof window.location?.assign === 'function') {
|
|
@@ -181,14 +123,9 @@ export const startOAuthAuthorization = bindActionGlobally(authStore, async ({sta
|
|
|
181
123
|
* responses, exchanges the authorization `code` for tokens, persists them, and
|
|
182
124
|
* transitions to `LOGGED_IN`.
|
|
183
125
|
*
|
|
184
|
-
* Returns the URL for the caller to
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* params. Returns `false` when there was nothing to handle (no code, or an
|
|
188
|
-
* exchange already in progress). A callback that fails validation while a
|
|
189
|
-
* session is already `LOGGED_IN` (e.g. a remount before the params were
|
|
190
|
-
* stripped) is ignored with a warning and still returns the cleaned URL, so a
|
|
191
|
-
* string result does not imply an exchange ran.
|
|
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).
|
|
192
129
|
*
|
|
193
130
|
* @public
|
|
194
131
|
*/
|
|
@@ -215,29 +152,22 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
215
152
|
|
|
216
153
|
const session = typeof sessionStorage !== 'undefined' ? sessionStorage : undefined
|
|
217
154
|
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// established: a stale callback URL (e.g. remount before the params were
|
|
222
|
-
// stripped, after the artifacts were cleared) or a denied re-authorization
|
|
223
|
-
// must not clobber LOGGED_IN.
|
|
224
|
-
const rejectCallback = (name: string, message: string): string => {
|
|
225
|
-
if (authState.type === AuthStateType.LOGGED_IN) {
|
|
226
|
-
// Leave PKCE artifacts alone: a re-authorization may be in flight.
|
|
227
|
-
logger.warn(`${message} — ignoring callback, session already established`)
|
|
228
|
-
return cleanedUrl
|
|
229
|
-
}
|
|
230
|
-
logger.error(`${message} — rejecting callback`)
|
|
231
|
-
clearOAuthArtifacts(session)
|
|
232
|
-
state.set(name, {authState: {type: AuthStateType.ERROR, error: new Error(message)}})
|
|
233
|
-
return cleanedUrl
|
|
155
|
+
const cleanedUrlObj = new URL(locationHref, DEFAULT_BASE)
|
|
156
|
+
for (const param of ['code', 'state', 'error', 'error_description']) {
|
|
157
|
+
cleanedUrlObj.searchParams.delete(param)
|
|
234
158
|
}
|
|
159
|
+
const cleanedUrl = cleanedUrlObj.toString()
|
|
235
160
|
|
|
236
161
|
if (error) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
241
171
|
}
|
|
242
172
|
|
|
243
173
|
if (!code) {
|
|
@@ -247,12 +177,22 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
247
177
|
|
|
248
178
|
const storedState = session?.getItem(OAUTH_STATE_KEY) ?? null
|
|
249
179
|
if (!returnedState || !storedState || returnedState !== storedState) {
|
|
250
|
-
|
|
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
|
|
251
186
|
}
|
|
252
187
|
|
|
253
188
|
const codeVerifier = session?.getItem(OAUTH_VERIFIER_KEY) ?? null
|
|
254
189
|
if (!codeVerifier) {
|
|
255
|
-
|
|
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
|
|
256
196
|
}
|
|
257
197
|
|
|
258
198
|
logger.info('Exchanging OAuth code for tokens')
|
|
@@ -268,13 +208,18 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
268
208
|
code_verifier: codeVerifier,
|
|
269
209
|
redirect_uri: options.oauth.redirectUri,
|
|
270
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',
|
|
271
219
|
})
|
|
272
|
-
appendResourceIndicator(params, options.oauth)
|
|
273
|
-
const response = await postTokenRequest(client, params, 'oauth.token')
|
|
274
220
|
|
|
275
221
|
const tokens = toOAuthTokens(response)
|
|
276
222
|
options.storageArea?.setItem(options.storageKey, serializeTokens(tokens))
|
|
277
|
-
const returnTo = getSameOriginReturnTo(session, options.oauth.redirectUri)
|
|
278
223
|
clearOAuthArtifacts(session)
|
|
279
224
|
|
|
280
225
|
logger.info('OAuth tokens obtained, user logged in')
|
|
@@ -282,7 +227,7 @@ export const handleOAuthCallback = bindActionGlobally(
|
|
|
282
227
|
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
283
228
|
oauthTokens: tokens,
|
|
284
229
|
})
|
|
285
|
-
return
|
|
230
|
+
return cleanedUrl
|
|
286
231
|
} catch (exchangeError) {
|
|
287
232
|
logger.error('Failed to exchange OAuth code for tokens', {error: exchangeError})
|
|
288
233
|
clearOAuthArtifacts(session)
|
|
@@ -351,9 +296,15 @@ async function doRefreshOAuthTokens({
|
|
|
351
296
|
grant_type: 'refresh_token',
|
|
352
297
|
refresh_token: current.refreshToken,
|
|
353
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',
|
|
354
307
|
})
|
|
355
|
-
appendResourceIndicator(params, options.oauth)
|
|
356
|
-
const response = await postTokenRequest(client, params, 'oauth.refresh')
|
|
357
308
|
|
|
358
309
|
const tokens = toOAuthTokens(response)
|
|
359
310
|
if (!tokens.refreshToken) tokens.refreshToken = current.refreshToken
|
|
@@ -435,20 +386,4 @@ export const getOAuthTokensState = bindActionGlobally(
|
|
|
435
386
|
function clearOAuthArtifacts(session: Storage | undefined): void {
|
|
436
387
|
session?.removeItem(OAUTH_VERIFIER_KEY)
|
|
437
388
|
session?.removeItem(OAUTH_STATE_KEY)
|
|
438
|
-
session?.removeItem(OAUTH_RETURN_TO_KEY)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Reads the stashed pre-authorize location, only honouring it when it shares
|
|
443
|
-
* the registered redirect URI's origin so a tampered value cannot redirect
|
|
444
|
-
* off-site. (`origin` includes the scheme, so non-http(s) URLs are rejected.)
|
|
445
|
-
*/
|
|
446
|
-
function getSameOriginReturnTo(session: Storage | undefined, redirectUri: string): string | null {
|
|
447
|
-
const returnTo = session?.getItem(OAUTH_RETURN_TO_KEY) ?? null
|
|
448
|
-
if (!returnTo) return null
|
|
449
|
-
try {
|
|
450
|
-
return new URL(returnTo).origin === new URL(redirectUri).origin ? returnTo : null
|
|
451
|
-
} catch {
|
|
452
|
-
return null
|
|
453
|
-
}
|
|
454
389
|
}
|
|
@@ -83,11 +83,6 @@ describe('serialize/deserialize tokens', () => {
|
|
|
83
83
|
expect(deserializeTokens('{"foo":"bar"}')).toBeNull()
|
|
84
84
|
expect(deserializeTokens('123')).toBeNull()
|
|
85
85
|
})
|
|
86
|
-
|
|
87
|
-
it('returns null when expiresAt does not parse to a valid date', () => {
|
|
88
|
-
const raw = JSON.stringify({...JSON.parse(serializeTokens(tokens)), expiresAt: 'garbage'})
|
|
89
|
-
expect(deserializeTokens(raw)).toBeNull()
|
|
90
|
-
})
|
|
91
86
|
})
|
|
92
87
|
|
|
93
88
|
describe('getOauthInitialState', () => {
|
|
@@ -99,16 +94,6 @@ describe('getOauthInitialState', () => {
|
|
|
99
94
|
expect(result.authMethod).toBe('localstorage')
|
|
100
95
|
})
|
|
101
96
|
|
|
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
97
|
it('returns LOGGING_IN when the callback URL matches the redirect URI', () => {
|
|
113
98
|
const storageArea = createMemoryStorage()
|
|
114
99
|
const result = getOauthInitialState(
|
|
@@ -125,14 +110,6 @@ describe('getOauthInitialState', () => {
|
|
|
125
110
|
expect(result.authState).toEqual({type: AuthStateType.LOGGED_OUT, isDestroyingSession: false})
|
|
126
111
|
})
|
|
127
112
|
|
|
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
113
|
it('returns LOGGED_OUT when there are no tokens and no callback', () => {
|
|
137
114
|
const storageArea = createMemoryStorage()
|
|
138
115
|
const result = getOauthInitialState(baseOptions({storageArea}))
|
|
@@ -19,9 +19,7 @@ interface SerializedOAuthTokens extends Omit<OAuthTokens, 'expiresAt'> {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Parses persisted token JSON back into {@link OAuthTokens}. Returns `null`
|
|
22
|
-
* when the value is missing or malformed
|
|
23
|
-
* not parse to a valid date (an `Invalid Date` would otherwise read as never
|
|
24
|
-
* expiring, since `NaN <= now` is always `false`).
|
|
22
|
+
* when the value is missing or malformed.
|
|
25
23
|
*
|
|
26
24
|
* @internal
|
|
27
25
|
*/
|
|
@@ -40,13 +38,11 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
|
|
|
40
38
|
return null
|
|
41
39
|
}
|
|
42
40
|
const value = parsed as SerializedOAuthTokens
|
|
43
|
-
const expiresAt = new Date(value.expiresAt)
|
|
44
|
-
if (Number.isNaN(expiresAt.getTime())) return null
|
|
45
41
|
return {
|
|
46
42
|
accessToken: value.accessToken,
|
|
47
43
|
tokenType: 'bearer',
|
|
48
44
|
expiresIn: value.expiresIn,
|
|
49
|
-
expiresAt,
|
|
45
|
+
expiresAt: new Date(value.expiresAt),
|
|
50
46
|
...(value.refreshToken !== undefined && {refreshToken: value.refreshToken}),
|
|
51
47
|
}
|
|
52
48
|
} catch {
|
|
@@ -59,7 +55,7 @@ export function deserializeTokens(raw: string | null): OAuthTokens | null {
|
|
|
59
55
|
*
|
|
60
56
|
* State discovery order:
|
|
61
57
|
* 1. Persisted tokens in `__sanity_oauth_tokens` → `LOGGED_IN`
|
|
62
|
-
* 2. Callback URL (contains `code
|
|
58
|
+
* 2. Callback URL (contains `code`/`state`/`error` and matches `redirectUri`)
|
|
63
59
|
* → `LOGGING_IN`
|
|
64
60
|
* 3. Otherwise → `LOGGED_OUT`
|
|
65
61
|
*
|
|
@@ -72,12 +68,7 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
|
|
|
72
68
|
const redirectUri = authConfig.oauth?.redirectUri
|
|
73
69
|
|
|
74
70
|
// Persisted tokens win
|
|
75
|
-
const
|
|
76
|
-
const tokens = deserializeTokens(rawTokens)
|
|
77
|
-
if (rawTokens && !tokens) {
|
|
78
|
-
// Corrupt entry: drop it so it cannot keep shadowing a fresh login
|
|
79
|
-
storageArea?.removeItem(OAUTH_TOKENS_KEY)
|
|
80
|
-
}
|
|
71
|
+
const tokens = deserializeTokens(storageArea?.getItem(OAUTH_TOKENS_KEY) ?? null)
|
|
81
72
|
if (tokens) {
|
|
82
73
|
return {
|
|
83
74
|
authState: createLoggedInAuthState(tokens.accessToken, null),
|
|
@@ -89,12 +80,11 @@ export function getOauthInitialState(options: AuthStrategyOptions): AuthStrategy
|
|
|
89
80
|
}
|
|
90
81
|
}
|
|
91
82
|
|
|
92
|
-
// Callback URL with code/error whose origin + pathname match our
|
|
93
|
-
// URI (query and hash are ignored).
|
|
94
|
-
// server always sends `code` or `error`, and handleOAuthCallback would
|
|
95
|
-
// otherwise leave the app stuck in LOGGING_IN.
|
|
83
|
+
// Callback URL with code/state/error whose origin + pathname match our
|
|
84
|
+
// redirect URI (query and hash are ignored).
|
|
96
85
|
const {searchParams} = new URL(initialLocationHref, DEFAULT_BASE)
|
|
97
|
-
const isCallback =
|
|
86
|
+
const isCallback =
|
|
87
|
+
searchParams.has('code') || searchParams.has('state') || searchParams.has('error')
|
|
98
88
|
if (redirectUri && isCallback) {
|
|
99
89
|
const loc = new URL(initialLocationHref, DEFAULT_BASE)
|
|
100
90
|
const redirect = new URL(redirectUri, DEFAULT_BASE)
|
package/src/config/authConfig.ts
CHANGED
|
@@ -90,11 +90,8 @@ export interface AuthConfig {
|
|
|
90
90
|
redirectUri: string
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Organisation id used for RFC 8707 resource scoping.
|
|
94
|
-
* omitted, the `resource` parameter is left off the authorize and token
|
|
95
|
-
* requests, so the issued tokens are not restricted to a single
|
|
96
|
-
* organisation.
|
|
93
|
+
* Organisation id used for RFC 8707 resource scoping.
|
|
97
94
|
*/
|
|
98
|
-
organizationId
|
|
95
|
+
organizationId: string
|
|
99
96
|
}
|
|
100
97
|
}
|
|
@@ -170,6 +170,14 @@ describe('dashboard connection', () => {
|
|
|
170
170
|
expect(callerId).toBe('favorites')
|
|
171
171
|
})
|
|
172
172
|
|
|
173
|
+
it('exposes the id a connection connected with', () => {
|
|
174
|
+
installMessageBus({appId: 'dashboard'})
|
|
175
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
176
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
177
|
+
|
|
178
|
+
expect(application.appId).toBe('favorites')
|
|
179
|
+
})
|
|
180
|
+
|
|
173
181
|
it('writes state to a connected application through its client', () => {
|
|
174
182
|
const dashboard = installMessageBus({appId: 'dashboard'})
|
|
175
183
|
const application = connectMessageBus({appId: 'favorites'})
|
|
@@ -469,6 +477,102 @@ describe('event topics', () => {
|
|
|
469
477
|
expect(code).toBe('NO_RESPONDER')
|
|
470
478
|
expect(finalized).toBe(true)
|
|
471
479
|
})
|
|
480
|
+
|
|
481
|
+
it('rejects a sender with REFUSED and the given message', async () => {
|
|
482
|
+
const messageBus = createMessageBus()
|
|
483
|
+
messageBus.subscribe('test.echo', (message) => message.reject('not on screen'))
|
|
484
|
+
|
|
485
|
+
const error = await messageBus.emit('test.echo', {n: 1}).catch((caught) => caught)
|
|
486
|
+
expect(error).toBeInstanceOf(MessageBusError)
|
|
487
|
+
expect(error).toMatchObject({code: 'REFUSED', message: 'not on screen'})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('warns and ignores a reply or reject after a reject', async () => {
|
|
491
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
492
|
+
const messageBus = createMessageBus()
|
|
493
|
+
messageBus.subscribe('test.echo', (message) => {
|
|
494
|
+
message.reject('first')
|
|
495
|
+
message.reject('second')
|
|
496
|
+
message.reply({n: 100})
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
await expect(messageBus.emit('test.echo', {n: 1})).rejects.toMatchObject({
|
|
500
|
+
code: 'REFUSED',
|
|
501
|
+
message: 'first',
|
|
502
|
+
})
|
|
503
|
+
expect(warn).toHaveBeenCalledWith(
|
|
504
|
+
'[sanity-sdk:message-bus] reject ignored for "test.echo": no waiting caller or already replied',
|
|
505
|
+
)
|
|
506
|
+
expect(warn).toHaveBeenCalledWith(
|
|
507
|
+
'[sanity-sdk:message-bus] reply ignored for "test.echo": no waiting caller or already replied',
|
|
508
|
+
)
|
|
509
|
+
})
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
describe('state topic rejections', () => {
|
|
513
|
+
it('rejects a pending query with REFUSED when the host rejects', async () => {
|
|
514
|
+
const host = createMessageBus()
|
|
515
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
516
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
517
|
+
|
|
518
|
+
const pending = app.query('test.suspending', {timeout: null})
|
|
519
|
+
client.reject('test.suspending', 'hidden')
|
|
520
|
+
|
|
521
|
+
await expect(pending).rejects.toMatchObject({code: 'REFUSED', message: 'hidden'})
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('rejects a later query with REFUSED until a value is written', async () => {
|
|
525
|
+
const host = createMessageBus()
|
|
526
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
527
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
528
|
+
client.reject('test.suspending', 'hidden')
|
|
529
|
+
|
|
530
|
+
await expect(app.query('test.suspending', {timeout: null})).rejects.toMatchObject({
|
|
531
|
+
code: 'REFUSED',
|
|
532
|
+
message: 'hidden',
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
client.emit('test.suspending', 'ready')
|
|
536
|
+
await expect(app.query('test.suspending')).resolves.toBe('ready')
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('rejects a query for a topic the connection already holds once rejected', async () => {
|
|
540
|
+
const host = createMessageBus()
|
|
541
|
+
registerStateTopics(host, {'test.count': 0})
|
|
542
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
543
|
+
client.emit('test.count', 1)
|
|
544
|
+
await expect(app.query('test.count')).resolves.toBe(1)
|
|
545
|
+
|
|
546
|
+
client.reject('test.count', 'hidden')
|
|
547
|
+
|
|
548
|
+
await expect(app.query('test.count')).rejects.toMatchObject({code: 'REFUSED'})
|
|
549
|
+
expect(app.subscribe('test.count').getCurrent()).toBe(1)
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('leaves subscribers silent through a rejection and delivers the later value', () => {
|
|
553
|
+
const host = createMessageBus()
|
|
554
|
+
registerStateTopics(host, {'test.suspending': undefined})
|
|
555
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
556
|
+
const seen: string[] = []
|
|
557
|
+
let errored: unknown
|
|
558
|
+
app.subscribe('test.suspending', (value) => seen.push(value), {})
|
|
559
|
+
app.subscribe('test.suspending').subscribe({error: (error) => (errored = error)})
|
|
560
|
+
|
|
561
|
+
client.reject('test.suspending', 'hidden')
|
|
562
|
+
client.emit('test.suspending', 'ready')
|
|
563
|
+
|
|
564
|
+
expect(errored).toBeUndefined()
|
|
565
|
+
expect(seen).toEqual(['ready'])
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('ignores a rejection once the connection has closed', async () => {
|
|
569
|
+
const host = createMessageBus()
|
|
570
|
+
registerStateTopics(host, {'test.count': 0})
|
|
571
|
+
const {app, client} = connect(host, {appId: 'favorites'})
|
|
572
|
+
|
|
573
|
+
app.disconnect()
|
|
574
|
+
expect(() => client.reject('test.count', 'hidden')).not.toThrow()
|
|
575
|
+
})
|
|
472
576
|
})
|
|
473
577
|
|
|
474
578
|
describe('application connections', () => {
|
|
@@ -883,6 +987,20 @@ describe('reset', () => {
|
|
|
883
987
|
expect(seen).toEqual([{ok: true, value: null}, panel])
|
|
884
988
|
})
|
|
885
989
|
|
|
990
|
+
it("clears a connection's rejections along with its state", async () => {
|
|
991
|
+
const host = installMessageBus({appId: 'dashboard'})
|
|
992
|
+
const application = connectMessageBus({appId: 'favorites'})
|
|
993
|
+
if (!application) throw new Error('Expected a dashboard message bus')
|
|
994
|
+
let client: MessageBusClient | undefined
|
|
995
|
+
host.connections.subscribe((connected) => (client = connected))
|
|
996
|
+
client?.reject('panels.mode', 'hidden')
|
|
997
|
+
await expect(application.query('panels.mode')).rejects.toMatchObject({code: 'REFUSED'})
|
|
998
|
+
|
|
999
|
+
resetMessageBus()
|
|
1000
|
+
|
|
1001
|
+
await expect(application.query('panels.mode')).resolves.toEqual({ok: true, value: null})
|
|
1002
|
+
})
|
|
1003
|
+
|
|
886
1004
|
it('keeps announcing new connections to a host subscribed before the reset', () => {
|
|
887
1005
|
const host = installMessageBus({appId: 'dashboard'})
|
|
888
1006
|
const seen: string[] = []
|