universal-cookie で、SSR な Nuxt.js でも JWT 認証

## はじめに
この記事は [Nuxt.js Advent Calendar 2020](https://qiita.com/advent-calendar/2020/nuxtjs) 17日目の記事です🎉

SSR(universal モード） な Nuxt.js で自前の API サーバーと連携する際、試行錯誤した結果 Cookie を使うことになったので、方法をまとめておこうと思います。
サンプルコードは TypeScript ですが JavaScript でも大きく変わらないはずです。

## 全体の流れ
Cookie を使うことで、SSRでもクライアント側・サーバー側の両方で同じ JWT を保持して利用することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/135266/988c8739-5334-dac6-5434-a0666abbc207.png)

① クライアント側で API サーバーにログインリクエストを送る
② API サーバーは JWT を返却し、ブラウザは Cookie として保存する
③ クライアント側でAPI リクエストを行う場合、Cookie から JWT を取り出して Authentication ヘッダーに付与する
④ その後 Nuxt.js への初回アクセス時（画面リロード、別タブなど）、サーバー側に Cookie ヘッダで JWT が送られる
⑤ asyncData など、サーバー側から API リクエストする場合に Cookie で送られた JWT を付与する

### なぜ Cookie なのか？（SPA（CSR）との違い）

似た構成でも、 Nuxt.js で SPA(CSR) や SSG している場合は、 `localStorage` や `sessionStorage` に JWT トークンを保持できます。
しかし SSR の場合は初回アクセス時はサーバー(Node.js)側でレンダリングするので、この時点ではブラウザに保管されたWebストレージの情報を取り出すことはできません。
プラグインやミドルウェアを試行錯誤して、クライアント側で処理させることも試しましたが、初回アクセス時に一瞬画面が表示されてしまう（トークンチェックの前にレンダリングされるので）という問題がありました。
一方、Cookie を使えばサーバー（Node.js）側とクライアント(ブラウザ)側で同じ情報を保持できるので、初回アクセス時でもその後遷移した場合も、レンダリング前に認証チェックをかけることができます。

## 実装方法（サンプルコード）
以下の記事を参考にさせていただきました。
この場を借りてお礼申し上げます！
[Nuxt + universal-cookieでクライアント、サーバーサイド両方でcookieを扱えるようにする](https://qiita.com/igayamaguchi/items/92b473f28b0e548c0da8)

### universal-cookie の導入

universal な環境の JavaScript で Cookie を扱う [universal-cookie](https://github.com/reactivestack/cookies/tree/master/packages/universal-cookie) をインストールします。

```ssh
yarn add universal-cookie
```

### 認証ライブラリの作成

ここが一番のミソ。

```libs/auth.ts
import { IncomingMessage, OutgoingMessage } from 'http';
import { serialize } from 'cookie';
import Cookies, { CookieChangeOptions } from 'universal-cookie';

const cookieKeys = {
  accessToken: 'ACCESS_TOKEN',
};

export const isAuthenticated = (
  req: IncomingMessage,
  res: OutgoingMessage
) => {
  // JWT の有無しか見ていないけど、期限のチェックも入れるべき
  return !!getAccessToken(req, res);
};

export const getAccessToken = (
  req: IncomingMessage,
  res: OutgoingMessage
) => {
  const cookies = getCookiesInstance(req, res);
  return cookies.get(cookieKeys.accessToken);
};

export const setAccessToken = (token: String) => {
  const cookies = new Cookies();
  cookies.set(cookieKeys.accessToken, token);
};

const getCookiesInstance = (req: IncomingMessage, res: OutgoingMessage) => {
  if (process.server) {
    return createServerCookie(req.headers.cookie, res);
  } else {
    return new Cookies();
  }
};

// サーバー側での Cookie の変更をクライアントに伝えられるようにしておく
const createServerCookie = (
  cookie: string | undefined,
  res: OutgoingMessage
): Cookies => {
  const universalCookie = new Cookies(cookie);
  universalCookie.addChangeListener((change: CookieChangeOptions) => {
    if (res.headersSent) {
      return;
    }
    let cookieHeader = res.getHeader('Set-Cookie');
    if (typeof cookieHeader === 'string') {
      cookieHeader = [cookieHeader];
    } else if (typeof cookieHeader === 'number') {
      cookieHeader = [cookieHeader.toString()];
    }
    cookieHeader = (cookieHeader as string[]) || [];

    if (change.value === undefined) {
      cookieHeader.push(serialize(change.name, '', change.options));
    } else {
      cookieHeader.push(serialize(change.name, change.value, change.options));
    }

    res.setHeader('Set-Cookie', cookieHeader);
  });

  return universalCookie;
};
```

### ログイン時に JWT を Cookie に保存する

```typescript:pages/login.vue
export default Vue.extend({
  // 略
  methods: {
    async onSubmit() {
      try {
        const { data } = await login(this.input);
        setAccessToken(data.jwt);
      } catch (e) {
        // ログインエラー
      }
    },
  },
});
```

### APIリクエスト時に JWT を送る
Axios でAPI にリクエストを送る際、Cookie に保存された JWT を付与するようにしておきます。

```plugins/axios.ts
import {
  isAuthenticated,
  getAccessToken,
} from '@/libs/auth';

export let axios;
export default function ({ $axios, redirect, req, res, error }: Context) {
  // 略

  // リクエスト時の処理
  $axios.onRequest((config) => {
    const requestConfig = config;
    // JWT トークンがあれば Authorization ヘッダーに付与
    if (isAuthenticated(req, res)) {
      requestConfig.headers = {
        ...requestConfig.headers,
        Authorization: `Bearer ${getAccessToken(req, res)}`,
      };
    }
    requestConfig.headers = {
      ...requestConfig.headers,
      'Content-Type': 'application/json',
    };
    return requestConfig;
  });
 
 // 略

  axios = $axios;
}

```

### middleware でレンダリング前に認証状態をチェックする

```middleware/auth.ts
import { Context } from '@nuxt/types';
import { isAuthenticated } from '@/libs/auth';

export default ({ redirect, req, res }: Context) => {
  if (!isAuthenticated(req, res)) {
    redirect('/login');
    return;
  }
};
```

認証が必要な画面に、

```js
  middleware: ['auth'],
```

を指定しておけばレンダリング前にログイン画面にリダイレクトされます。

## まとめ

`universal-cookie` を使うことで、 SSR モードでもシームレスな API 認証の仕組みを作ることができました！
サンプルコードではログアウト機能や JWT の期限切れなどの考慮がされていませんが、要件に合わせて適宜検討してみてください。