/
createAuthStore.ts
281 lines (253 loc) · 8.29 KB
/
createAuthStore.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import {ClientConfig as SanityClientConfig, SanityClient} from '@sanity/client'
import {defer} from 'rxjs'
import {map, shareReplay, startWith, switchMap} from 'rxjs/operators'
import {memoize} from 'lodash'
import {CorsOriginError} from '../cors'
import {AuthState, AuthStore} from './types'
import {createBroadcastChannel} from './createBroadcastChannel'
import {sessionId} from './sessionId'
import * as storage from './storage'
import {createLoginComponent} from './createLoginComponent'
/** @internal */
export interface AuthProvider {
name: string
title: string
url: string
logo?: string
}
/** @internal */
export interface AuthStoreOptions {
clientFactory: (options: SanityClientConfig) => SanityClient
projectId: string
dataset: string
/**
* Login method to use for the studio the studio. Can be one of:
* - `dual` (default) - attempt to use cookies where possible, falling back to
* storing authentication token in `localStorage` otherwise
* - `cookie` - explicitly disable `localStorage` method, relying only on
* cookies
*/
loginMethod?: 'dual' | 'cookie'
/**
* Append the custom providers to the default providers or replace them.
*/
mode?: 'append' | 'replace'
/**
* If true, don't show the choose provider logo screen, automatically redirect
* to the single provider login
*/
redirectOnSingle?: boolean
/**
* The custom provider implementations
*/
providers?: AuthProvider[]
}
const getStorageKey = (projectId: string) => {
// Project ID is part of the localStorage key so that different projects can
// store their separate tokens, and it's easier to do book keeping.
if (!projectId) throw new Error('Invalid project id')
return `__studio_auth_token_${projectId}`
}
const getToken = (projectId: string): string | null => {
try {
const item = storage.getItem(getStorageKey(projectId))
if (item) {
const {token} = JSON.parse(item) as {token: string}
if (token && typeof token === 'string') {
return token
}
}
} catch (err) {
console.error(err)
}
return null
}
const clearToken = (projectId: string): void => {
try {
storage.removeItem(getStorageKey(projectId))
} catch (err) {
console.error(err)
}
}
const saveToken = ({token, projectId}: {token: string; projectId: string}): void => {
try {
storage.setItem(
getStorageKey(projectId),
JSON.stringify({token, time: new Date().toISOString()})
)
} catch (err) {
console.error(err)
}
}
const getCurrentUser = async (
client: SanityClient,
broadcastToken: (token: string | null) => void
) => {
try {
const user = await client.request({
uri: '/users/me',
withCredentials: true,
tag: 'users.get-current',
})
// if the user came back with an id, assume it's a full CurrentUser
return typeof user?.id === 'string' ? user : null
} catch (err) {
// 401 means the user had some kind of credentials, but failed to authenticate,
// we should clear any local token in this case and treat it as if the used was
// logged out
if (err.statusCode === 401) {
clearToken(client.config().projectId || '')
broadcastToken(null)
return null
}
// Request failed for a non-auth reason, see if this was a CORS-error by
// checking the `/ping` endpoint, which allows all origins
const invalidCorsConfig = await client
.request({uri: '/ping', withCredentials: false, tag: 'cors-check'})
.then(
() => true, // Request succeeded, so likely the CORS origin is disallowed
() => false // Request failed, so likely a network error of some kind
)
if (invalidCorsConfig) {
// Throw a specific error on CORS-errors, to allow us to show a customized dialog
throw new CorsOriginError({projectId: client.config()?.projectId})
}
// Some non-CORS error - is it one of those undefinable network errors?
if (err.isNetworkError && !err.message && err.request && err.request.url) {
const host = new URL(err.request.url).host
throw new Error(`Unknown network error attempting to reach ${host}`)
}
// Some other error, just throw it
throw err
}
}
/**
* @internal
*/
export function _createAuthStore({
clientFactory,
projectId,
dataset,
loginMethod = 'dual',
...providerOptions
}: AuthStoreOptions): AuthStore {
// this broadcast channel receives either a token as a `string` or `null`.
// a new client will be created from it, otherwise, it'll only trigger a retry
// for cookie-based auth
const {broadcast, messages} = createBroadcastChannel<string | null>(`dual_mode_auth_${projectId}`)
// // TODO: there is currently a bug where the AuthBoundary flashes the
// // `NotAuthenticatedComponent` on the first load after a login with
// // cookieless mode. A potential solution to fix this bug is to delay
// // emitting `state$` until the session ID has been converted to a token
// const firstMessage = messages.pipe(first())
const token$ = messages.pipe(startWith(loginMethod === 'dual' ? getToken(projectId) : null))
const state$ = token$.pipe(
// // see above
// debounce(() => firstMessage),
map((token) =>
clientFactory({
projectId,
dataset,
apiVersion: '2021-06-07',
useCdn: false,
...(token && {token}),
withCredentials: true,
requestTagPrefix: 'sanity.studio',
ignoreBrowserTokenWarning: true,
allowReconfigure: false,
// @ts-expect-error: __SANITY_STAGING__ is a global env variable set by the vite config
...(typeof __SANITY_STAGING__ !== 'undefined' && __SANITY_STAGING__
? {
apiHost:
/* __SANITY_STAGING__ is a global variable set by the vite config */
// @ts-expect-error: __SANITY_STAGING__ is a global env variable set by the vite config
typeof __SANITY_STAGING__ !== 'undefined' && __SANITY_STAGING__ === true
? 'https://api.sanity.work'
: undefined,
}
: {}),
})
),
switchMap((client) =>
defer(async (): Promise<AuthState> => {
const currentUser = await getCurrentUser(client, broadcast)
return {
currentUser,
client,
authenticated: !!currentUser,
}
})
),
shareReplay(1)
)
async function handleCallbackUrl() {
if (sessionId && loginMethod === 'dual') {
const requestClient = clientFactory({
projectId,
dataset,
useCdn: true,
withCredentials: true,
apiVersion: '2021-06-07',
requestTagPrefix: 'sanity.studio',
})
// try to get the current user by using the cookie credentials
const currentUser = await getCurrentUser(requestClient, broadcast)
if (currentUser) {
// if that worked, then we don't need to fetch a token
broadcast(null)
} else {
// if that didn't work, then we need to trade the session ID for a token
const {token} = await requestClient.request<{token: string}>({
method: 'GET',
uri: `/auth/fetch`,
query: {sid: sessionId},
tag: 'auth.fetch-token',
})
saveToken({token, projectId})
broadcast(token)
}
} else {
broadcast(loginMethod === 'dual' ? getToken(projectId) : null)
}
}
async function logout() {
const requestClient = clientFactory({
projectId,
dataset,
useCdn: true,
withCredentials: true,
apiVersion: '2021-06-07',
requestTagPrefix: 'sanity.studio',
})
clearToken(projectId)
await requestClient.auth.logout()
broadcast(null)
}
const LoginComponent = createLoginComponent({
...providerOptions,
getClient: () => state$.pipe(map((state) => state.client)),
loginMethod,
})
return {
handleCallbackUrl,
token: token$,
state: state$,
LoginComponent,
logout,
}
}
function hash(value: unknown): string {
if (typeof value !== 'object' || value === null) return `${value}`
// note: this code path works for arrays as well as objects
return JSON.stringify(
Object.fromEntries(
Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b, 'en'))
.map(([k, v]) => [k, hash(v)])
)
)
}
/**
* @internal
*/
export const createAuthStore = memoize(_createAuthStore, hash)