···11+---
22+'@urql/exchange-auth': major
33+---
44+55+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.
66+77+The new API requires you to now pass an initializer function. This function receives a `utils`
88+object with `utils.mutate` and `utils.appendHeaders` utility methods.
99+It must return the configuration object, wrapped in a promise, and this configuration is similar to
1010+what we had before, if you're migrating to this. Its `refreshAuth` method is now only called after
1111+authentication errors occur and not on initialization. Instead, it's now recommended that you write
1212+your initialization logic in-line.
1313+1414+```js
1515+authExchange(async utils => {
1616+ let token = localStorage.getItem('token');
1717+ let refreshToken = localStorage.getItem('refreshToken');
1818+ return {
1919+ addAuthToOperation(operation) {
2020+ return utils.appendHeaders(operation, {
2121+ Authorization: `Bearer ${token}`,
2222+ });
2323+ },
2424+ didAuthError(error) {
2525+ return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
2626+ },
2727+ async refreshAuth() {
2828+ const result = await utils.mutate(REFRESH, { token });
2929+ if (result.data?.refreshLogin) {
3030+ token = result.data.refreshLogin.token;
3131+ refreshToken = result.data.refreshLogin.refreshToken;
3232+ localStorage.setItem('token', token);
3333+ localStorage.setItem('refreshToken', refreshToken);
3434+ }
3535+ },
3636+ };
3737+});
3838+```
+202-182
docs/advanced/authentication.md
···5959 exchanges: [
6060 dedupExchange,
6161 cacheExchange,
6262- authExchange({
6363- /* config */
6262+ authExchange(async utils => {
6363+ return {
6464+ /* config... */
6565+ };
6466 }),
6567 fetchExchange,
6668 ],
6769});
6870```
69717272+You pass an initialization function to the `authExchange`. This function is called by the exchange
7373+when it first initializes. It'll let you receive an object of utilities and you must return
7474+a (promisified) object of configuration options.
7575+7076Let's discuss each of the [configuration options](../api/auth-exchange.md#options) and how to use them in turn.
71777272-### Configuring `getAuth` (initial load, fetch from storage)
7878+### Configuring the initializer function (initial load)
73797474-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:
8080+The initializer function must return a promise of a configuration object and hence also gives you an
8181+opportunity to fetch your authentication state from storage.
75827683```js
7777-const getAuth = async ({ authState }) => {
7878- if (!authState) {
7979- const token = localStorage.getItem('token');
8080- const refreshToken = localStorage.getItem('refreshToken');
8181- if (token && refreshToken) {
8282- return { token, refreshToken };
8383- }
8484- return null;
8585- }
8484+async function initializeAuthState() {
8585+ const token = localStorage.getItem('token');
8686+ const refreshToken = localStorage.getItem('refreshToken');
8787+ return { token, refreshToken };
8888+}
86898787- return null;
8888-};
9090+authExchange(async utils => {
9191+ let { token, refreshToken } = initializeAuthState();
9292+ return {
9393+ /* config... */
9494+ };
9595+});
8996```
90979191-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
9292-storage. The structure of this particular `authState` is an object with keys for `token` and
9393-`refreshToken`, but this format is not required. We can use different keys or store any additional
9494-auth related information here. For example, we could decode and store the token expiry date, which
9595-would save us from decoding the JWT every time we want to check whether it has expired.
9898+The first step here is to retrieve our tokens from a kind of storage, which may be asynchronous as
9999+well, as illustrated by `initializeAuthState`.
961009797-In React Native, this is very similar, but because persisted storage in React Native is always asynchronous, so is this function:
101101+In React Native, this is very similar, but because persisted storage in React Native is always
102102+asynchronous and promisified, we would await our tokens. This works because the
103103+function that `authExchange` is async, i.e. must return a `Promise`.
9810499105```js
100100-const getAuth = async ({ authState, mutate }) => {
101101- if (!authState) {
102102- const token = await AsyncStorage.getItem(TOKEN_KEY, {});
103103- const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY, {});
104104- if (token && refreshToken) {
105105- return { token, refreshToken };
106106- }
107107- return null;
108108- }
106106+async function initializeAuthState() {
107107+ const token = await AsyncStorage.getItem(TOKEN_KEY);
108108+ const refreshToken = await AyncStorage.getItem(REFRESH_KEY);
109109+ return { token, refreshToken };
110110+}
109111110110- return null;
111111-};
112112+authExchange(async utils => {
113113+ let { token, refreshToken } = initializeAuthState();
114114+ return {
115115+ /* config... */
116116+ };
117117+});
112118```
113119114120### Configuring `addAuthToOperation`
115121116116-The purpose of `addAuthToOperation` is to apply an auth state to each request. Note that the format
117117-of the `authState` will be whatever we've returned from `getAuth` and not constrained by the exchange:
118118-119119-```js
120120-import { makeOperation } from '@urql/core';
122122+The purpose of `addAuthToOperation` is to apply an auth state to each request. Here, we'll use the
123123+tokens we retrieved from storage and add them to our operations.
121124122122-const addAuthToOperation = ({ authState, operation }) => {
123123- if (!authState || !authState.token) {
124124- return operation;
125125- }
125125+In this example, we're using a utility we're passed, `appendHeaders`. This utility is a simply
126126+shortcut to quickly add HTTP headers via `fetchOptions` to an `Operation`, however, we may as well
127127+be editing the `Operation` context here using `makeOperation`.
126128127127- const fetchOptions =
128128- typeof operation.context.fetchOptions === 'function'
129129- ? operation.context.fetchOptions()
130130- : operation.context.fetchOptions || {};
129129+```js
130130+authExchange(async utils => {
131131+ let token = await AsyncStorage.getItem(TOKEN_KEY);
132132+ let refreshToken = await AyncStorage.getItem(REFRESH_KEY);
131133132132- return makeOperation(operation.kind, operation, {
133133- ...operation.context,
134134- fetchOptions: {
135135- ...fetchOptions,
136136- headers: {
137137- ...fetchOptions.headers,
138138- Authorization: authState.token,
139139- },
134134+ return {
135135+ addAuthToOperation(operation) {
136136+ if (!token) return operation;
137137+ return utils.appendHeaders(operation, {
138138+ Authorization: `Bearer ${token}`,
139139+ });
140140 },
141141- });
142142-};
141141+ // ...
142142+ };
143143+});
143144```
144145145145-First, we check that we have an `authState` and a `token`. Then we apply it to the request
146146-`fetchOptions` as an `Authorization` header. The header format can vary based on the API (e.g. using
147147-`Bearer ${token}` instead of just `token`) which is why it'll be up to us to add the header
148148-in the expected format for our API.
146146+First, we check that we have a non-null `token`. Then we apply it to the request using the
147147+`appendHeaders` utility as an `Authorization` header.
148148+149149+We could also be using `makeOperation` here to update the context in any other way, such as:
150150+151151+```js
152152+import { makeOperation } from '@urql/core';
153153+154154+makeOperation(operation.kind, operation, {
155155+ ...operation.context,
156156+ someAuthThing: token,
157157+});
158158+```
149159150160### Configuring `didAuthError`
151161152152-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
153153-[`CombinedError`](../api/core.md#combinederror), and we can use the `graphQLErrors` array in `CombinedError` to determine if an auth error has occurred.
162162+This function lets the `authExchange` know what is defined to be an API error for your API.
163163+`didAuthError` is called by `authExchange` when it receives an `error` on an `OperationResult`, which
164164+is of type [`CombinedError`](../api/core.md#combinederror).
154165155155-The GraphQL error looks like something like this:
166166+We can for example check the error's `graphQLErrors` array in `CombinedError` to determine if an auth
167167+error has occurred. While your API may implement this differently, an authentication error on an
168168+execution result may look a little like this if your API uses `extensions.code` on errors:
156169157170```js
158171{
···163176 extensions: {
164177 code: 'FORBIDDEN'
165178 },
166166- response: {
167167- status: 200
168168- }
169179 }
170180 ]
171181}
172182```
173183174174-Most GraphQL APIs will communicate auth errors via the [error code
175175-extension](https://www.apollographql.com/docs/apollo-server/data/errors/#codes), which
176176-is the recommended approach. We'll be able to determine whether any of the GraphQL errors were due
184184+If you're building a new API, using `extensions` on errors is the recommended approach to add
185185+metadata to your errors. We'll be able to determine whether any of the GraphQL errors were due
177186to an unauthorized error code, which would indicate an auth failure:
178187179188```js
180180-const didAuthError = ({ error }) => {
181181- return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
182182-};
183183-```
184184-185185-For some GraphQL APIs, the auth error is communicated via an 401 HTTP response as is common in RESTful APIs:
186186-187187-```js
188188-{
189189- data: null,
190190- errors: [
191191- {
192192- message: 'Unauthorized: Token has expired',
193193- response: {
194194- status: 401
195195- }
196196- }
197197- ]
198198-}
189189+authExchange(async utils => {
190190+ // ...
191191+ return {
192192+ // ...
193193+ didAuthError(error, _operation) {
194194+ return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
195195+ },
196196+ };
197197+});
199198```
200199201201-In this case we can determine the auth error based on the status code of the request:
200200+For some GraphQL APIs, the authentication error is only communicated via a 401 HTTP status as is
201201+common in RESTful APIs, which is suboptimal, but which we can still write a check for.
202202203203```js
204204-const didAuthError = ({ error }) => {
205205- return error.graphQLErrors.some(
206206- e => e.response.status === 401,
207207- );
208208-},
204204+authExchange(async utils => {
205205+ // ...
206206+ return {
207207+ // ...
208208+ didAuthError(error, _operation) {
209209+ return error.response.status === 401;
210210+ },
211211+ };
212212+});
209213```
210214211211-If `didAuthError` returns `true`, it will trigger the exchange to trigger the logic for asking for re-authentication via `getAuth`.
215215+If `didAuthError` returns `true`, it will trigger the `authExchange` to trigger the logic for asking
216216+for re-authentication via `refreshAuth`.
212217213213-### Configuring `getAuth` (triggered after an auth error has occurred)
218218+### Configuring `refershAuth` (triggered after an auth error has occurred)
214219215220If the API doesn't support any sort of token refresh, this is where we could simply log the user out.
216221217222```js
218218-const getAuth = async ({ authState }) => {
219219- if (!authState) {
220220- const token = localStorage.getItem('token');
221221- const refreshToken = localStorage.getItem('refreshToken');
222222- if (token && refreshToken) {
223223- return { token, refreshToken };
224224- }
225225- return null;
226226- }
227227-228228- logout();
229229-230230- return null;
231231-};
223223+authExchange(async utils => {
224224+ // ...
225225+ return {
226226+ // ...
227227+ async refreshAuth() {
228228+ logout();
229229+ },
230230+ };
231231+});
232232```
233233234234Here, `logout()` is a placeholder that is called when we got an error, so that we can redirect to a
···238238user first:
239239240240```js
241241-const getAuth = async ({ authState, mutate }) => {
242242- if (!authState) {
243243- const token = localStorage.getItem('token');
244244- const refreshToken = localStorage.getItem('refreshToken');
245245- if (token && refreshToken) {
246246- return { token, refreshToken };
247247- }
248248- return null;
249249- }
250250-251251- const result = await mutate(refreshMutation, {
252252- token: authState!.refreshToken,
253253- });
241241+authExchange(async utils => {
242242+ let token = localStorage.getItem('token');
243243+ let refreshToken = localStorage.getItem('refreshToken');
254244255255- if (result.data?.refreshLogin) {
256256- localStorage.setItem('token', result.data.refreshLogin.token);
257257- localStorage.setItem('refreshToken', result.data.refreshLogin.refreshToken);
245245+ return {
246246+ // ...
247247+ async refreshAuth() {
248248+ const result = await utils.mutate(REFRESH, { refreshToken });
258249259259- return {
260260- token: result.data.refreshLogin.token,
261261- refreshToken: result.data.refreshLogin.refreshToken,
262262- };
263263- }
250250+ if (result.data?.refreshLogin) {
251251+ // Update our local variables and write to our storage
252252+ token = result.data.refreshLogin.token;
253253+ refreshToken = result.data.refreshLogin.refreshToken;
254254+ localStorage.setItem('token', token);
255255+ localStorage.setItem('refreshToken', refreshToken);
256256+ } else {
257257+ // This is where auth has gone wrong and we need to clean up and redirect to a login page
258258+ localStorage.clear();
259259+ logout();
260260+ }
261261+ },
262262+ };
263263+});
264264+```
264265265265- // This is where auth has gone wrong and we need to clean up and redirect to a login page
266266- localStorage.clear();
267267- logout();
266266+Here we use the special `mutate` utility method provided by the `authExchange` to do the token
267267+refresh. This is a useful method to use if your GraphQL API expects you to make a GraphQL mutation
268268+to update your authentication state. It will send the mutation and bypass all authentication and
269269+prior exchanges.
268270269269- return null;
270270-}
271271-```
271271+If your authentication is not handled via GraphQL but a REST endpoint, you can use the `fetch` API
272272+here however instead of a mutation.
272273273273-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
274274-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
275275-at the same time.
274274+All other requests will be paused while `refreshAuth` runs, so we won't have to deal with multiple
275275+authentication errors or refreshes at once.
276276277277### Configuring `willAuthError`
278278279279-`willAuthError` is an optional parameter and is run _before_ a network request is made. We can use it to trigger the logic in
280280-`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:
279279+`willAuthError` is an optional parameter and is run _before_ a request is made.
280280+281281+We can use it to trigger an authentication error and let the `authExchange` run our `refreshAuth`
282282+function without the need to first let a request fail with an authentication error. For example, we
283283+can use this to predict an authentication error, for instance, because of expired JWT tokens.
281284282285```js
283283-const willAuthError = ({ authState }) => {
284284- if (!authState || /* JWT is expired */) return true;
285285- return false;
286286-}
286286+authExchange(async utils => {
287287+ // ...
288288+ return {
289289+ // ...
290290+ willAuthError(_operation) {
291291+ // Check whether `token` JWT is expired
292292+ return false;
293293+ },
294294+ };
295295+});
287296```
288297289298This can be really useful when we know when our authentication state is invalid and want to prevent
290290-even sending any operation that we know will fail with an authentication error. However, if we were
291291-to use this and are logging in our users with a login _mutation_ then the above code will
292292-unfortunately never let this login mutation through to our GraphQL API.
299299+even sending any operation that we know will fail with an authentication error.
293300294294-If we have such a mutation we may need to write a more sophisticated `willAuthError` function like
295295-the following:
301301+However, we have to be careful on how we define this function, if some queries or login mutations
302302+are sent to our API without being logged in. In these cases, it's better to either detect the
303303+mutations we'd like to allow or return `false` when a token isn't set in storage yet.
296304297297-```js
298298-const willAuthError = ({ operation, authState }) => {
299299- if (!authState) {
300300- // Detect our login mutation and let this operation through:
301301- return !(
302302- operation.kind === 'mutation' &&
303303- // Here we find any mutation definition with the "login" field
304304- operation.query.definitions.some(definition => {
305305- return (
306306- definition.kind === 'OperationDefinition' &&
307307- definition.selectionSet.selections.some(node => {
308308- // The field name is just an example, since signup may also be an exception
309309- return node.kind === 'Field' && node.name.value === 'login';
310310- })
311311- );
312312- })
313313- );
314314- } else if (false /* JWT is expired */) {
315315- return true;
316316- }
305305+If we'd like to detect a mutation that will never fail with an authentication error, we could for
306306+instance write the following logic:
317307318318- return false;
319319-};
308308+```js
309309+authExchange(async utils => {
310310+ // ...
311311+ return {
312312+ // ...
313313+ willAuthError(operation) {
314314+ if (
315315+ operation.kind === 'mutation' &&
316316+ // Here we find any mutation definition with the "login" field
317317+ operation.query.definitions.some(definition => {
318318+ return (
319319+ definition.kind === 'OperationDefinition' &&
320320+ definition.selectionSet.selections.some(node => {
321321+ // The field name is just an example, since signup may also be an exception
322322+ return node.kind === 'Field' && node.name.value === 'login';
323323+ })
324324+ );
325325+ })
326326+ ) {
327327+ return false;
328328+ } else if (false /* is JWT expired? */) {
329329+ return true;
330330+ } else {
331331+ return false;
332332+ }
333333+ },
334334+ };
335335+});
320336```
321337322322-Alternatively, you may decide to let all operations through if `authState` isn't defined or to allow
323323-all mutations through. In an application that allows unauthenticated users to perform various
324324-actions, it's a good idea for us to return `false` when `!authState` applies.
325325-326326-[Read more about `@urql/exchange-auth`'s API in our API docs.](../api/auth-exchange.md)
338338+Alternatively, you may decide to let all operations through if your token isn't set in storage, i.e.
339339+if you have no prior authentication state.
327340328341## Handling Logout by reacting to Errors
329342···348361 }
349362 },
350363 }),
351351- authExchange({
352352- /* config */
364364+ authExchange(async utils => {
365365+ return {
366366+ /* config */
367367+ };
353368 }),
354369 fetchExchange,
355370 ],
···365380366381## Cache Invalidation on Logout
367382368368-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:
383383+If we're dealing with multiple authentication states at the same time, e.g. logouts, we need to
384384+ensure that the `Client` is reinitialized whenever the authentication state changes.
385385+Here's an example of how we may do this in React if necessary:
369386370387```jsx
371388import { createClient, Provider } from 'urql';
···391408}
392409```
393410394394-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
395395-whether to show the user the logged in or logged out view.
411411+When the application launches, the first thing we do is check whether the user has any authentication
412412+tokens in persisted storage. This will tell us whether to show the user the logged in or logged out view.
396413397414The `isLoggedIn` prop should always be updated based on authentication state change. For instance, we may set it to
398415`true` after the user has authenticated and their tokens have been added to storage, and set it to
399416`false` once the user has been logged out and their tokens have been cleared. It's important to clear
400417or add tokens to a storage _before_ updating the prop in order for the auth exchange to work
401418correctly.
419419+420420+This pattern of creating a new `Client` when changing authentication states is especially useful
421421+since it will also recreate our client-side cache and invalidate all cached data.
+19-59
docs/api/auth-exchange.md
···3232 exchanges: [
3333 dedupExchange,
3434 cacheExchange,
3535- authExchange({
3636- /* config */
3535+ authExchange(async utils => {
3636+ return {
3737+ /* config... */
3838+ };
3739 }),
3840 fetchExchange,
3941 ],
4042});
4143```
42444343-The `authExchange` accepts an object of options, which are used to configure how your
4444-authentication method works. Internally, the `authExchange` keeps an authentication state, whose
4545-shape is determined by the functions passed to the exchange's options:
4545+The `authExchange` accepts an initialization function. This function is called when your exchange
4646+and `Client` first start up, and must return an object of options wrapped in a `Promise`, which is
4747+used to configure how your authentication method works.
4848+4949+You can use this function to first retrieve your authentication state from a kind
5050+of local storage, or to call your API to validate your authentication state first.
5151+5252+The relevant configuration options, returned to the `authExchange`, then determine
5353+how the `authExchange` behaves:
46544755- `addAuthToOperation` must be provided to tell `authExchange` how to add authentication information
4856 to an operation, e.g. how to add the authentication state to an operation's fetch headers.
4949-- `getAuth` must be provided to let the `authExchange` handle the authentication flow, including
5050- token refreshes and other reauthentication. It may send mutations to the GraphQL API or make
5151- out-of-band API requests using `fetch`.
5252-- `didAuthError` may be provided to let the `authExchange` detect authentication errors from the API
5353- to trigger the `getAuth` method and reauthentication flow.
5457- `willAuthError` may be provided to detect expired tokens or tell whether an operation will likely
5555- fail due to an authentication error, which may trigger the `getAuth` method and reauthentication
5656- flow early.
5757-5858-## Options
5959-6060-| Option | Description |
6161-| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
6262-| `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. |
6363-| `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. |
6464-| `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. |
6565-| `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. |
6666-6767-## Examples
6868-6969-The `addAuthToOperation` method is frequently populated with a function that adds the `authState` to
7070-the operation's fetch headers.
7171-7272-```js
7373-function addAuthToOperation: ({
7474- authState,
7575- operation,
7676-}) {
7777- // the token isn't in the auth state, return the operation without changes
7878- if (!authState || !authState.token) {
7979- return operation;
8080- }
5858+ fail due to an authentication error.
5959+- `didAuthError` may be provided to let the `authExchange` detect authentication errors from the
6060+ API on results.
6161+- `refreshAuth` is called when an authentication error occurs and gives you an opportunity to update
6262+ your authentication state. Afterwards, the `authExchange` will retry your operation.
81638282- // fetchOptions can be a function (See Client API) but you can simplify this based on usage
8383- const fetchOptions =
8484- typeof operation.context.fetchOptions === 'function'
8585- ? operation.context.fetchOptions()
8686- : operation.context.fetchOptions || {};
8787-8888- return {
8989- ...operation,
9090- context: {
9191- ...operation.context,
9292- fetchOptions: {
9393- ...fetchOptions,
9494- headers: {
9595- ...fetchOptions.headers,
9696- "Authorization": authState.token,
9797- },
9898- },
9999- },
100100- };
101101-}
102102-```
103103-104104-[Read more examples in the documentation given here.](https://github.com/urql-graphql/urql/tree/main/exchanges/auth#quick-start-guide)
6464+[Read more examples in the documentation given here.](../advanced/authentication.md)
+52-81
exchanges/auth/README.md
···2626 exchanges: [
2727 dedupExchange,
2828 cacheExchange,
2929- authExchange({
3030- addAuthToOperation: ({ authState, operation }) => {
3131- // the token isn't in the auth state, return the operation without changes
3232- if (!authState || !authState.token) {
2929+ authExchange(async utils => {
3030+ // called on initial launch,
3131+ // fetch the auth state from storage (local storage, async storage etc)
3232+ let token = localStorage.getItem('token');
3333+ let refreshToken = localStorage.getItem('refreshToken');
3434+3535+ return {
3636+ addAuthToOperation(operation) {
3737+ if (token) {
3838+ return utils.appendHeaders(operation, {
3939+ Authorization: `Bearer ${token}`,
4040+ });
4141+ }
3342 return operation;
3434- }
4343+ },
4444+ willAuthError(_operation) {
4545+ // e.g. check for expiration, existence of auth etc
4646+ return !token;
4747+ },
4848+ didAuthError(error, _operation) {
4949+ // check if the error was an auth error
5050+ // this can be implemented in various ways, e.g. 401 or a special error code
5151+ return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
5252+ },
5353+ async refreshAuth() {
5454+ // called when auth error has occurred
5555+ // we should refresh the token with a GraphQL mutation or a fetch call,
5656+ // depending on what the API supports
5757+ const result = await mutate(refreshMutation, {
5858+ token: authState?.refreshToken,
5959+ });
35603636- // fetchOptions can be a function (See Client API) but you can simplify this based on usage
3737- const fetchOptions =
3838- typeof operation.context.fetchOptions === 'function'
3939- ? operation.context.fetchOptions()
4040- : operation.context.fetchOptions || {};
4141-4242- return makeOperation(operation.kind, operation, {
4343- ...operation.context,
4444- fetchOptions: {
4545- ...fetchOptions,
4646- headers: {
4747- ...fetchOptions.headers,
4848- Authorization: authState.token,
4949- },
5050- },
5151- });
5252- },
5353- willAuthError: ({ authState }) => {
5454- if (!authState) return true;
5555- // e.g. check for expiration, existence of auth etc
5656- return false;
5757- },
5858- didAuthError: ({ error }) => {
5959- // check if the error was an auth error (this can be implemented in various ways, e.g. 401 or a special error code)
6060- return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
6161- },
6262- getAuth: async ({ authState, mutate }) => {
6363- // for initial launch, fetch the auth state from storage (local storage, async storage etc)
6464- if (!authState) {
6565- const token = localStorage.getItem('token');
6666- const refreshToken = localStorage.getItem('refreshToken');
6767- if (token && refreshToken) {
6868- return { token, refreshToken };
6161+ if (result.data?.refreshLogin) {
6262+ // save the new tokens in storage for next restart
6363+ token = result.data.refreshLogin.token;
6464+ refreshToken = result.data.refreshLogin.refreshToken;
6565+ localStorage.setItem('token', token);
6666+ localStorage.setItem('refreshToken', refreshToken);
6767+ } else {
6868+ // otherwise, if refresh fails, log clear storage and log out
6969+ localStorage.clear();
7070+ logout();
6971 }
7070- return null;
7171- }
7272-7373- /**
7474- * the following code gets executed when an auth error has occurred
7575- * we should refresh the token if possible and return a new auth state
7676- * If refresh fails, we should log out
7777- **/
7878-7979- // if your refresh logic is in graphQL, you must use this mutate function to call it
8080- // if your refresh logic is a separate RESTful endpoint, use fetch or similar
8181- const result = await mutate(refreshMutation, {
8282- token: authState?.refreshToken,
8383- });
8484-8585- if (result.data?.refreshLogin) {
8686- // save the new tokens in storage for next restart
8787- localStorage.setItem('token', result.data.refreshLogin.token);
8888- localStorage.setItem('refreshToken', result.data.refreshLogin.refreshToken);
8989-9090- // return the new tokens
9191- return {
9292- token: result.data.refreshLogin.token,
9393- refreshToken: result.data.refreshLogin.refreshToken,
9494- };
9595- }
9696-9797- // otherwise, if refresh fails, log clear storage and log out
9898- localStorage.clear();
9999-100100- // your app logout logic should trigger here
101101- logout();
102102-103103- return null;
104104- },
7272+ },
7373+ };
10574 }),
10675 fetchExchange,
10776 ],
···1107911180## Handling Errors via the errorExchange
11281113113-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`.
114114-If you want to handle errors globally, this can be done using the `errorExchange`:
8282+Handling the logout logic in `refreshAuth` is the easiest way to get started,
8383+but it means the errors will always get swallowed by the `authExchange`.
8484+If you want to handle errors globally, this can be done using the `mapExchange`:
1158511686```js
117117-import { errorExchange } from 'urql';
8787+import { mapExchange } from 'urql';
11888119119-// 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
120120-errorExchange({
121121- onError: (error) => {
122122- // 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
8989+// this needs to be placed ABOVE the authExchange in the exchanges array, otherwise the auth error
9090+// will show up hear before the auth exchange has had the chance to handle it
9191+mapExchange({
9292+ onError(error) {
9393+ // we only get an auth error here when the auth exchange had attempted to refresh auth and
9494+ // getting an auth error again for the second time
12395 const isAuthError = error.graphQLErrors.some(
12496 e => e.extensions?.code === 'FORBIDDEN',
12597 );
126126-12798 if (isAuthError) {
12899 // clear storage, log the user out etc
129100 }
···1212} from 'wonka';
13131414import {
1515+ createRequest,
1616+ makeOperation,
1517 Operation,
1618 OperationContext,
1719 OperationResult,
1820 CombinedError,
1921 Exchange,
2020- createRequest,
2121- makeOperation,
2222 TypedDocumentNode,
2323+ AnyVariables,
2324} from '@urql/core';
24252526import { DocumentNode } from 'graphql';
26272727-export interface AuthConfig<T> {
2828- /** addAuthToOperation() must be provided to add the custom `authState` to an Operation's context, so that it may be picked up by the `fetchExchange`. */
2929- addAuthToOperation(params: {
3030- authState: T | null;
3131- operation: Operation;
3232- }): Operation;
2828+/** Utilities to use while refreshing authentication tokens. */
2929+export interface AuthUtilities {
3030+ /** Sends a mutation to your GraphQL API, bypassing earlier exchanges and authentication.
3131+ *
3232+ * @param query - a GraphQL document containing the mutation operation that will be executed.
3333+ * @param variables - the variables used to execute the operation.
3434+ * @param context - {@link OperationContext} options that'll be used in future exchanges.
3535+ * @returns A `Promise` of an {@link OperationResult} for the GraphQL mutation.
3636+ *
3737+ * @remarks
3838+ * The `mutation()` utility method is useful when your authentication requires you to make a GraphQL mutation
3939+ * request to update your authentication tokens. In these cases, you likely wish to bypass prior exchanges and
4040+ * the authentication in the `authExchange` itself.
4141+ *
4242+ * This method bypasses the usual mutation flow of the `Client` and instead issues the mutation as directly
4343+ * as possible. This also means that it doesn’t carry your `Client`'s default {@link OperationContext}
4444+ * options, so you may have to pass them again, if needed.
4545+ */
4646+ mutate<Data = any, Variables extends AnyVariables = AnyVariables>(
4747+ query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
4848+ variables: Variables,
4949+ context?: Partial<OperationContext>
5050+ ): Promise<OperationResult<Data>>;
33513434- /** didAuthError() may be provided to tweak the detection of an authentication error that this exchange should handle. */
3535- didAuthError?(params: { error: CombinedError; authState: T | null }): boolean;
5252+ /** Adds additional HTTP headers to an `Operation`.
5353+ *
5454+ * @param operation - An {@link Operation} to add headers to.
5555+ * @param headers - The HTTP headers to add to the `Operation`.
5656+ * @returns The passed {@link Operation} with the headers added to it.
5757+ *
5858+ * @remarks
5959+ * The `appendHeaders()` utility method is useful to add additional HTTP headers
6060+ * to an {@link Operation}. It’s a simple convenience function that takes
6161+ * `operation.context.fetchOptions` into account, since adding headers for
6262+ * authentication is common.
6363+ */
6464+ appendHeaders(
6565+ operation: Operation,
6666+ headers: Record<string, string>
6767+ ): Operation;
6868+}
36693737- /** willAuthError() may be provided to detect a potential operation that'll receive authentication error so that getAuth() can be run proactively. */
3838- willAuthError?(params: {
3939- authState: T | null;
4040- operation: Operation;
4141- }): boolean;
7070+/** Configuration for the `authExchange` returned by the initializer function you write. */
7171+export interface AuthConfig {
7272+ /** Called for every operation to add authentication data to your operation.
7373+ *
7474+ * @param operation - An {@link Operation} that needs authentication tokens added.
7575+ * @returns a new {@link Operation} with added authentication tokens.
7676+ *
7777+ * @remarks
7878+ * The {@link authExchange} will call this function you provide and expects that you
7979+ * add your authentication tokens to your operation here, on the {@link Operation}
8080+ * that is returned.
8181+ *
8282+ * Hint: You likely want to modify your `fetchOptions.headers` here, for instance to
8383+ * add an `Authorization` header.
8484+ */
8585+ addAuthToOperation(operation: Operation): Operation;
42864343- /** getAuth() handles how the application refreshes or reauthenticates given a stale `authState` and should return a new `authState` or `null`. */
4444- getAuth(params: {
4545- authState: T | null;
4646- /** The mutate() method may be used to send one-off mutations to the GraphQL API for the purpose of authentication. */
4747- mutate<Data = any, Variables extends object = {}>(
4848- query: DocumentNode | TypedDocumentNode<Data, Variables> | string,
4949- variables?: Variables,
5050- context?: Partial<OperationContext>
5151- ): Promise<OperationResult<Data>>;
5252- }): Promise<T | null>;
8787+ /** Called before an operation is forwaded onwards to make a request.
8888+ *
8989+ * @param operation - An {@link Operation} that needs authentication tokens added.
9090+ * @returns a boolean, if true, authentication must be refreshed.
9191+ *
9292+ * @remarks
9393+ * The {@link authExchange} will call this function before an {@link Operation} is
9494+ * forwarded onwards to your following exchanges.
9595+ *
9696+ * When this function returns `true`, the `authExchange` will call
9797+ * {@link AuthConfig.refreshAuth} before forwarding more operations
9898+ * to prompt you to update your authentication tokens.
9999+ *
100100+ * Hint: If you define this function, you can use it to check whether your authentication
101101+ * tokens have expired.
102102+ */
103103+ willAuthError?(operation: Operation): boolean;
104104+105105+ /** Called after receiving an operation result to check whether it has failed with an authentication error.
106106+ *
107107+ * @param error - A {@link CombinedError} that a result has come back with.
108108+ * @param operation - The {@link Operation} of that has failed.
109109+ * @returns a boolean, if true, authentication must be refreshed.
110110+ *
111111+ * @remarks
112112+ * The {@link authExchange} will call this function if it sees an {@link OperationResult}
113113+ * with a {@link CombinedError} on it, implying that it may have failed due to an authentication
114114+ * error.
115115+ *
116116+ * When this function returns `true`, the `authExchange` will call
117117+ * {@link AuthConfig.refreshAuth} before forwarding more operations
118118+ * to prompt you to update your authentication tokens.
119119+ * Afterwards, this operation will be retried once.
120120+ *
121121+ * Hint: You should define a function that detects your API’s authentication
122122+ * errors, e.g. using `result.extensions`.
123123+ */
124124+ didAuthError(error: CombinedError, operation: Operation): boolean;
125125+126126+ /** Called to refresh the authentication state.
127127+ *
128128+ * @remarks
129129+ * The {@link authExchange} will call this function if either {@link AuthConfig.willAuthError}
130130+ * or {@link AuthConfig.didAuthError} have returned `true` prior, which indicates that the
131131+ * authentication state you hold has expired or is out-of-date.
132132+ *
133133+ * When this function is called, you should refresh your authentication state.
134134+ * For instance, if you have a refresh token and an access token, you should rotate
135135+ * these tokens with your API by sending the refresh token.
136136+ *
137137+ * Hint: You can use the {@link fetch} API here, or use {@link AuthUtilities.mutate}
138138+ * if your API requires a GraphQL mutation to refresh your authentication state.
139139+ */
140140+ refreshAuth(): Promise<void>;
53141}
5414255143const addAuthAttemptToOperation = (
56144 operation: Operation,
5757- hasAttempted: boolean
145145+ authAttempt: boolean
58146) =>
59147 makeOperation(operation.kind, operation, {
60148 ...operation.context,
6161- authAttempt: hasAttempted,
149149+ authAttempt,
62150 });
631516464-export function authExchange<T>({
6565- addAuthToOperation,
6666- getAuth,
6767- didAuthError,
6868- willAuthError,
6969-}: AuthConfig<T>): Exchange {
152152+/** Creates an `Exchange` handling control flow for authentication.
153153+ *
154154+ * @param init - An initializer function that returns an {@link AuthConfig} wrapped in a `Promise`.
155155+ * @returns the created authentication {@link Exchange}.
156156+ *
157157+ * @remarks
158158+ * The `authExchange` is used to create an exchange handling authentication and
159159+ * the control flow of refresh authentication.
160160+ *
161161+ * You must pass an initializer function, which receives {@link AuthUtilities} and
162162+ * must return an {@link AuthConfig} wrapped in a `Promise`.
163163+ * When this exchange is used in your `Client`, it will first call your initializer
164164+ * function, which gives you an opportunity to get your authentication state, e.g.
165165+ * from local storage.
166166+ *
167167+ * You may then choose to validate this authentication state and update it, and must
168168+ * then return an {@link AuthConfig}.
169169+ *
170170+ * This configuration defines how you add authentication state to {@link Operation | Operations},
171171+ * when your authentication state expires, when an {@link OperationResult} has errored
172172+ * with an authentication error, and how to refresh your authentication state.
173173+ *
174174+ * @example
175175+ * ```ts
176176+ * authExchange(async (utils) => {
177177+ * let token = localStorage.getItem('token');
178178+ * let refreshToken = localStorage.getItem('refreshToken');
179179+ * return {
180180+ * addAuthToOperation(operation) {
181181+ * return utils.appendHeaders(operation, {
182182+ * Authorization: `Bearer ${token}`,
183183+ * });
184184+ * },
185185+ * didAuthError(error) {
186186+ * return error.graphQLErrors.some(e => e.extensions?.code === 'FORBIDDEN');
187187+ * },
188188+ * async refreshAuth() {
189189+ * const result = await utils.mutate(REFRESH, { token });
190190+ * if (result.data?.refreshLogin) {
191191+ * token = result.data.refreshLogin.token;
192192+ * refreshToken = result.data.refreshLogin.refreshToken;
193193+ * localStorage.setItem('token', token);
194194+ * localStorage.setItem('refreshToken', refreshToken);
195195+ * }
196196+ * },
197197+ * };
198198+ * });
199199+ * ```
200200+ */
201201+export function authExchange(
202202+ init: (utilities: AuthUtilities) => Promise<AuthConfig>
203203+): Exchange {
70204 return ({ client, forward }) => {
7171- const bypassQueue: WeakSet<Operation> = new WeakSet();
7272- const retryQueue: Map<number, Operation> = new Map();
205205+ const bypassQueue = new WeakSet<Operation>();
206206+ const retries = makeSubject<Operation>();
732077474- const {
7575- source: retrySource$,
7676- next: retryOperation,
7777- } = makeSubject<Operation>();
208208+ let retryQueue = new Map<number, Operation>();
782097979- let authState: T | null = null;
210210+ function flushQueue(_config?: AuthConfig | undefined) {
211211+ if (_config) config = _config;
212212+ authPromise = undefined;
213213+ const queue = retryQueue;
214214+ retryQueue = new Map();
215215+ queue.forEach(retries.next);
216216+ }
217217+218218+ let authPromise: Promise<void> | void;
219219+ let config: AuthConfig | null = null;
8022081221 return operations$ => {
8282- function mutate<Data = any, Variables extends object = {}>(
8383- query: DocumentNode | string,
8484- variables?: Variables,
8585- context?: Partial<OperationContext>
8686- ): Promise<OperationResult<Data>> {
8787- const operation = client.createRequestOperation(
8888- 'mutation',
8989- createRequest(query, variables),
9090- context
9191- );
222222+ authPromise = Promise.resolve()
223223+ .then(() =>
224224+ init({
225225+ mutate<Data = any, Variables extends AnyVariables = AnyVariables>(
226226+ query: DocumentNode | string,
227227+ variables: Variables,
228228+ context?: Partial<OperationContext>
229229+ ): Promise<OperationResult<Data>> {
230230+ const baseOperation = client.createRequestOperation(
231231+ 'mutation',
232232+ createRequest(query, variables),
233233+ context
234234+ );
235235+ return pipe(
236236+ result$,
237237+ onStart(() => {
238238+ const operation = addAuthToOperation(baseOperation);
239239+ bypassQueue.add(operation);
240240+ retries.next(operation);
241241+ }),
242242+ filter(result => result.operation.key === baseOperation.key),
243243+ take(1),
244244+ toPromise
245245+ );
246246+ },
247247+ appendHeaders(
248248+ operation: Operation,
249249+ headers: Record<string, string>
250250+ ) {
251251+ const fetchOptions =
252252+ typeof operation.context.fetchOptions === 'function'
253253+ ? operation.context.fetchOptions()
254254+ : operation.context.fetchOptions || {};
255255+ return makeOperation(operation.kind, operation, {
256256+ ...operation.context,
257257+ fetchOptions: {
258258+ ...fetchOptions,
259259+ headers: {
260260+ ...fetchOptions.headers,
261261+ ...headers,
262262+ },
263263+ },
264264+ });
265265+ },
266266+ })
267267+ )
268268+ .then(flushQueue);
922699393- return pipe(
9494- result$,
9595- onStart(() => {
9696- bypassQueue.add(operation);
9797- retryOperation(operation);
9898- }),
9999- filter(result => result.operation.key === operation.key),
100100- take(1),
101101- toPromise
270270+ function refreshAuth(operation: Operation) {
271271+ // add to retry queue to try again later
272272+ retryQueue.set(
273273+ operation.key,
274274+ addAuthAttemptToOperation(operation, true)
102275 );
276276+ // check that another operation isn't already doing refresh
277277+ if (config && !authPromise) {
278278+ authPromise = config.refreshAuth().finally(flushQueue);
279279+ }
103280 }
104281105105- const updateAuthState = (newAuthState: T | null) => {
106106- authState = newAuthState;
107107- authPromise = undefined;
108108- retryQueue.forEach(retryOperation);
109109- retryQueue.clear();
110110- };
282282+ function willAuthError(operation: Operation) {
283283+ return (
284284+ !operation.context.authAttempt &&
285285+ config &&
286286+ config.willAuthError &&
287287+ config.willAuthError(operation)
288288+ );
289289+ }
111290112112- let authPromise: Promise<any> | void = Promise.resolve()
113113- .then(() => getAuth({ authState, mutate }))
114114- .then(updateAuthState);
291291+ function didAuthError(result: OperationResult) {
292292+ return (
293293+ config &&
294294+ config.didAuthError &&
295295+ config.didAuthError(result.error!, result.operation)
296296+ );
297297+ }
115298116116- const refreshAuth = (operation: Operation): void => {
117117- // add to retry queue to try again later
118118- operation = addAuthAttemptToOperation(operation, true);
119119- retryQueue.set(operation.key, operation);
120120-121121- // check that another operation isn't already doing refresh
122122- if (!authPromise) {
123123- authPromise = getAuth({ authState, mutate })
124124- .then(updateAuthState)
125125- .catch(() => updateAuthState(null));
126126- }
127127- };
299299+ function addAuthToOperation(operation: Operation) {
300300+ return config ? config.addAuthToOperation(operation) : operation;
301301+ }
128302129303 const sharedOps$ = pipe(operations$, share);
130304131305 const teardownOps$ = pipe(
132306 sharedOps$,
133133- filter((operation: Operation) => {
134134- return operation.kind === 'teardown';
135135- })
307307+ filter(operation => operation.kind === 'teardown')
136308 );
137309138310 const pendingOps$ = pipe(
139311 sharedOps$,
140140- filter((operation: Operation) => {
141141- return operation.kind !== 'teardown';
142142- })
312312+ filter(operation => operation.kind !== 'teardown')
143313 );
144314145315 const opsWithAuth$ = pipe(
146146- merge([retrySource$, pendingOps$]),
316316+ merge([retries.source, pendingOps$]),
147317 map(operation => {
148318 if (bypassQueue.has(operation)) {
149319 return operation;
150320 } else if (authPromise) {
151151- operation = addAuthAttemptToOperation(operation, false);
152152- retryQueue.set(
153153- operation.key,
154154- addAuthAttemptToOperation(operation, false)
155155- );
321321+ if (!retryQueue.has(operation.key)) {
322322+ retryQueue.set(
323323+ operation.key,
324324+ addAuthAttemptToOperation(operation, false)
325325+ );
326326+ }
156327 return null;
157157- } else if (
158158- !operation.context.authAttempt &&
159159- willAuthError &&
160160- willAuthError({ operation, authState })
161161- ) {
328328+ } else if (willAuthError(operation)) {
162329 refreshAuth(operation);
163330 return null;
164331 }
165332166166- operation = addAuthAttemptToOperation(operation, false);
167167- return addAuthToOperation({ operation, authState });
333333+ return addAuthToOperation(
334334+ addAuthAttemptToOperation(operation, false)
335335+ );
168336 }),
169337 filter(Boolean)
170338 ) as Source<Operation>;
···173341174342 return pipe(
175343 result$,
176176- filter(({ error, operation }) => {
177177- if (error && didAuthError && didAuthError({ error, authState })) {
178178- if (!operation.context.authAttempt) {
179179- refreshAuth(operation);
180180- return false;
181181- }
344344+ filter(result => {
345345+ if (
346346+ result.error &&
347347+ didAuthError(result) &&
348348+ !result.operation.context.authAttempt
349349+ ) {
350350+ refreshAuth(result.operation);
351351+ return false;
182352 }
183353184354 return true;
+1-1
exchanges/auth/src/index.ts
···11export { authExchange } from './authExchange';
22-export type { AuthConfig } from './authExchange';
22+export type { AuthUtilities, AuthConfig } from './authExchange';