Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(auth): Implement new initializer function API (#3012)

authored by kitten.sh and committed by

GitHub 4133d918 2c96edde

+730 -579
+38
.changeset/perfect-lobsters-kiss.md
··· 1 + --- 2 + '@urql/exchange-auth': major 3 + --- 4 + 5 + Implement new `authExchange` API, which removes the need for an `authState` (i.e. an internal authentication state) and removes `getAuth`, replacing it with a separate `refreshAuth` flow. 6 + 7 + The new API requires you to now pass an initializer function. This function receives a `utils` 8 + object with `utils.mutate` and `utils.appendHeaders` utility methods. 9 + It must return the configuration object, wrapped in a promise, and this configuration is similar to 10 + what we had before, if you're migrating to this. Its `refreshAuth` method is now only called after 11 + authentication errors occur and not on initialization. Instead, it's now recommended that you write 12 + your initialization logic in-line. 13 + 14 + ```js 15 + authExchange(async utils => { 16 + let token = localStorage.getItem('token'); 17 + let refreshToken = localStorage.getItem('refreshToken'); 18 + return { 19 + addAuthToOperation(operation) { 20 + return utils.appendHeaders(operation, { 21 + Authorization: `Bearer ${token}`, 22 + }); 23 + }, 24 + didAuthError(error) { 25 + return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 26 + }, 27 + async refreshAuth() { 28 + const result = await utils.mutate(REFRESH, { token }); 29 + if (result.data?.refreshLogin) { 30 + token = result.data.refreshLogin.token; 31 + refreshToken = result.data.refreshLogin.refreshToken; 32 + localStorage.setItem('token', token); 33 + localStorage.setItem('refreshToken', refreshToken); 34 + } 35 + }, 36 + }; 37 + }); 38 + ```
+202 -182
docs/advanced/authentication.md
··· 59 59 exchanges: [ 60 60 dedupExchange, 61 61 cacheExchange, 62 - authExchange({ 63 - /* config */ 62 + authExchange(async utils => { 63 + return { 64 + /* config... */ 65 + }; 64 66 }), 65 67 fetchExchange, 66 68 ], 67 69 }); 68 70 ``` 69 71 72 + You pass an initialization function to the `authExchange`. This function is called by the exchange 73 + when it first initializes. It'll let you receive an object of utilities and you must return 74 + a (promisified) object of configuration options. 75 + 70 76 Let's discuss each of the [configuration options](../api/auth-exchange.md#options) and how to use them in turn. 71 77 72 - ### Configuring `getAuth` (initial load, fetch from storage) 78 + ### Configuring the initializer function (initial load) 73 79 74 - The `getAuth` option is used to fetch the auth state. This is how to configure it for fetching the tokens at initial launch in React: 80 + The initializer function must return a promise of a configuration object and hence also gives you an 81 + opportunity to fetch your authentication state from storage. 75 82 76 83 ```js 77 - const getAuth = async ({ authState }) => { 78 - if (!authState) { 79 - const token = localStorage.getItem('token'); 80 - const refreshToken = localStorage.getItem('refreshToken'); 81 - if (token && refreshToken) { 82 - return { token, refreshToken }; 83 - } 84 - return null; 85 - } 84 + async function initializeAuthState() { 85 + const token = localStorage.getItem('token'); 86 + const refreshToken = localStorage.getItem('refreshToken'); 87 + return { token, refreshToken }; 88 + } 86 89 87 - return null; 88 - }; 90 + authExchange(async utils => { 91 + let { token, refreshToken } = initializeAuthState(); 92 + return { 93 + /* config... */ 94 + }; 95 + }); 89 96 ``` 90 97 91 - We check that the `authState` doesn't already exist (this indicates that it is the first time this exchange is executed and not an auth failure) and fetch the auth state from 92 - storage. The structure of this particular `authState` is an object with keys for `token` and 93 - `refreshToken`, but this format is not required. We can use different keys or store any additional 94 - auth related information here. For example, we could decode and store the token expiry date, which 95 - would save us from decoding the JWT every time we want to check whether it has expired. 98 + The first step here is to retrieve our tokens from a kind of storage, which may be asynchronous as 99 + well, as illustrated by `initializeAuthState`. 96 100 97 - In React Native, this is very similar, but because persisted storage in React Native is always asynchronous, so is this function: 101 + In React Native, this is very similar, but because persisted storage in React Native is always 102 + asynchronous and promisified, we would await our tokens. This works because the 103 + function that `authExchange` is async, i.e. must return a `Promise`. 98 104 99 105 ```js 100 - const getAuth = async ({ authState, mutate }) => { 101 - if (!authState) { 102 - const token = await AsyncStorage.getItem(TOKEN_KEY, {}); 103 - const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY, {}); 104 - if (token && refreshToken) { 105 - return { token, refreshToken }; 106 - } 107 - return null; 108 - } 106 + async function initializeAuthState() { 107 + const token = await AsyncStorage.getItem(TOKEN_KEY); 108 + const refreshToken = await AyncStorage.getItem(REFRESH_KEY); 109 + return { token, refreshToken }; 110 + } 109 111 110 - return null; 111 - }; 112 + authExchange(async utils => { 113 + let { token, refreshToken } = initializeAuthState(); 114 + return { 115 + /* config... */ 116 + }; 117 + }); 112 118 ``` 113 119 114 120 ### Configuring `addAuthToOperation` 115 121 116 - The purpose of `addAuthToOperation` is to apply an auth state to each request. Note that the format 117 - of the `authState` will be whatever we've returned from `getAuth` and not constrained by the exchange: 118 - 119 - ```js 120 - import { makeOperation } from '@urql/core'; 122 + The purpose of `addAuthToOperation` is to apply an auth state to each request. Here, we'll use the 123 + tokens we retrieved from storage and add them to our operations. 121 124 122 - const addAuthToOperation = ({ authState, operation }) => { 123 - if (!authState || !authState.token) { 124 - return operation; 125 - } 125 + In this example, we're using a utility we're passed, `appendHeaders`. This utility is a simply 126 + shortcut to quickly add HTTP headers via `fetchOptions` to an `Operation`, however, we may as well 127 + be editing the `Operation` context here using `makeOperation`. 126 128 127 - const fetchOptions = 128 - typeof operation.context.fetchOptions === 'function' 129 - ? operation.context.fetchOptions() 130 - : operation.context.fetchOptions || {}; 129 + ```js 130 + authExchange(async utils => { 131 + let token = await AsyncStorage.getItem(TOKEN_KEY); 132 + let refreshToken = await AyncStorage.getItem(REFRESH_KEY); 131 133 132 - return makeOperation(operation.kind, operation, { 133 - ...operation.context, 134 - fetchOptions: { 135 - ...fetchOptions, 136 - headers: { 137 - ...fetchOptions.headers, 138 - Authorization: authState.token, 139 - }, 134 + return { 135 + addAuthToOperation(operation) { 136 + if (!token) return operation; 137 + return utils.appendHeaders(operation, { 138 + Authorization: `Bearer ${token}`, 139 + }); 140 140 }, 141 - }); 142 - }; 141 + // ... 142 + }; 143 + }); 143 144 ``` 144 145 145 - First, we check that we have an `authState` and a `token`. Then we apply it to the request 146 - `fetchOptions` as an `Authorization` header. The header format can vary based on the API (e.g. using 147 - `Bearer ${token}` instead of just `token`) which is why it'll be up to us to add the header 148 - in the expected format for our API. 146 + First, we check that we have a non-null `token`. Then we apply it to the request using the 147 + `appendHeaders` utility as an `Authorization` header. 148 + 149 + We could also be using `makeOperation` here to update the context in any other way, such as: 150 + 151 + ```js 152 + import { makeOperation } from '@urql/core'; 153 + 154 + makeOperation(operation.kind, operation, { 155 + ...operation.context, 156 + someAuthThing: token, 157 + }); 158 + ``` 149 159 150 160 ### Configuring `didAuthError` 151 161 152 - This function lets the exchange know what is defined to be an API error for your API. `didAuthError` receives an `error` which is of type 153 - [`CombinedError`](../api/core.md#combinederror), and we can use the `graphQLErrors` array in `CombinedError` to determine if an auth error has occurred. 162 + This function lets the `authExchange` know what is defined to be an API error for your API. 163 + `didAuthError` is called by `authExchange` when it receives an `error` on an `OperationResult`, which 164 + is of type [`CombinedError`](../api/core.md#combinederror). 154 165 155 - The GraphQL error looks like something like this: 166 + We can for example check the error's `graphQLErrors` array in `CombinedError` to determine if an auth 167 + error has occurred. While your API may implement this differently, an authentication error on an 168 + execution result may look a little like this if your API uses `extensions.code` on errors: 156 169 157 170 ```js 158 171 { ··· 163 176 extensions: { 164 177 code: 'FORBIDDEN' 165 178 }, 166 - response: { 167 - status: 200 168 - } 169 179 } 170 180 ] 171 181 } 172 182 ``` 173 183 174 - Most GraphQL APIs will communicate auth errors via the [error code 175 - extension](https://www.apollographql.com/docs/apollo-server/data/errors/#codes), which 176 - is the recommended approach. We'll be able to determine whether any of the GraphQL errors were due 184 + If you're building a new API, using `extensions` on errors is the recommended approach to add 185 + metadata to your errors. We'll be able to determine whether any of the GraphQL errors were due 177 186 to an unauthorized error code, which would indicate an auth failure: 178 187 179 188 ```js 180 - const didAuthError = ({ error }) => { 181 - return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 182 - }; 183 - ``` 184 - 185 - For some GraphQL APIs, the auth error is communicated via an 401 HTTP response as is common in RESTful APIs: 186 - 187 - ```js 188 - { 189 - data: null, 190 - errors: [ 191 - { 192 - message: 'Unauthorized: Token has expired', 193 - response: { 194 - status: 401 195 - } 196 - } 197 - ] 198 - } 189 + authExchange(async utils => { 190 + // ... 191 + return { 192 + // ... 193 + didAuthError(error, _operation) { 194 + return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 195 + }, 196 + }; 197 + }); 199 198 ``` 200 199 201 - In this case we can determine the auth error based on the status code of the request: 200 + For some GraphQL APIs, the authentication error is only communicated via a 401 HTTP status as is 201 + common in RESTful APIs, which is suboptimal, but which we can still write a check for. 202 202 203 203 ```js 204 - const didAuthError = ({ error }) => { 205 - return error.graphQLErrors.some( 206 - e => e.response.status === 401, 207 - ); 208 - }, 204 + authExchange(async utils => { 205 + // ... 206 + return { 207 + // ... 208 + didAuthError(error, _operation) { 209 + return error.response.status === 401; 210 + }, 211 + }; 212 + }); 209 213 ``` 210 214 211 - If `didAuthError` returns `true`, it will trigger the exchange to trigger the logic for asking for re-authentication via `getAuth`. 215 + If `didAuthError` returns `true`, it will trigger the `authExchange` to trigger the logic for asking 216 + for re-authentication via `refreshAuth`. 212 217 213 - ### Configuring `getAuth` (triggered after an auth error has occurred) 218 + ### Configuring `refershAuth` (triggered after an auth error has occurred) 214 219 215 220 If the API doesn't support any sort of token refresh, this is where we could simply log the user out. 216 221 217 222 ```js 218 - const getAuth = async ({ authState }) => { 219 - if (!authState) { 220 - const token = localStorage.getItem('token'); 221 - const refreshToken = localStorage.getItem('refreshToken'); 222 - if (token && refreshToken) { 223 - return { token, refreshToken }; 224 - } 225 - return null; 226 - } 227 - 228 - logout(); 229 - 230 - return null; 231 - }; 223 + authExchange(async utils => { 224 + // ... 225 + return { 226 + // ... 227 + async refreshAuth() { 228 + logout(); 229 + }, 230 + }; 231 + }); 232 232 ``` 233 233 234 234 Here, `logout()` is a placeholder that is called when we got an error, so that we can redirect to a ··· 238 238 user first: 239 239 240 240 ```js 241 - const getAuth = async ({ authState, mutate }) => { 242 - if (!authState) { 243 - const token = localStorage.getItem('token'); 244 - const refreshToken = localStorage.getItem('refreshToken'); 245 - if (token && refreshToken) { 246 - return { token, refreshToken }; 247 - } 248 - return null; 249 - } 250 - 251 - const result = await mutate(refreshMutation, { 252 - token: authState!.refreshToken, 253 - }); 241 + authExchange(async utils => { 242 + let token = localStorage.getItem('token'); 243 + let refreshToken = localStorage.getItem('refreshToken'); 254 244 255 - if (result.data?.refreshLogin) { 256 - localStorage.setItem('token', result.data.refreshLogin.token); 257 - localStorage.setItem('refreshToken', result.data.refreshLogin.refreshToken); 245 + return { 246 + // ... 247 + async refreshAuth() { 248 + const result = await utils.mutate(REFRESH, { refreshToken }); 258 249 259 - return { 260 - token: result.data.refreshLogin.token, 261 - refreshToken: result.data.refreshLogin.refreshToken, 262 - }; 263 - } 250 + if (result.data?.refreshLogin) { 251 + // Update our local variables and write to our storage 252 + token = result.data.refreshLogin.token; 253 + refreshToken = result.data.refreshLogin.refreshToken; 254 + localStorage.setItem('token', token); 255 + localStorage.setItem('refreshToken', refreshToken); 256 + } else { 257 + // This is where auth has gone wrong and we need to clean up and redirect to a login page 258 + localStorage.clear(); 259 + logout(); 260 + } 261 + }, 262 + }; 263 + }); 264 + ``` 264 265 265 - // This is where auth has gone wrong and we need to clean up and redirect to a login page 266 - localStorage.clear(); 267 - logout(); 266 + Here we use the special `mutate` utility method provided by the `authExchange` to do the token 267 + refresh. This is a useful method to use if your GraphQL API expects you to make a GraphQL mutation 268 + to update your authentication state. It will send the mutation and bypass all authentication and 269 + prior exchanges. 268 270 269 - return null; 270 - } 271 - ``` 271 + If your authentication is not handled via GraphQL but a REST endpoint, you can use the `fetch` API 272 + here however instead of a mutation. 272 273 273 - Here we use the special mutate function provided by the auth exchange to do the token refresh. If your auth is not handled via GraphQL but a REST endpoint, you can 274 - use `fetch` in this function instead of a mutation. All other requests will be paused while `getAuth` returns, so we never have to handle multiple auth failures 275 - at the same time. 274 + All other requests will be paused while `refreshAuth` runs, so we won't have to deal with multiple 275 + authentication errors or refreshes at once. 276 276 277 277 ### Configuring `willAuthError` 278 278 279 - `willAuthError` is an optional parameter and is run _before_ a network request is made. We can use it to trigger the logic in 280 - `getAuth` without the need to send a request and get a GraphQL Error back. For example, we can use this to predict that the authentication will fail because our JWT is invalid already: 279 + `willAuthError` is an optional parameter and is run _before_ a request is made. 280 + 281 + We can use it to trigger an authentication error and let the `authExchange` run our `refreshAuth` 282 + function without the need to first let a request fail with an authentication error. For example, we 283 + can use this to predict an authentication error, for instance, because of expired JWT tokens. 281 284 282 285 ```js 283 - const willAuthError = ({ authState }) => { 284 - if (!authState || /* JWT is expired */) return true; 285 - return false; 286 - } 286 + authExchange(async utils => { 287 + // ... 288 + return { 289 + // ... 290 + willAuthError(_operation) { 291 + // Check whether `token` JWT is expired 292 + return false; 293 + }, 294 + }; 295 + }); 287 296 ``` 288 297 289 298 This can be really useful when we know when our authentication state is invalid and want to prevent 290 - even sending any operation that we know will fail with an authentication error. However, if we were 291 - to use this and are logging in our users with a login _mutation_ then the above code will 292 - unfortunately never let this login mutation through to our GraphQL API. 299 + even sending any operation that we know will fail with an authentication error. 293 300 294 - If we have such a mutation we may need to write a more sophisticated `willAuthError` function like 295 - the following: 301 + However, we have to be careful on how we define this function, if some queries or login mutations 302 + are sent to our API without being logged in. In these cases, it's better to either detect the 303 + mutations we'd like to allow or return `false` when a token isn't set in storage yet. 296 304 297 - ```js 298 - const willAuthError = ({ operation, authState }) => { 299 - if (!authState) { 300 - // Detect our login mutation and let this operation through: 301 - return !( 302 - operation.kind === 'mutation' && 303 - // Here we find any mutation definition with the "login" field 304 - operation.query.definitions.some(definition => { 305 - return ( 306 - definition.kind === 'OperationDefinition' && 307 - definition.selectionSet.selections.some(node => { 308 - // The field name is just an example, since signup may also be an exception 309 - return node.kind === 'Field' && node.name.value === 'login'; 310 - }) 311 - ); 312 - }) 313 - ); 314 - } else if (false /* JWT is expired */) { 315 - return true; 316 - } 305 + If we'd like to detect a mutation that will never fail with an authentication error, we could for 306 + instance write the following logic: 317 307 318 - return false; 319 - }; 308 + ```js 309 + authExchange(async utils => { 310 + // ... 311 + return { 312 + // ... 313 + willAuthError(operation) { 314 + if ( 315 + operation.kind === 'mutation' && 316 + // Here we find any mutation definition with the "login" field 317 + operation.query.definitions.some(definition => { 318 + return ( 319 + definition.kind === 'OperationDefinition' && 320 + definition.selectionSet.selections.some(node => { 321 + // The field name is just an example, since signup may also be an exception 322 + return node.kind === 'Field' && node.name.value === 'login'; 323 + }) 324 + ); 325 + }) 326 + ) { 327 + return false; 328 + } else if (false /* is JWT expired? */) { 329 + return true; 330 + } else { 331 + return false; 332 + } 333 + }, 334 + }; 335 + }); 320 336 ``` 321 337 322 - Alternatively, you may decide to let all operations through if `authState` isn't defined or to allow 323 - all mutations through. In an application that allows unauthenticated users to perform various 324 - actions, it's a good idea for us to return `false` when `!authState` applies. 325 - 326 - [Read more about `@urql/exchange-auth`'s API in our API docs.](../api/auth-exchange.md) 338 + Alternatively, you may decide to let all operations through if your token isn't set in storage, i.e. 339 + if you have no prior authentication state. 327 340 328 341 ## Handling Logout by reacting to Errors 329 342 ··· 348 361 } 349 362 }, 350 363 }), 351 - authExchange({ 352 - /* config */ 364 + authExchange(async utils => { 365 + return { 366 + /* config */ 367 + }; 353 368 }), 354 369 fetchExchange, 355 370 ], ··· 365 380 366 381 ## Cache Invalidation on Logout 367 382 368 - If we're dealing with multiple authentication states at the same time, e.g. logouts, we need to ensure that the `Client` is reinitialized whenever the authentication state changes. Here's an example of how we may do this in React if necessary: 383 + If we're dealing with multiple authentication states at the same time, e.g. logouts, we need to 384 + ensure that the `Client` is reinitialized whenever the authentication state changes. 385 + Here's an example of how we may do this in React if necessary: 369 386 370 387 ```jsx 371 388 import { createClient, Provider } from 'urql'; ··· 391 408 } 392 409 ``` 393 410 394 - When the application launches, the first thing we do is check whether the user has any auth tokens in persisted storage. This will tell us 395 - whether to show the user the logged in or logged out view. 411 + When the application launches, the first thing we do is check whether the user has any authentication 412 + tokens in persisted storage. This will tell us whether to show the user the logged in or logged out view. 396 413 397 414 The `isLoggedIn` prop should always be updated based on authentication state change. For instance, we may set it to 398 415 `true` after the user has authenticated and their tokens have been added to storage, and set it to 399 416 `false` once the user has been logged out and their tokens have been cleared. It's important to clear 400 417 or add tokens to a storage _before_ updating the prop in order for the auth exchange to work 401 418 correctly. 419 + 420 + This pattern of creating a new `Client` when changing authentication states is especially useful 421 + since it will also recreate our client-side cache and invalidate all cached data.
+19 -59
docs/api/auth-exchange.md
··· 32 32 exchanges: [ 33 33 dedupExchange, 34 34 cacheExchange, 35 - authExchange({ 36 - /* config */ 35 + authExchange(async utils => { 36 + return { 37 + /* config... */ 38 + }; 37 39 }), 38 40 fetchExchange, 39 41 ], 40 42 }); 41 43 ``` 42 44 43 - The `authExchange` accepts an object of options, which are used to configure how your 44 - authentication method works. Internally, the `authExchange` keeps an authentication state, whose 45 - shape is determined by the functions passed to the exchange's options: 45 + The `authExchange` accepts an initialization function. This function is called when your exchange 46 + and `Client` first start up, and must return an object of options wrapped in a `Promise`, which is 47 + used to configure how your authentication method works. 48 + 49 + You can use this function to first retrieve your authentication state from a kind 50 + of local storage, or to call your API to validate your authentication state first. 51 + 52 + The relevant configuration options, returned to the `authExchange`, then determine 53 + how the `authExchange` behaves: 46 54 47 55 - `addAuthToOperation` must be provided to tell `authExchange` how to add authentication information 48 56 to an operation, e.g. how to add the authentication state to an operation's fetch headers. 49 - - `getAuth` must be provided to let the `authExchange` handle the authentication flow, including 50 - token refreshes and other reauthentication. It may send mutations to the GraphQL API or make 51 - out-of-band API requests using `fetch`. 52 - - `didAuthError` may be provided to let the `authExchange` detect authentication errors from the API 53 - to trigger the `getAuth` method and reauthentication flow. 54 57 - `willAuthError` may be provided to detect expired tokens or tell whether an operation will likely 55 - fail due to an authentication error, which may trigger the `getAuth` method and reauthentication 56 - flow early. 57 - 58 - ## Options 59 - 60 - | Option | Description | 61 - | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 62 - | `addAuthToOperation` | Receives a parameter object with the current `authState` (`null \| T`) and an error ([See `CombinedError`](./core.md#combinederror)). It should return the same operation to which the authentication state has been added, e.g. as an authentication header. | 63 - | `getAuth` | This provided method receives the `authState` (`null \| T`). It should then refresh the authentication state using either a `fetch`call or [the`mutate`method, which is similar to`client.mutation`](./core.md#clientmutation) and return a new `authState`object. In case it receives`null` it should return a stored authentication state, e.g. from local storage. It's allowed to throw an error, which will interrupt the auth flow and let the authentication error fallthrough. | 64 - | `didAuthError` | May be provided and return a `boolean` that indicates whether an error is an authentication error, given `error: CombinedError` and `authState: T \| null` as parameters. | 65 - | `willAuthError` | May be provided and return a `boolean` that indicates whether an operation is likely to fail, e.g. due to an expired token, to trigger the authentication flow early, and is given `operation: Operation` and `authState: T \| null` as parameters. | 66 - 67 - ## Examples 68 - 69 - The `addAuthToOperation` method is frequently populated with a function that adds the `authState` to 70 - the operation's fetch headers. 71 - 72 - ```js 73 - function addAuthToOperation: ({ 74 - authState, 75 - operation, 76 - }) { 77 - // the token isn't in the auth state, return the operation without changes 78 - if (!authState || !authState.token) { 79 - return operation; 80 - } 58 + fail due to an authentication error. 59 + - `didAuthError` may be provided to let the `authExchange` detect authentication errors from the 60 + API on results. 61 + - `refreshAuth` is called when an authentication error occurs and gives you an opportunity to update 62 + your authentication state. Afterwards, the `authExchange` will retry your operation. 81 63 82 - // fetchOptions can be a function (See Client API) but you can simplify this based on usage 83 - const fetchOptions = 84 - typeof operation.context.fetchOptions === 'function' 85 - ? operation.context.fetchOptions() 86 - : operation.context.fetchOptions || {}; 87 - 88 - return { 89 - ...operation, 90 - context: { 91 - ...operation.context, 92 - fetchOptions: { 93 - ...fetchOptions, 94 - headers: { 95 - ...fetchOptions.headers, 96 - "Authorization": authState.token, 97 - }, 98 - }, 99 - }, 100 - }; 101 - } 102 - ``` 103 - 104 - [Read more examples in the documentation given here.](https://github.com/urql-graphql/urql/tree/main/exchanges/auth#quick-start-guide) 64 + [Read more examples in the documentation given here.](../advanced/authentication.md)
+52 -81
exchanges/auth/README.md
··· 26 26 exchanges: [ 27 27 dedupExchange, 28 28 cacheExchange, 29 - authExchange({ 30 - addAuthToOperation: ({ authState, operation }) => { 31 - // the token isn't in the auth state, return the operation without changes 32 - if (!authState || !authState.token) { 29 + authExchange(async utils => { 30 + // called on initial launch, 31 + // fetch the auth state from storage (local storage, async storage etc) 32 + let token = localStorage.getItem('token'); 33 + let refreshToken = localStorage.getItem('refreshToken'); 34 + 35 + return { 36 + addAuthToOperation(operation) { 37 + if (token) { 38 + return utils.appendHeaders(operation, { 39 + Authorization: `Bearer ${token}`, 40 + }); 41 + } 33 42 return operation; 34 - } 43 + }, 44 + willAuthError(_operation) { 45 + // e.g. check for expiration, existence of auth etc 46 + return !token; 47 + }, 48 + didAuthError(error, _operation) { 49 + // check if the error was an auth error 50 + // this can be implemented in various ways, e.g. 401 or a special error code 51 + return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 52 + }, 53 + async refreshAuth() { 54 + // called when auth error has occurred 55 + // we should refresh the token with a GraphQL mutation or a fetch call, 56 + // depending on what the API supports 57 + const result = await mutate(refreshMutation, { 58 + token: authState?.refreshToken, 59 + }); 35 60 36 - // fetchOptions can be a function (See Client API) but you can simplify this based on usage 37 - const fetchOptions = 38 - typeof operation.context.fetchOptions === 'function' 39 - ? operation.context.fetchOptions() 40 - : operation.context.fetchOptions || {}; 41 - 42 - return makeOperation(operation.kind, operation, { 43 - ...operation.context, 44 - fetchOptions: { 45 - ...fetchOptions, 46 - headers: { 47 - ...fetchOptions.headers, 48 - Authorization: authState.token, 49 - }, 50 - }, 51 - }); 52 - }, 53 - willAuthError: ({ authState }) => { 54 - if (!authState) return true; 55 - // e.g. check for expiration, existence of auth etc 56 - return false; 57 - }, 58 - didAuthError: ({ error }) => { 59 - // check if the error was an auth error (this can be implemented in various ways, e.g. 401 or a special error code) 60 - return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 61 - }, 62 - getAuth: async ({ authState, mutate }) => { 63 - // for initial launch, fetch the auth state from storage (local storage, async storage etc) 64 - if (!authState) { 65 - const token = localStorage.getItem('token'); 66 - const refreshToken = localStorage.getItem('refreshToken'); 67 - if (token && refreshToken) { 68 - return { token, refreshToken }; 61 + if (result.data?.refreshLogin) { 62 + // save the new tokens in storage for next restart 63 + token = result.data.refreshLogin.token; 64 + refreshToken = result.data.refreshLogin.refreshToken; 65 + localStorage.setItem('token', token); 66 + localStorage.setItem('refreshToken', refreshToken); 67 + } else { 68 + // otherwise, if refresh fails, log clear storage and log out 69 + localStorage.clear(); 70 + logout(); 69 71 } 70 - return null; 71 - } 72 - 73 - /** 74 - * the following code gets executed when an auth error has occurred 75 - * we should refresh the token if possible and return a new auth state 76 - * If refresh fails, we should log out 77 - **/ 78 - 79 - // if your refresh logic is in graphQL, you must use this mutate function to call it 80 - // if your refresh logic is a separate RESTful endpoint, use fetch or similar 81 - const result = await mutate(refreshMutation, { 82 - token: authState?.refreshToken, 83 - }); 84 - 85 - if (result.data?.refreshLogin) { 86 - // save the new tokens in storage for next restart 87 - localStorage.setItem('token', result.data.refreshLogin.token); 88 - localStorage.setItem('refreshToken', result.data.refreshLogin.refreshToken); 89 - 90 - // return the new tokens 91 - return { 92 - token: result.data.refreshLogin.token, 93 - refreshToken: result.data.refreshLogin.refreshToken, 94 - }; 95 - } 96 - 97 - // otherwise, if refresh fails, log clear storage and log out 98 - localStorage.clear(); 99 - 100 - // your app logout logic should trigger here 101 - logout(); 102 - 103 - return null; 104 - }, 72 + }, 73 + }; 105 74 }), 106 75 fetchExchange, 107 76 ], ··· 110 79 111 80 ## Handling Errors via the errorExchange 112 81 113 - Handling the logout logic in `getAuth` is the easiest way to get started, but it means the errors will always get swallowed by the `authExchange`. 114 - If you want to handle errors globally, this can be done using the `errorExchange`: 82 + Handling the logout logic in `refreshAuth` is the easiest way to get started, 83 + but it means the errors will always get swallowed by the `authExchange`. 84 + If you want to handle errors globally, this can be done using the `mapExchange`: 115 85 116 86 ```js 117 - import { errorExchange } from 'urql'; 87 + import { mapExchange } from 'urql'; 118 88 119 - // this needs to be placed ABOVE the authExchange in the exchanges array, otherwise the auth error will show up hear before the auth exchange has had the chance to handle it 120 - errorExchange({ 121 - onError: (error) => { 122 - // we only get an auth error here when the auth exchange had attempted to refresh auth and getting an auth error again for the second time 89 + // this needs to be placed ABOVE the authExchange in the exchanges array, otherwise the auth error 90 + // will show up hear before the auth exchange has had the chance to handle it 91 + mapExchange({ 92 + onError(error) { 93 + // we only get an auth error here when the auth exchange had attempted to refresh auth and 94 + // getting an auth error again for the second time 123 95 const isAuthError = error.graphQLErrors.some( 124 96 e => e.extensions?.code === 'FORBIDDEN', 125 97 ); 126 - 127 98 if (isAuthError) { 128 99 // clear storage, log the user out etc 129 100 }
+143 -151
exchanges/auth/src/authExchange.test.ts
··· 6 6 take, 7 7 makeSubject, 8 8 publish, 9 + scan, 9 10 tap, 10 11 map, 11 12 } from 'wonka'; 12 - import { vi, expect, it } from 'vitest'; 13 13 14 - import { print } from 'graphql'; 15 - import { authExchange } from './authExchange'; 16 14 import { 17 15 makeOperation, 18 16 CombinedError, ··· 20 18 Operation, 21 19 OperationResult, 22 20 } from '@urql/core'; 21 + 22 + import { vi, expect, it } from 'vitest'; 23 + import { print } from 'graphql'; 23 24 import { queryOperation } from '../../../packages/core/src/test-utils'; 25 + import { authExchange } from './authExchange'; 24 26 25 27 const makeExchangeArgs = () => { 26 28 const operations: Operation[] = []; ··· 43 45 }; 44 46 }; 45 47 46 - const withAuthHeader = (operation, token) => { 47 - const fetchOptions = 48 - typeof operation.context.fetchOptions === 'function' 49 - ? operation.context.fetchOptions() 50 - : operation.context.fetchOptions || {}; 51 - 52 - return makeOperation(operation.kind, operation, { 53 - ...operation.context, 54 - fetchOptions: { 55 - ...fetchOptions, 56 - headers: { 57 - ...fetchOptions.headers, 58 - Authorization: token, 59 - }, 60 - }, 61 - }); 62 - }; 63 - 64 48 it('adds the auth header correctly', async () => { 65 49 const { exchangeArgs } = makeExchangeArgs(); 66 50 67 51 const res = await pipe( 68 52 fromValue(queryOperation), 69 - authExchange({ 70 - getAuth: async () => ({ token: 'my-token' }), 71 - willAuthError: () => false, 72 - addAuthToOperation: ({ authState, operation }) => { 73 - return withAuthHeader(operation, authState!.token); 74 - }, 53 + authExchange(async utils => { 54 + const token = 'my-token'; 55 + return { 56 + addAuthToOperation(operation) { 57 + return utils.appendHeaders(operation, { 58 + Authorization: token, 59 + }); 60 + }, 61 + didAuthError: () => false, 62 + async refreshAuth() { 63 + /*noop*/ 64 + }, 65 + }; 75 66 })(exchangeArgs), 76 67 take(1), 77 68 toPromise ··· 86 77 }); 87 78 }); 88 79 89 - it('adds the auth header correctly when it is fetched asynchronously', async () => { 80 + it('adds the auth header correctly when intialized asynchronously', async () => { 90 81 const { exchangeArgs } = makeExchangeArgs(); 91 82 92 83 const res = await pipe( 93 84 fromValue(queryOperation), 94 - authExchange<{ token: string }>({ 95 - getAuth: async () => { 96 - await Promise.resolve(); 97 - return { token: 'async-token' }; 98 - }, 99 - willAuthError: () => false, 100 - addAuthToOperation: ({ authState, operation }) => { 101 - return withAuthHeader(operation, authState!.token); 102 - }, 85 + authExchange(async utils => { 86 + // delayed initial auth 87 + await Promise.resolve(); 88 + const token = 'async-token'; 89 + 90 + return { 91 + addAuthToOperation(operation) { 92 + return utils.appendHeaders(operation, { 93 + Authorization: token, 94 + }); 95 + }, 96 + didAuthError: () => false, 97 + async refreshAuth() { 98 + /*noop*/ 99 + }, 100 + }; 103 101 })(exchangeArgs), 104 102 take(1), 105 103 toPromise ··· 114 112 }); 115 113 }); 116 114 117 - it('supports calls to the mutate() method in getAuth()', async () => { 115 + it('supports calls to the mutate() method in refreshAuth()', async () => { 118 116 const { exchangeArgs } = makeExchangeArgs(); 119 117 120 - const res = await pipe( 118 + const willAuthError = vi 119 + .fn() 120 + .mockReturnValueOnce(true) 121 + .mockReturnValue(false); 122 + 123 + const [mutateRes, res] = await pipe( 121 124 fromValue(queryOperation), 122 - authExchange<{ token: string }>({ 123 - getAuth: async ({ mutate }) => { 124 - const result = await mutate('mutation { auth }'); 125 - expect(print(result.operation.query)).toBe('mutation {\n auth\n}'); 126 - return { token: 'async-token' }; 127 - }, 128 - willAuthError: () => false, 129 - addAuthToOperation: ({ authState, operation }) => { 130 - return withAuthHeader(operation, authState?.token); 131 - }, 125 + authExchange(async utils => { 126 + const token = 'async-token'; 127 + 128 + return { 129 + addAuthToOperation(operation) { 130 + return utils.appendHeaders(operation, { 131 + Authorization: token, 132 + }); 133 + }, 134 + willAuthError, 135 + didAuthError: () => false, 136 + async refreshAuth() { 137 + const result = await utils.mutate('mutation { auth }', undefined); 138 + expect(print(result.operation.query)).toBe('mutation {\n auth\n}'); 139 + }, 140 + }; 132 141 })(exchangeArgs), 133 142 take(2), 143 + scan((acc, res) => [...acc, res], [] as OperationResult[]), 134 144 toPromise 135 145 ); 136 146 147 + expect(mutateRes.operation.context.fetchOptions).toEqual({ 148 + headers: { 149 + Authorization: 'async-token', 150 + }, 151 + }); 152 + 137 153 expect(res.operation.context.authAttempt).toBe(false); 138 154 expect(res.operation.context.fetchOptions).toEqual({ 139 - ...(queryOperation.context.fetchOptions || {}), 155 + method: 'POST', 140 156 headers: { 141 157 Authorization: 'async-token', 142 158 }, ··· 150 166 const result = vi.fn(); 151 167 const auth$ = pipe( 152 168 source, 153 - authExchange({ 154 - getAuth: async () => { 155 - await Promise.resolve(); 156 - return { token: 'my-token' }; 157 - }, 158 - willAuthError: () => false, 159 - addAuthToOperation: ({ authState, operation }) => { 160 - return withAuthHeader(operation, authState!.token); 161 - }, 169 + authExchange(async utils => { 170 + const token = 'my-token'; 171 + return { 172 + addAuthToOperation(operation) { 173 + return utils.appendHeaders(operation, { 174 + Authorization: token, 175 + }); 176 + }, 177 + didAuthError: () => false, 178 + async refreshAuth() { 179 + /*noop*/ 180 + }, 181 + }; 162 182 })(exchangeArgs), 163 183 tap(result), 164 184 take(2), ··· 200 220 const { exchangeArgs, result, operations } = makeExchangeArgs(); 201 221 const { source, next } = makeSubject<any>(); 202 222 203 - let initialAuth; 204 - let afterErrorAuth; 205 - 206 223 const didAuthError = vi.fn().mockReturnValueOnce(true); 207 224 208 - const getAuth = vi 209 - .fn() 210 - .mockImplementationOnce(() => { 211 - initialAuth = Promise.resolve({ token: 'initial-token' }); 212 - return initialAuth; 213 - }) 214 - .mockImplementationOnce(() => { 215 - afterErrorAuth = Promise.resolve({ token: 'final-token' }); 216 - return afterErrorAuth; 217 - }); 218 - 219 225 pipe( 220 226 source, 221 - authExchange<{ token: string }>({ 222 - getAuth, 223 - didAuthError, 224 - willAuthError: () => false, 225 - addAuthToOperation: ({ authState, operation }) => { 226 - return withAuthHeader(operation, authState?.token); 227 - }, 227 + authExchange(async utils => { 228 + let token = 'initial-token'; 229 + return { 230 + addAuthToOperation(operation) { 231 + return utils.appendHeaders(operation, { 232 + Authorization: token, 233 + }); 234 + }, 235 + didAuthError, 236 + async refreshAuth() { 237 + token = 'final-token'; 238 + }, 239 + }; 228 240 })(exchangeArgs), 229 241 publish 230 242 ); 231 243 232 - await Promise.resolve(); 233 - expect(getAuth).toHaveBeenCalledTimes(1); 234 - await initialAuth; 235 - await new Promise(res => { 236 - setTimeout(() => { 237 - res(null); 238 - }); 239 - }); 244 + await new Promise(resolve => setTimeout(resolve)); 240 245 241 246 result.mockReturnValueOnce({ 242 247 operation: queryOperation, ··· 248 253 next(queryOperation); 249 254 expect(result).toHaveBeenCalledTimes(1); 250 255 expect(didAuthError).toHaveBeenCalledTimes(1); 251 - expect(getAuth).toHaveBeenCalledTimes(2); 252 256 253 - await afterErrorAuth; 254 - await new Promise(res => { 255 - setTimeout(() => { 256 - res(null); 257 - }); 258 - }); 257 + await new Promise(resolve => setTimeout(resolve)); 259 258 260 259 expect(result).toHaveBeenCalledTimes(2); 261 260 expect(operations.length).toBe(2); ··· 273 272 const { exchangeArgs, result, operations } = makeExchangeArgs(); 274 273 const { source, next } = makeSubject<any>(); 275 274 276 - let initialAuth; 277 - let afterErrorAuth; 278 - 279 - vi.useRealTimers(); 280 275 const willAuthError = vi 281 276 .fn() 282 277 .mockReturnValueOnce(true) 283 278 .mockReturnValue(false); 284 279 285 - const getAuth = vi 286 - .fn() 287 - .mockImplementationOnce(async () => { 288 - initialAuth = Promise.resolve({ token: 'initial-token' }); 289 - return await initialAuth; 290 - }) 291 - .mockImplementationOnce(() => { 292 - afterErrorAuth = Promise.resolve({ token: 'final-token' }); 293 - return afterErrorAuth; 294 - }); 295 - 296 280 pipe( 297 281 source, 298 - authExchange<{ token: string }>({ 299 - getAuth, 300 - willAuthError, 301 - didAuthError: () => false, 302 - addAuthToOperation: ({ authState, operation }) => { 303 - return withAuthHeader(operation, authState?.token); 304 - }, 282 + authExchange(async utils => { 283 + let token = 'initial-token'; 284 + return { 285 + addAuthToOperation(operation) { 286 + return utils.appendHeaders(operation, { 287 + Authorization: token, 288 + }); 289 + }, 290 + willAuthError, 291 + didAuthError: () => false, 292 + async refreshAuth() { 293 + token = 'final-token'; 294 + }, 295 + }; 305 296 })(exchangeArgs), 306 297 publish 307 298 ); 308 299 309 - await Promise.resolve(); 310 - expect(getAuth).toHaveBeenCalledTimes(1); 311 - await initialAuth; 312 - await new Promise(res => { 313 - setTimeout(() => { 314 - res(null); 315 - }); 316 - }); 300 + await new Promise(resolve => setTimeout(resolve)); 317 301 318 302 next(queryOperation); 319 303 expect(result).toHaveBeenCalledTimes(0); 320 304 expect(willAuthError).toHaveBeenCalledTimes(1); 321 - expect(getAuth).toHaveBeenCalledTimes(2); 322 305 323 - await afterErrorAuth; 324 - 325 - await new Promise(res => { 326 - setTimeout(() => { 327 - res(null); 328 - }); 329 - }); 306 + await new Promise(resolve => setTimeout(resolve)); 330 307 331 308 expect(result).toHaveBeenCalledTimes(1); 332 309 expect(operations.length).toBe(1); ··· 342 319 343 320 let initialAuthResolve: ((_?: any) => void) | undefined; 344 321 345 - const willAuthError = vi.fn().mockReturnValue(false); 322 + const willAuthError = vi 323 + .fn() 324 + .mockReturnValueOnce(true) 325 + .mockReturnValue(false); 346 326 347 327 pipe( 348 328 source, 349 - authExchange<{ token: string }>({ 350 - async getAuth() { 351 - await new Promise(resolve => { 352 - initialAuthResolve = resolve; 353 - }); 329 + authExchange(async utils => { 330 + await new Promise(resolve => { 331 + initialAuthResolve = resolve; 332 + }); 354 333 355 - return { token: 'token' }; 356 - }, 357 - willAuthError, 358 - didAuthError: () => false, 359 - addAuthToOperation: ({ authState, operation }) => { 360 - return withAuthHeader(operation, authState?.token); 361 - }, 334 + let token = 'token'; 335 + return { 336 + willAuthError, 337 + didAuthError: () => false, 338 + addAuthToOperation(operation) { 339 + return utils.appendHeaders(operation, { 340 + Authorization: token, 341 + }); 342 + }, 343 + async refreshAuth() { 344 + token = 'final-token'; 345 + }, 346 + }; 362 347 })(exchangeArgs), 363 348 publish 364 349 ); 365 350 366 351 await Promise.resolve(); 367 352 368 - next(queryOperation); 353 + next({ ...queryOperation, key: 1 }); 354 + next({ ...queryOperation, key: 2 }); 355 + 369 356 expect(result).toHaveBeenCalledTimes(0); 370 357 expect(willAuthError).toHaveBeenCalledTimes(0); 371 358 ··· 374 361 375 362 await new Promise(resolve => setTimeout(resolve)); 376 363 377 - expect(willAuthError).toHaveBeenCalledTimes(1); 378 - expect(result).toHaveBeenCalledTimes(1); 364 + expect(willAuthError).toHaveBeenCalledTimes(2); 365 + expect(result).toHaveBeenCalledTimes(2); 379 366 380 - expect(operations.length).toBe(1); 367 + expect(operations.length).toBe(2); 381 368 expect(operations[0]).toHaveProperty( 382 369 'context.fetchOptions.headers.Authorization', 383 - 'token' 370 + 'final-token' 371 + ); 372 + 373 + expect(operations[1]).toHaveProperty( 374 + 'context.fetchOptions.headers.Authorization', 375 + 'final-token' 384 376 ); 385 377 });
+275 -105
exchanges/auth/src/authExchange.ts
··· 12 12 } from 'wonka'; 13 13 14 14 import { 15 + createRequest, 16 + makeOperation, 15 17 Operation, 16 18 OperationContext, 17 19 OperationResult, 18 20 CombinedError, 19 21 Exchange, 20 - createRequest, 21 - makeOperation, 22 22 TypedDocumentNode, 23 + AnyVariables, 23 24 } from '@urql/core'; 24 25 25 26 import { DocumentNode } from 'graphql'; 26 27 27 - export interface AuthConfig<T> { 28 - /** addAuthToOperation() must be provided to add the custom `authState` to an Operation's context, so that it may be picked up by the `fetchExchange`. */ 29 - addAuthToOperation(params: { 30 - authState: T | null; 31 - operation: Operation; 32 - }): Operation; 28 + /** Utilities to use while refreshing authentication tokens. */ 29 + export interface AuthUtilities { 30 + /** Sends a mutation to your GraphQL API, bypassing earlier exchanges and authentication. 31 + * 32 + * @param query - a GraphQL document containing the mutation operation that will be executed. 33 + * @param variables - the variables used to execute the operation. 34 + * @param context - {@link OperationContext} options that'll be used in future exchanges. 35 + * @returns A `Promise` of an {@link OperationResult} for the GraphQL mutation. 36 + * 37 + * @remarks 38 + * The `mutation()` utility method is useful when your authentication requires you to make a GraphQL mutation 39 + * request to update your authentication tokens. In these cases, you likely wish to bypass prior exchanges and 40 + * the authentication in the `authExchange` itself. 41 + * 42 + * This method bypasses the usual mutation flow of the `Client` and instead issues the mutation as directly 43 + * as possible. This also means that it doesn’t carry your `Client`'s default {@link OperationContext} 44 + * options, so you may have to pass them again, if needed. 45 + */ 46 + mutate<Data = any, Variables extends AnyVariables = AnyVariables>( 47 + query: DocumentNode | TypedDocumentNode<Data, Variables> | string, 48 + variables: Variables, 49 + context?: Partial<OperationContext> 50 + ): Promise<OperationResult<Data>>; 33 51 34 - /** didAuthError() may be provided to tweak the detection of an authentication error that this exchange should handle. */ 35 - didAuthError?(params: { error: CombinedError; authState: T | null }): boolean; 52 + /** Adds additional HTTP headers to an `Operation`. 53 + * 54 + * @param operation - An {@link Operation} to add headers to. 55 + * @param headers - The HTTP headers to add to the `Operation`. 56 + * @returns The passed {@link Operation} with the headers added to it. 57 + * 58 + * @remarks 59 + * The `appendHeaders()` utility method is useful to add additional HTTP headers 60 + * to an {@link Operation}. It’s a simple convenience function that takes 61 + * `operation.context.fetchOptions` into account, since adding headers for 62 + * authentication is common. 63 + */ 64 + appendHeaders( 65 + operation: Operation, 66 + headers: Record<string, string> 67 + ): Operation; 68 + } 36 69 37 - /** willAuthError() may be provided to detect a potential operation that'll receive authentication error so that getAuth() can be run proactively. */ 38 - willAuthError?(params: { 39 - authState: T | null; 40 - operation: Operation; 41 - }): boolean; 70 + /** Configuration for the `authExchange` returned by the initializer function you write. */ 71 + export interface AuthConfig { 72 + /** Called for every operation to add authentication data to your operation. 73 + * 74 + * @param operation - An {@link Operation} that needs authentication tokens added. 75 + * @returns a new {@link Operation} with added authentication tokens. 76 + * 77 + * @remarks 78 + * The {@link authExchange} will call this function you provide and expects that you 79 + * add your authentication tokens to your operation here, on the {@link Operation} 80 + * that is returned. 81 + * 82 + * Hint: You likely want to modify your `fetchOptions.headers` here, for instance to 83 + * add an `Authorization` header. 84 + */ 85 + addAuthToOperation(operation: Operation): Operation; 42 86 43 - /** getAuth() handles how the application refreshes or reauthenticates given a stale `authState` and should return a new `authState` or `null`. */ 44 - getAuth(params: { 45 - authState: T | null; 46 - /** The mutate() method may be used to send one-off mutations to the GraphQL API for the purpose of authentication. */ 47 - mutate<Data = any, Variables extends object = {}>( 48 - query: DocumentNode | TypedDocumentNode<Data, Variables> | string, 49 - variables?: Variables, 50 - context?: Partial<OperationContext> 51 - ): Promise<OperationResult<Data>>; 52 - }): Promise<T | null>; 87 + /** Called before an operation is forwaded onwards to make a request. 88 + * 89 + * @param operation - An {@link Operation} that needs authentication tokens added. 90 + * @returns a boolean, if true, authentication must be refreshed. 91 + * 92 + * @remarks 93 + * The {@link authExchange} will call this function before an {@link Operation} is 94 + * forwarded onwards to your following exchanges. 95 + * 96 + * When this function returns `true`, the `authExchange` will call 97 + * {@link AuthConfig.refreshAuth} before forwarding more operations 98 + * to prompt you to update your authentication tokens. 99 + * 100 + * Hint: If you define this function, you can use it to check whether your authentication 101 + * tokens have expired. 102 + */ 103 + willAuthError?(operation: Operation): boolean; 104 + 105 + /** Called after receiving an operation result to check whether it has failed with an authentication error. 106 + * 107 + * @param error - A {@link CombinedError} that a result has come back with. 108 + * @param operation - The {@link Operation} of that has failed. 109 + * @returns a boolean, if true, authentication must be refreshed. 110 + * 111 + * @remarks 112 + * The {@link authExchange} will call this function if it sees an {@link OperationResult} 113 + * with a {@link CombinedError} on it, implying that it may have failed due to an authentication 114 + * error. 115 + * 116 + * When this function returns `true`, the `authExchange` will call 117 + * {@link AuthConfig.refreshAuth} before forwarding more operations 118 + * to prompt you to update your authentication tokens. 119 + * Afterwards, this operation will be retried once. 120 + * 121 + * Hint: You should define a function that detects your API’s authentication 122 + * errors, e.g. using `result.extensions`. 123 + */ 124 + didAuthError(error: CombinedError, operation: Operation): boolean; 125 + 126 + /** Called to refresh the authentication state. 127 + * 128 + * @remarks 129 + * The {@link authExchange} will call this function if either {@link AuthConfig.willAuthError} 130 + * or {@link AuthConfig.didAuthError} have returned `true` prior, which indicates that the 131 + * authentication state you hold has expired or is out-of-date. 132 + * 133 + * When this function is called, you should refresh your authentication state. 134 + * For instance, if you have a refresh token and an access token, you should rotate 135 + * these tokens with your API by sending the refresh token. 136 + * 137 + * Hint: You can use the {@link fetch} API here, or use {@link AuthUtilities.mutate} 138 + * if your API requires a GraphQL mutation to refresh your authentication state. 139 + */ 140 + refreshAuth(): Promise<void>; 53 141 } 54 142 55 143 const addAuthAttemptToOperation = ( 56 144 operation: Operation, 57 - hasAttempted: boolean 145 + authAttempt: boolean 58 146 ) => 59 147 makeOperation(operation.kind, operation, { 60 148 ...operation.context, 61 - authAttempt: hasAttempted, 149 + authAttempt, 62 150 }); 63 151 64 - export function authExchange<T>({ 65 - addAuthToOperation, 66 - getAuth, 67 - didAuthError, 68 - willAuthError, 69 - }: AuthConfig<T>): Exchange { 152 + /** Creates an `Exchange` handling control flow for authentication. 153 + * 154 + * @param init - An initializer function that returns an {@link AuthConfig} wrapped in a `Promise`. 155 + * @returns the created authentication {@link Exchange}. 156 + * 157 + * @remarks 158 + * The `authExchange` is used to create an exchange handling authentication and 159 + * the control flow of refresh authentication. 160 + * 161 + * You must pass an initializer function, which receives {@link AuthUtilities} and 162 + * must return an {@link AuthConfig} wrapped in a `Promise`. 163 + * When this exchange is used in your `Client`, it will first call your initializer 164 + * function, which gives you an opportunity to get your authentication state, e.g. 165 + * from local storage. 166 + * 167 + * You may then choose to validate this authentication state and update it, and must 168 + * then return an {@link AuthConfig}. 169 + * 170 + * This configuration defines how you add authentication state to {@link Operation | Operations}, 171 + * when your authentication state expires, when an {@link OperationResult} has errored 172 + * with an authentication error, and how to refresh your authentication state. 173 + * 174 + * @example 175 + * ```ts 176 + * authExchange(async (utils) => { 177 + * let token = localStorage.getItem('token'); 178 + * let refreshToken = localStorage.getItem('refreshToken'); 179 + * return { 180 + * addAuthToOperation(operation) { 181 + * return utils.appendHeaders(operation, { 182 + * Authorization: `Bearer ${token}`, 183 + * }); 184 + * }, 185 + * didAuthError(error) { 186 + * return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN'); 187 + * }, 188 + * async refreshAuth() { 189 + * const result = await utils.mutate(REFRESH, { token }); 190 + * if (result.data?.refreshLogin) { 191 + * token = result.data.refreshLogin.token; 192 + * refreshToken = result.data.refreshLogin.refreshToken; 193 + * localStorage.setItem('token', token); 194 + * localStorage.setItem('refreshToken', refreshToken); 195 + * } 196 + * }, 197 + * }; 198 + * }); 199 + * ``` 200 + */ 201 + export function authExchange( 202 + init: (utilities: AuthUtilities) => Promise<AuthConfig> 203 + ): Exchange { 70 204 return ({ client, forward }) => { 71 - const bypassQueue: WeakSet<Operation> = new WeakSet(); 72 - const retryQueue: Map<number, Operation> = new Map(); 205 + const bypassQueue = new WeakSet<Operation>(); 206 + const retries = makeSubject<Operation>(); 73 207 74 - const { 75 - source: retrySource$, 76 - next: retryOperation, 77 - } = makeSubject<Operation>(); 208 + let retryQueue = new Map<number, Operation>(); 78 209 79 - let authState: T | null = null; 210 + function flushQueue(_config?: AuthConfig | undefined) { 211 + if (_config) config = _config; 212 + authPromise = undefined; 213 + const queue = retryQueue; 214 + retryQueue = new Map(); 215 + queue.forEach(retries.next); 216 + } 217 + 218 + let authPromise: Promise<void> | void; 219 + let config: AuthConfig | null = null; 80 220 81 221 return operations$ => { 82 - function mutate<Data = any, Variables extends object = {}>( 83 - query: DocumentNode | string, 84 - variables?: Variables, 85 - context?: Partial<OperationContext> 86 - ): Promise<OperationResult<Data>> { 87 - const operation = client.createRequestOperation( 88 - 'mutation', 89 - createRequest(query, variables), 90 - context 91 - ); 222 + authPromise = Promise.resolve() 223 + .then(() => 224 + init({ 225 + mutate<Data = any, Variables extends AnyVariables = AnyVariables>( 226 + query: DocumentNode | string, 227 + variables: Variables, 228 + context?: Partial<OperationContext> 229 + ): Promise<OperationResult<Data>> { 230 + const baseOperation = client.createRequestOperation( 231 + 'mutation', 232 + createRequest(query, variables), 233 + context 234 + ); 235 + return pipe( 236 + result$, 237 + onStart(() => { 238 + const operation = addAuthToOperation(baseOperation); 239 + bypassQueue.add(operation); 240 + retries.next(operation); 241 + }), 242 + filter(result => result.operation.key === baseOperation.key), 243 + take(1), 244 + toPromise 245 + ); 246 + }, 247 + appendHeaders( 248 + operation: Operation, 249 + headers: Record<string, string> 250 + ) { 251 + const fetchOptions = 252 + typeof operation.context.fetchOptions === 'function' 253 + ? operation.context.fetchOptions() 254 + : operation.context.fetchOptions || {}; 255 + return makeOperation(operation.kind, operation, { 256 + ...operation.context, 257 + fetchOptions: { 258 + ...fetchOptions, 259 + headers: { 260 + ...fetchOptions.headers, 261 + ...headers, 262 + }, 263 + }, 264 + }); 265 + }, 266 + }) 267 + ) 268 + .then(flushQueue); 92 269 93 - return pipe( 94 - result$, 95 - onStart(() => { 96 - bypassQueue.add(operation); 97 - retryOperation(operation); 98 - }), 99 - filter(result => result.operation.key === operation.key), 100 - take(1), 101 - toPromise 270 + function refreshAuth(operation: Operation) { 271 + // add to retry queue to try again later 272 + retryQueue.set( 273 + operation.key, 274 + addAuthAttemptToOperation(operation, true) 102 275 ); 276 + // check that another operation isn't already doing refresh 277 + if (config && !authPromise) { 278 + authPromise = config.refreshAuth().finally(flushQueue); 279 + } 103 280 } 104 281 105 - const updateAuthState = (newAuthState: T | null) => { 106 - authState = newAuthState; 107 - authPromise = undefined; 108 - retryQueue.forEach(retryOperation); 109 - retryQueue.clear(); 110 - }; 282 + function willAuthError(operation: Operation) { 283 + return ( 284 + !operation.context.authAttempt && 285 + config && 286 + config.willAuthError && 287 + config.willAuthError(operation) 288 + ); 289 + } 111 290 112 - let authPromise: Promise<any> | void = Promise.resolve() 113 - .then(() => getAuth({ authState, mutate })) 114 - .then(updateAuthState); 291 + function didAuthError(result: OperationResult) { 292 + return ( 293 + config && 294 + config.didAuthError && 295 + config.didAuthError(result.error!, result.operation) 296 + ); 297 + } 115 298 116 - const refreshAuth = (operation: Operation): void => { 117 - // add to retry queue to try again later 118 - operation = addAuthAttemptToOperation(operation, true); 119 - retryQueue.set(operation.key, operation); 120 - 121 - // check that another operation isn't already doing refresh 122 - if (!authPromise) { 123 - authPromise = getAuth({ authState, mutate }) 124 - .then(updateAuthState) 125 - .catch(() => updateAuthState(null)); 126 - } 127 - }; 299 + function addAuthToOperation(operation: Operation) { 300 + return config ? config.addAuthToOperation(operation) : operation; 301 + } 128 302 129 303 const sharedOps$ = pipe(operations$, share); 130 304 131 305 const teardownOps$ = pipe( 132 306 sharedOps$, 133 - filter((operation: Operation) => { 134 - return operation.kind === 'teardown'; 135 - }) 307 + filter(operation => operation.kind === 'teardown') 136 308 ); 137 309 138 310 const pendingOps$ = pipe( 139 311 sharedOps$, 140 - filter((operation: Operation) => { 141 - return operation.kind !== 'teardown'; 142 - }) 312 + filter(operation => operation.kind !== 'teardown') 143 313 ); 144 314 145 315 const opsWithAuth$ = pipe( 146 - merge([retrySource$, pendingOps$]), 316 + merge([retries.source, pendingOps$]), 147 317 map(operation => { 148 318 if (bypassQueue.has(operation)) { 149 319 return operation; 150 320 } else if (authPromise) { 151 - operation = addAuthAttemptToOperation(operation, false); 152 - retryQueue.set( 153 - operation.key, 154 - addAuthAttemptToOperation(operation, false) 155 - ); 321 + if (!retryQueue.has(operation.key)) { 322 + retryQueue.set( 323 + operation.key, 324 + addAuthAttemptToOperation(operation, false) 325 + ); 326 + } 156 327 return null; 157 - } else if ( 158 - !operation.context.authAttempt && 159 - willAuthError && 160 - willAuthError({ operation, authState }) 161 - ) { 328 + } else if (willAuthError(operation)) { 162 329 refreshAuth(operation); 163 330 return null; 164 331 } 165 332 166 - operation = addAuthAttemptToOperation(operation, false); 167 - return addAuthToOperation({ operation, authState }); 333 + return addAuthToOperation( 334 + addAuthAttemptToOperation(operation, false) 335 + ); 168 336 }), 169 337 filter(Boolean) 170 338 ) as Source<Operation>; ··· 173 341 174 342 return pipe( 175 343 result$, 176 - filter(({ error, operation }) => { 177 - if (error && didAuthError && didAuthError({ error, authState })) { 178 - if (!operation.context.authAttempt) { 179 - refreshAuth(operation); 180 - return false; 181 - } 344 + filter(result => { 345 + if ( 346 + result.error && 347 + didAuthError(result) && 348 + !result.operation.context.authAttempt 349 + ) { 350 + refreshAuth(result.operation); 351 + return false; 182 352 } 183 353 184 354 return true;
+1 -1
exchanges/auth/src/index.ts
··· 1 1 export { authExchange } from './authExchange'; 2 - export type { AuthConfig } from './authExchange'; 2 + export type { AuthUtilities, AuthConfig } from './authExchange';