Repository: FormidableLabs/urql
Pull request #939 (2020-08-14 19:26:02)

(exchanges) implement auth exchange
## Summary

Currently, every user will have to build their own auth exchange for authentication, and token refresh. The motivation for this exchange is to provide a general api that enables auth and token refresh for both React and React Native.

## High level overview

The exchange will allow flexibility in:
- being used in React (web) or React Native (primarily the implication is that, the initial auth state can be fetched asynchronously or synchronously)
- what constitutes an auth error (e.g. a 401 or a specific error code)
- how to add auth to a request (format of the header(s))
- what auth state looks like (you can also store and use refresh token, token expiry or anything else)
- how to refresh token (using a REST or graphQL endpoint)
- how to react to errors (when to log the user out)

Once the exchange is created, we trigger `getAuth` for the first time. Here you should fetch your existing auth state from storage and return it. If the storage is asynchronous (in case of React Native), the exchange will pause the execution of requests until the auth is fetched and can be added to requests. Note that we don't call `getAuth` for every request, but store the `authState` in the exchange.

When the `authState` has been returned, `addAuthToOperation` will be called for each operation. You should use the auth state to add the proper auth to each request (e.g. the auth header).

Next, `willAuthError` is called. This is an opportunity to bail out early before doing a request. E.g if the JWT is expired we'll know there's no need to do the request, and it'll definitely fail.

Once the request has been done, if an error occurs, then `didAuthError` is called to check whether the error was an auth error. If this returns true, then the auth library will call `getAuth` - this is where you should use the existing auth state to create a new (hopefully valid) auth state, e.g. use the refresh token to refresh your JWT. The `authExchange` then calls `addAuthToOperation` with the new auth state and attempts to do the request again. If the second attempt also fails with an authentication error, we let the error fall through.

Finally, we use the `errorExchange` to handle critical auth failures and log out. We know that once the error reached the `errorExchange`, authentication refresh has already been attempted:

```js
import { errorExchange } from 'urql';

errorExchange({
  onError: ({ error }) => {
    const isAuthError = error.graphQLErrors.some(
      e => e.extensions?.code === "FORBIDDEN",
    );

    if (isAuthError) {
      // log the user out
    }
  }
})
```

Note! The `errorExchange` must be placed _before_ the `authExchange` in the exchanges array, because exchanges get executed in reverse order.


## Configuration options

#### `getAuth: ({ authState: T | null }) => Promise<T | null>;`

Allows you to handle retrieving your auth state (usually a token and refresh token). Here is there the bulk of the auth is handled.

This function is called during initial load and whenever an auth error occurs. This way you can use is to fetch your token from storage on initial load,
or do a token refresh during auth failure.

#### `addAuthToOperation: ({ authState: T | null; operation: Operation; }) => Operation;`

Given the auth state you have defined, use that to add auth to each operation. Usuall this involves adding a JWT to the request header.

#### `didAuthError: ({ error: CombinedError; authState: T | null; }) => boolean;`

This gets called whenever a graphQL error occurs. Return `true` if the error should count as an auth error, `false` otherwise.

#### `willAuthError: ({ operation: Operation; authState: T | null; }) => boolean;`

This is checked _before_ a network request is done and gives you the opportunity to bail out early.
For example if you already you have an expiry on your jwt, this could be stored in `authState` and checked in this function.
If the token is expired, we'll know the request will fail with an auth error without having to do the request.
Defaults to `false`

## Set of changes

- [x]  fetch token from storage
- [x]  add token to operation
- [x]  define unauthorised response
- [x]  token refresh with REST
- [x] token refresh with graphQL
- [x] document input options

changeset-bot[bot]: ###  💥  No Changeset

Latest commit: 6390c228764c65a5565c8bfbd749517435e6da0d

Merging this PR will not cause any packages to be released. If these changes should not cause updates to packages in this repo, this is fine 🙂

**If these changes should be published to npm, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/FormidableLabs/urql/new/feature/auth-exchange?filename=.changeset/olive-flowers-search.md&value=---%0A%22%40urql%2Fexchange-auth%22%3A%20patch%0A---%0A%0A%5Bwip%5D%20Auth%20exchange%0A)


StevenLangbroek: Hey @kadikraman! Thanks for this 🥳 . We're working on authentication in our app and it seems to work. One small note; it doesn't seem to play well with URQL DevTools; it throws a promise which contains the introspection query operation. cc @kitten 
kitten: I've just added the planned `mutate` method to the `getAuth`'s function parameters which means that users can now send one-off mutations as part of their authentication flow, in case they for instance have a `refreshToken` mutation rather than REST endpoints. This mutation is not part of the usual exchange pipeline so that they don't get stuck on other preceding exchanges or never pass through due to the `pendingPromise`.

The last task is to check which events we'd like to add for the devtools.

Patch:
diff --git a/.changeset/pink-crabs-clean.md b/.changeset/pink-crabs-clean.md
new file mode 100644
index 00000000..7879ed64
--- /dev/null
+++ b/.changeset/pink-crabs-clean.md
@@ -0,0 +1,5 @@
+---
+'@urql/core': patch
+---
+
+Fix condition where mutated result data would be picked up by the `ssrExchange`, for instance as a result of mutations by Graphcache. Instead the `ssrExchange` now serializes data early.
diff --git a/.changeset/pretty-pillows-serve.md b/.changeset/pretty-pillows-serve.md
new file mode 100644
index 00000000..a5728acb
--- /dev/null
+++ b/.changeset/pretty-pillows-serve.md
@@ -0,0 +1,7 @@
+---
+'@urql/core': patch
+'@urql/exchange-multipart-fetch': patch
+'@urql/exchange-persisted-fetch': patch
+---
+
+Omit the `Content-Type: application/json` HTTP header when using GET in the `fetchExchange`, `persistedFetchExchange`, or `multipartFetchExchange`.
diff --git a/exchanges/auth/CHANGELOG.md b/exchanges/auth/CHANGELOG.md
new file mode 100644
index 00000000..8c30f86a
--- /dev/null
+++ b/exchanges/auth/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## v0.1.0
+
+**Initial Release**
diff --git a/exchanges/auth/README.md b/exchanges/auth/README.md
new file mode 100644
index 00000000..0244e4b7
--- /dev/null
+++ b/exchanges/auth/README.md
@@ -0,0 +1,139 @@
+<h2 align="center">@urql/exchange-auth</h2>
+
+<p align="center"><strong>An exchange for managing authentication in <code>urql</code></strong></p>
+
+`@urql/exchange-auth` is an exchange for the [`urql`](https://github.com/FormidableLabs/urql) GraphQL client which helps handle auth headers and token refresh
+
+## Quick Start Guide
+
+First install `@urql/exchange-auth` alongside `urql`:
+
+```sh
+yarn add @urql/exchange-auth
+# or
+npm install --save @urql/exchange-auth
+```
+
+You'll then need to add the `authExchange`, that this package exposes to your `urql` Client
+
+```js
+import { createClient, dedupExchange, cacheExchange, Operation } from 'urql';
+import { authExchange } from '@urql/exchange-auth';
+
+const client = createClient({
+  url: 'http://localhost:1234/graphql',
+  exchanges: [
+    dedupExchange,
+    cacheExchange,
+    authExchange({
+      addAuthToOperation: ({
+        authState,
+        operation,
+      }) => {
+        // the token isn't in the auth state, return the operation without changes
+        if (!authState || !authState.token) {
+          return operation;
+        }
+
+        // fetchOptions can be a function (See Client API) but you can simplify this based on usage
+        const fetchOptions =
+          typeof operation.context.fetchOptions === 'function'
+            ? operation.context.fetchOptions()
+            : operation.context.fetchOptions || {};
+
+        return {
+          ...operation,
+          context: {
+            ...operation.context,
+            fetchOptions: {
+              ...fetchOptions,
+              headers: {
+                ...fetchOptions.headers,
+                "Authorization": authState.token,
+              },
+            },
+          },
+        };
+      },
+      willAuthError: ({ authState }) => {
+        if (!authState) return true;
+        // e.g. check for expiration, existence of auth etc
+        return false;
+      },
+      didAuthError: ({ error }) => {
+        // check if the error was an auth error (this can be implemented in various ways, e.g. 401 or a special error code)
+        return error.graphQLErrors.some(
+          e => e.extensions?.code === 'FORBIDDEN',
+        );
+      },
+      getAuth: async ({ authState, mutate }) => {
+        // for initial launch, fetch the auth state from storage (local storage, async storage etc)
+        if (!authState) {
+          const token = localStorage.getItem('token');
+          const refreshToken = localStorage.getItem('refreshToken');
+          if (token && refreshToken) {
+            return { token, refreshToken };
+          }
+          return null;
+        }
+
+        /**
+         * the following code gets exetuted when an auth error has occurred
+         * we should refresh the token if possible and return a new auth state
+         * If refrsh fails, we should log out
+         **/
+
+        // if your refresh logic is in graphQL, you must use this mutate function to call it
+        // if your refresh logic is a separate RESTful endpoint, use fetch or similar
+        const result = await mutate(refreshMutation, {
+          token: authState!.refreshToken,
+        });
+
+        if (result.data?.refreshLogin) {
+          // save the new tokens in storage for next restart
+          localStorage.setItem('token', result.data.refreshLogin.token);
+          localStorage.setItem('refreshToken', result.data.refreshLogin.refreshToken);
+
+          // return the new tokens
+          return {
+            token: result.data.refreshLogin.token,
+            refreshToken: result.data.refreshLogin.refreshToken,
+          };
+        }
+
+        // otherwise, if refresh fails, log clear storage and log out
+        localStorage.clear();
+
+        // your app logout logic should trigger here
+        logout();
+
+        return null;
+      },
+    }),
+    fetchExchange
+  ],
+});
+```
+
+## Handling Errors via the errorExchange
+
+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`.
+If you want to handle errors globally, this can be done using the `errorExchange`:
+
+```js
+import { errorExchange } from 'urql';
+
+// 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
+errorExchange({
+  onError: ({ error }) => {
+    // we only get an auth error here when the auth excahnge had attempted to refresh auth and getting an auth error again for the second time
+    const isAuthError = error.graphQLErrors.some(
+      e => e.extensions?.code === 'FORBIDDEN',
+    );
+
+    if (isAuthError) {
+      // clear storage, log the user out etc
+    }
+  }
+}),
+```
diff --git a/exchanges/auth/package.json b/exchanges/auth/package.json
new file mode 100644
index 00000000..3df2f8ce
--- /dev/null
+++ b/exchanges/auth/package.json
@@ -0,0 +1,67 @@
+{
+  "name": "@urql/exchange-auth",
+  "version": "0.1.0",
+  "description": "An exchange for managing authentication and token refresh in urql",
+  "sideEffects": false,
+  "homepage": "https://formidable.com/open-source/urql/docs/",
+  "bugs": "https://github.com/FormidableLabs/urql/issues",
+  "license": "MIT",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/FormidableLabs/urql.git",
+    "directory": "exchanges/auth"
+  },
+  "keywords": [
+    "urql",
+    "exchange",
+    "auth",
+    "authentication",
+    "formidablelabs",
+    "exchanges"
+  ],
+  "main": "dist/urql-exchange-auth",
+  "module": "dist/urql-exchange-auth.mjs",
+  "types": "dist/types/index.d.ts",
+  "source": "src/index.ts",
+  "exports": {
+    ".": {
+      "import": "./dist/urql-exchange-auth.mjs",
+      "require": "./dist/urql-exchange-auth.js",
+      "types": "./dist/types/index.d.ts",
+      "source": "./src/index.ts"
+    },
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "LICENSE",
+    "CHANGELOG.md",
+    "README.md",
+    "dist/"
+  ],
+  "scripts": {
+    "test": "jest",
+    "clean": "rimraf dist extras",
+    "check": "tsc --noEmit",
+    "lint": "eslint --ext=js,jsx,ts,tsx .",
+    "build": "rollup -c ../../scripts/rollup/config.js",
+    "prepare": "node ../../scripts/prepare/index.js",
+    "prepublishOnly": "run-s clean build"
+  },
+  "jest": {
+    "preset": "../../scripts/jest/preset"
+  },
+  "dependencies": {
+    "@urql/core": ">=1.12.0",
+    "wonka": "^4.0.14"
+  },
+  "peerDependencies": {
+    "graphql": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0"
+  },
+  "devDependencies": {
+    "graphql": "^15.1.0",
+    "graphql-tag": "^2.10.1"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}
diff --git a/exchanges/auth/src/authExchange.ts b/exchanges/auth/src/authExchange.ts
new file mode 100644
index 00000000..8f7de6cd
--- /dev/null
+++ b/exchanges/auth/src/authExchange.ts
@@ -0,0 +1,197 @@
+import {
+  pipe,
+  map,
+  mergeMap,
+  fromPromise,
+  fromValue,
+  filter,
+  onStart,
+  take,
+  makeSubject,
+  toPromise,
+  merge,
+  share,
+  takeUntil,
+} from 'wonka';
+
+import {
+  Operation,
+  OperationContext,
+  OperationResult,
+  CombinedError,
+  Exchange,
+  createRequest,
+} from '@urql/core';
+
+import { DocumentNode } from 'graphql';
+
+export interface AuthConfig<T> {
+  /** addAuthToOperation() must be provided to add the custom `authState` to an Operation's context, so that it may be picked up by the `fetchExchange`. */
+  addAuthToOperation(params: {
+    authState: T | null;
+    operation: Operation;
+  }): Operation;
+
+  /** didAuthError() may be provided to tweak the detection of an authentication error that this exchange should handle. */
+  didAuthError?(params: { error: CombinedError; authState: T | null }): boolean;
+
+  /** willAuthError() may be provided to detect a potential operation that'll receive authentication error so that getAuth() can be run proactively. */
+  willAuthError?(params: {
+    authState: T | null;
+    operation: Operation;
+  }): boolean;
+
+  /** getAuth() handles how the application refreshes or reauthenticates given a stale `authState` and should return a new `authState` or `null`. */
+  getAuth(params: {
+    authState: T | null;
+    /** The mutate() method may be used to send one-off mutations to the GraphQL API for the purpose of authentication. */
+    mutate<Data = any, Variables extends object = {}>(
+      query: DocumentNode | string,
+      variables?: Variables,
+      context?: Partial<OperationContext>
+    ): Promise<OperationResult<Data>>;
+  }): Promise<T | null>;
+}
+
+const addAuthAttemptToOperation = (
+  operation: Operation,
+  hasAttempted: boolean
+) => ({
+  ...operation,
+  context: {
+    ...operation.context,
+    authAttempt: hasAttempted,
+  },
+});
+
+export function authExchange<T>({
+  addAuthToOperation,
+  getAuth,
+  didAuthError,
+  willAuthError,
+}: AuthConfig<T>): Exchange {
+  return ({ client, forward }) => {
+    const retryQueue: Map<number, Operation> = new Map();
+    const { source: retrySource$, next: retryOperation } = makeSubject<
+      Operation
+    >();
+
+    let authState: T | null = null;
+
+    return operations$ => {
+      function mutate<Data = any, Variables extends object = {}>(
+        query: DocumentNode | string,
+        variables?: Variables,
+        context?: Partial<OperationContext>
+      ): Promise<OperationResult<Data>> {
+        const operation = client.createRequestOperation(
+          'mutation',
+          createRequest(query, variables),
+          context
+        );
+
+        return pipe(
+          result$,
+          onStart(() => retryOperation(operation)),
+          filter(result => result.operation.key === operation.key),
+          take(1),
+          toPromise
+        );
+      }
+
+      const updateAuthState = (newAuthState: T | null) => {
+        authState = newAuthState;
+        pendingPromise = undefined;
+        retryQueue.forEach(retryOperation);
+        retryQueue.clear();
+      };
+
+      let pendingPromise: Promise<any> | void = Promise.resolve()
+        .then(() => getAuth({ authState, mutate }))
+        .then(updateAuthState);
+
+      const refreshAuth = (operation: Operation): Promise<any> => {
+        // add to retry queue to try again later
+        operation = addAuthAttemptToOperation(operation, true);
+        retryQueue.set(operation.key, operation);
+
+        // check that another operation isn't already doing refresh
+        if (!pendingPromise) {
+          pendingPromise = getAuth({ authState, mutate })
+            .then(updateAuthState)
+            .catch(() => updateAuthState(null));
+        }
+
+        return pendingPromise;
+      };
+
+      const sharedOps$ = pipe(operations$, share);
+
+      const teardownOps$ = pipe(
+        sharedOps$,
+        filter((operation: Operation) => {
+          return operation.operationName === 'teardown';
+        })
+      );
+
+      const pendingOps$ = pipe(
+        sharedOps$,
+        filter((operation: Operation) => {
+          return operation.operationName !== 'teardown';
+        })
+      );
+
+      const opsWithAuth$ = pipe(
+        merge([
+          retrySource$,
+          pipe(
+            pendingOps$,
+            mergeMap(operation => {
+              if (
+                !pendingPromise &&
+                willAuthError &&
+                willAuthError({ operation, authState })
+              ) {
+                pendingPromise = refreshAuth(operation);
+              } else if (!pendingPromise) {
+                return fromValue(addAuthAttemptToOperation(operation, false));
+              }
+
+              const teardown$ = pipe(
+                sharedOps$,
+                filter(op => {
+                  return (
+                    op.operationName === 'teardown' && op.key === operation.key
+                  );
+                })
+              );
+
+              return pipe(
+                fromPromise(pendingPromise),
+                map(() => addAuthAttemptToOperation(operation, false)),
+                takeUntil(teardown$)
+              );
+            })
+          ),
+        ]),
+        map(operation => addAuthToOperation({ operation, authState }))
+      );
+
+      const result$ = pipe(merge([opsWithAuth$, teardownOps$]), forward, share);
+
+      return pipe(
+        result$,
+        filter(({ error, operation }) => {
+          if (error && didAuthError && didAuthError({ error, authState })) {
+            if (!operation.context.authAttempt) {
+              pendingPromise = refreshAuth(operation);
+              return false;
+            }
+          }
+
+          return true;
+        })
+      );
+    };
+  };
+}
diff --git a/exchanges/auth/src/index.ts b/exchanges/auth/src/index.ts
new file mode 100644
index 00000000..e2b0c49b
--- /dev/null
+++ b/exchanges/auth/src/index.ts
@@ -0,0 +1,1 @@
+export { authExchange } from './authExchange';
diff --git a/exchanges/auth/tsconfig.json b/exchanges/auth/tsconfig.json
new file mode 100644
index 00000000..28c32515
--- /dev/null
+++ b/exchanges/auth/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "target": "es2019",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/core/*": ["../../node_modules/@urql/core/src/*"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/core/src/exchanges/ssr.ts b/packages/core/src/exchanges/ssr.ts
index 53a56025..edb21646 100644
--- a/packages/core/src/exchanges/ssr.ts
+++ b/packages/core/src/exchanges/ssr.ts
@@ -4,7 +4,7 @@ import { Exchange, OperationResult, Operation } from '../types';
 import { CombinedError } from '../utils';
 
 export interface SerializedResult {
-  data?: any;
+  data?: string | undefined; // JSON string of data
   error?: {
     graphQLErrors: Array<Partial<GraphQLError> | string>;
     networkError?: string;
@@ -35,7 +35,11 @@ const serializeResult = ({
   data,
   error,
 }: OperationResult): SerializedResult => {
-  const result: SerializedResult = { data, error: undefined };
+  const result: SerializedResult = {
+    data: JSON.stringify(data),
+    error: undefined,
+  };
+
   if (error) {
     result.error = {
       graphQLErrors: error.graphQLErrors.map(error => {
@@ -59,10 +63,11 @@ const deserializeResult = (
   operation: Operation,
   result: SerializedResult
 ): OperationResult => {
-  const { error, data } = result;
+  const { error, data: dataJson } = result;
+
   const deserialized: OperationResult = {
     operation,
-    data,
+    data: dataJson ? JSON.parse(dataJson) : undefined,
     extensions: undefined,
     error: error
       ? new CombinedError({
diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts
index b14743e7..244f3592 100644
--- a/packages/core/src/internal/fetchOptions.ts
+++ b/packages/core/src/internal/fetchOptions.ts
@@ -87,9 +87,8 @@ export const makeFetchOptions = (
     ...extraOptions,
     body: !useGETMethod && body ? JSON.stringify(body) : undefined,
     method: useGETMethod ? 'GET' : 'POST',
-    headers: {
-      'content-type': 'application/json',
-      ...extraOptions.headers,
-    },
+    headers: useGETMethod
+      ? extraOptions.headers
+      : { 'content-type': 'application/json', ...extraOptions.headers },
   };
 };


Test patch:
diff --git a/exchanges/auth/src/authExchange.test.ts b/exchanges/auth/src/authExchange.test.ts
new file mode 100644
index 00000000..9b6c6cd3
--- /dev/null
+++ b/exchanges/auth/src/authExchange.test.ts
@@ -0,0 +1,356 @@
+import {
+  Source,
+  pipe,
+  fromValue,
+  toPromise,
+  take,
+  makeSubject,
+  publish,
+  tap,
+  map,
+} from 'wonka';
+
+import { print } from 'graphql';
+import { authExchange } from './authExchange';
+import { CombinedError, Client, Operation, OperationResult } from '@urql/core';
+import { queryOperation } from '@urql/core/test-utils';
+
+const makeExchangeArgs = () => {
+  const operations: Operation[] = [];
+  const result = jest.fn(
+    (operation: Operation): OperationResult => ({ operation })
+  );
+
+  return {
+    operations,
+    result,
+    exchangeArgs: {
+      forward: (op$: Source<Operation>) =>
+        pipe(
+          op$,
+          tap(op => operations.push(op)),
+          map(result)
+        ),
+      client: new Client({ url: '/api' }),
+    } as any,
+  };
+};
+
+const withAuthHeader = (operation, token) => {
+  const fetchOptions =
+    typeof operation.context.fetchOptions === 'function'
+      ? operation.context.fetchOptions()
+      : operation.context.fetchOptions || {};
+
+  return {
+    ...operation,
+    context: {
+      ...operation.context,
+      fetchOptions: {
+        ...fetchOptions,
+        headers: {
+          ...fetchOptions.headers,
+          Authorization: token,
+        },
+      },
+    },
+  };
+};
+
+it('adds the auth header correctly', async () => {
+  const { exchangeArgs } = makeExchangeArgs();
+
+  const res = await pipe(
+    fromValue(queryOperation),
+    authExchange({
+      getAuth: async () => ({ token: 'my-token' }),
+      willAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState!.token);
+      },
+    })(exchangeArgs),
+    take(1),
+    toPromise
+  );
+
+  const expectedOperation = {
+    ...queryOperation,
+    context: {
+      ...queryOperation.context,
+      authAttempt: false,
+      fetchOptions: {
+        ...(queryOperation.context.fetchOptions || {}),
+        headers: {
+          Authorization: 'my-token',
+        },
+      },
+    },
+  };
+
+  expect(res).toEqual({ operation: expectedOperation });
+});
+
+it('adds the auth header correctly when it is fetched asynchronously', async () => {
+  const { exchangeArgs } = makeExchangeArgs();
+
+  const res = await pipe(
+    fromValue(queryOperation),
+    authExchange<{ token: string }>({
+      getAuth: async () => {
+        await Promise.resolve();
+        return { token: 'async-token' };
+      },
+      willAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState!.token);
+      },
+    })(exchangeArgs),
+    take(1),
+    toPromise
+  );
+
+  const expectedOperation = {
+    ...queryOperation,
+    context: {
+      ...queryOperation.context,
+      authAttempt: false,
+      fetchOptions: {
+        ...(queryOperation.context.fetchOptions || {}),
+        headers: {
+          Authorization: 'async-token',
+        },
+      },
+    },
+  };
+
+  expect(res).toEqual({ operation: expectedOperation });
+});
+
+it('supports calls to the mutate() method in getAuth()', async () => {
+  const { exchangeArgs } = makeExchangeArgs();
+
+  const res = await pipe(
+    fromValue(queryOperation),
+    authExchange<{ token: string }>({
+      getAuth: async ({ mutate }) => {
+        const result = await mutate('mutation { auth }');
+        expect(print(result.operation.query)).toBe('mutation {\n  auth\n}\n');
+        return { token: 'async-token' };
+      },
+      willAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState?.token);
+      },
+    })(exchangeArgs),
+    take(2),
+    toPromise
+  );
+
+  const expectedOperation = {
+    ...queryOperation,
+    context: {
+      ...queryOperation.context,
+      authAttempt: false,
+      fetchOptions: {
+        ...(queryOperation.context.fetchOptions || {}),
+        headers: {
+          Authorization: 'async-token',
+        },
+      },
+    },
+  };
+
+  expect(res).toEqual({ operation: expectedOperation });
+});
+
+it('adds the same token to subsequent operations', async () => {
+  const { exchangeArgs } = makeExchangeArgs();
+  const { source, next } = makeSubject<any>();
+
+  const result = jest.fn();
+  const auth$ = pipe(
+    source,
+    authExchange({
+      getAuth: async () => {
+        await Promise.resolve();
+        return { token: 'my-token' };
+      },
+      willAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState!.token);
+      },
+    })(exchangeArgs),
+    tap(result),
+    take(2),
+    toPromise
+  );
+
+  next(queryOperation);
+
+  const secondQuery = {
+    ...queryOperation,
+    context: {
+      ...queryOperation.context,
+      foo: 'bar',
+    },
+  };
+
+  next(secondQuery);
+
+  await auth$;
+  expect(result).toHaveBeenCalledTimes(2);
+
+  expect(result).nthCalledWith(1, {
+    operation: {
+      ...queryOperation,
+      context: {
+        ...queryOperation.context,
+        authAttempt: false,
+        fetchOptions: {
+          ...(queryOperation.context.fetchOptions || {}),
+          headers: {
+            Authorization: 'my-token',
+          },
+        },
+      },
+    },
+  });
+
+  expect(result).nthCalledWith(2, {
+    operation: {
+      ...secondQuery,
+      context: {
+        ...secondQuery.context,
+        authAttempt: false,
+        fetchOptions: {
+          ...(secondQuery.context.fetchOptions || {}),
+          headers: {
+            Authorization: 'my-token',
+          },
+        },
+      },
+    },
+  });
+});
+
+it('triggers authentication when an operation did error', async () => {
+  const { exchangeArgs, result, operations } = makeExchangeArgs();
+  const { source, next } = makeSubject<any>();
+
+  let initialAuth;
+  let afterErrorAuth;
+
+  const didAuthError = jest.fn().mockReturnValueOnce(true);
+
+  const getAuth = jest
+    .fn()
+    .mockImplementationOnce(() => {
+      initialAuth = Promise.resolve({ token: 'initial-token' });
+      return initialAuth;
+    })
+    .mockImplementationOnce(() => {
+      afterErrorAuth = Promise.resolve({ token: 'final-token' });
+      return afterErrorAuth;
+    });
+
+  pipe(
+    source,
+    authExchange<{ token: string }>({
+      getAuth,
+      didAuthError,
+      willAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState?.token);
+      },
+    })(exchangeArgs),
+    publish
+  );
+
+  await Promise.resolve();
+  expect(getAuth).toHaveBeenCalledTimes(1);
+  await initialAuth;
+  await Promise.resolve();
+  await Promise.resolve();
+
+  result.mockReturnValueOnce({
+    operation: queryOperation,
+    error: new CombinedError({
+      graphQLErrors: [{ message: 'Oops' }],
+    }),
+  });
+
+  next(queryOperation);
+  expect(result).toHaveBeenCalledTimes(1);
+  expect(didAuthError).toHaveBeenCalledTimes(1);
+  expect(getAuth).toHaveBeenCalledTimes(2);
+
+  await afterErrorAuth;
+
+  expect(result).toHaveBeenCalledTimes(2);
+  expect(operations.length).toBe(2);
+  expect(operations[0]).toHaveProperty(
+    'context.fetchOptions.headers.Authorization',
+    'initial-token'
+  );
+  expect(operations[1]).toHaveProperty(
+    'context.fetchOptions.headers.Authorization',
+    'final-token'
+  );
+});
+
+it('triggers authentication when an operation will error', async () => {
+  const { exchangeArgs, result, operations } = makeExchangeArgs();
+  const { source, next } = makeSubject<any>();
+
+  let initialAuth;
+  let afterErrorAuth;
+
+  const willAuthError = jest
+    .fn()
+    .mockReturnValueOnce(true)
+    .mockReturnValue(false);
+
+  const getAuth = jest
+    .fn()
+    .mockImplementationOnce(() => {
+      initialAuth = Promise.resolve({ token: 'initial-token' });
+      return initialAuth;
+    })
+    .mockImplementationOnce(() => {
+      afterErrorAuth = Promise.resolve({ token: 'final-token' });
+      return afterErrorAuth;
+    });
+
+  pipe(
+    source,
+    authExchange<{ token: string }>({
+      getAuth,
+      willAuthError,
+      didAuthError: () => false,
+      addAuthToOperation: ({ authState, operation }) => {
+        return withAuthHeader(operation, authState?.token);
+      },
+    })(exchangeArgs),
+    publish
+  );
+
+  await Promise.resolve();
+  expect(getAuth).toHaveBeenCalledTimes(1);
+  await initialAuth;
+  await Promise.resolve();
+  await Promise.resolve();
+
+  next(queryOperation);
+  expect(result).toHaveBeenCalledTimes(0);
+  expect(willAuthError).toHaveBeenCalledTimes(1);
+  expect(getAuth).toHaveBeenCalledTimes(2);
+
+  await afterErrorAuth;
+
+  expect(result).toHaveBeenCalledTimes(1);
+  expect(operations.length).toBe(1);
+  expect(operations[0]).toHaveProperty(
+    'context.fetchOptions.headers.Authorization',
+    'final-token'
+  );
+});
diff --git a/packages/core/src/exchanges/ssr.test.ts b/packages/core/src/exchanges/ssr.test.ts
index f8e14abb..d2b3e161 100644
--- a/packages/core/src/exchanges/ssr.test.ts
+++ b/packages/core/src/exchanges/ssr.test.ts
@@ -2,7 +2,7 @@ import { makeSubject, pipe, map, publish, forEach, Subject } from 'wonka';
 
 import { Client } from '../client';
 import { queryOperation, queryResponse } from '../test-utils';
-import { ExchangeIO, Operation } from '../types';
+import { ExchangeIO, Operation, OperationResult } from '../types';
 import { CombinedError } from '../utils';
 import { ssrExchange } from './ssr';
 
@@ -12,6 +12,11 @@ let client: Client;
 let input: Subject<Operation>;
 let output;
 
+const serializedQueryResponse = {
+  ...queryResponse,
+  data: JSON.stringify(queryResponse.data),
+};
+
 beforeEach(() => {
   input = makeSubject<Operation>();
   output = jest.fn();
@@ -35,7 +40,43 @@ it('caches query results correctly', () => {
 
   expect(data).toEqual({
     [queryOperation.key]: {
-      data: queryResponse.data,
+      data: serializedQueryResponse.data,
+      error: undefined,
+    },
+  });
+});
+
+it('serializes query results quickly', () => {
+  const queryResponse: OperationResult = {
+    operation: queryOperation,
+    data: {
+      user: {
+        name: 'Clive',
+      },
+    },
+  };
+
+  const serializedQueryResponse = {
+    ...queryResponse,
+    data: JSON.stringify(queryResponse.data),
+  };
+
+  output.mockReturnValueOnce(queryResponse);
+
+  const ssr = ssrExchange();
+  const { source: ops$, next } = input;
+  const exchange = ssr(exchangeInput)(ops$);
+
+  publish(exchange);
+  next(queryOperation);
+  queryResponse.data.user.name = 'Not Clive';
+
+  const data = ssr.extractData();
+  expect(Object.keys(data)).toEqual(['' + queryOperation.key]);
+
+  expect(data).toEqual({
+    [queryOperation.key]: {
+      data: serializedQueryResponse.data,
       error: undefined,
     },
   });
@@ -62,7 +103,7 @@ it('caches errored query results correctly', () => {
 
   expect(data).toEqual({
     [queryOperation.key]: {
-      data: null,
+      data: 'null',
       error: {
         graphQLErrors: ['Oh no!'],
         networkError: undefined,
@@ -104,7 +145,7 @@ it('resolves cached query results correctly', () => {
   const onPush = jest.fn();
 
   const ssr = ssrExchange({
-    initialState: { [queryOperation.key]: queryResponse as any },
+    initialState: { [queryOperation.key]: serializedQueryResponse as any },
   });
 
   const { source: ops$, next } = input;
@@ -124,7 +165,7 @@ it('deletes cached results in non-suspense environments', async () => {
   const onPush = jest.fn();
   const ssr = ssrExchange();
 
-  ssr.restoreData({ [queryOperation.key]: queryResponse as any });
+  ssr.restoreData({ [queryOperation.key]: serializedQueryResponse as any });
   expect(Object.keys(ssr.extractData()).length).toBe(1);
 
   const { source: ops$, next } = input;
diff --git a/packages/react-urql/src/test-utils/ssr.test.tsx b/packages/react-urql/src/test-utils/ssr.test.tsx
index 4083cead..3342429d 100644
--- a/packages/react-urql/src/test-utils/ssr.test.tsx
+++ b/packages/react-urql/src/test-utils/ssr.test.tsx
@@ -149,7 +149,12 @@ describe('client-side rehydration', () => {
   });
 
   it('can rehydrate results on the client', async () => {
-    ssr.restoreData({ [queryOperation.key]: queryResponse });
+    ssr.restoreData({
+      [queryOperation.key]: {
+        ...queryResponse,
+        data: JSON.stringify(queryResponse.data),
+      },
+    });
 
     expect(() => {
       pipe(client.executeRequestOperation(queryOperation), publish);