auth0-spa-jsをnuxt流に使ってみる

# はじめに

[auth0-spa-js](https://auth0.com/docs/libraries/auth0-spa-js)が7月にリリースされました。従来のJS向けSDKとして[auth0.js](https://auth0.com/docs/libraries/auth0js/v9)の代わりに、よりシンプルに認証を行うためのライブラリです。名前の通りSPA向けのライブラリで良い感じにログインしてくれます。

2019年11月19日現在Vue用のサンプルコードは存在しているみたいです
>https://auth0.com/docs/quickstart/spa/vuejs/01-login

ただ、このままnuxtで実装した場合generateする時にSSR関連で怒られたり、ミドルウェアでうまく使えなかったりするので辛いです。そこで、今回はnuxtウェイに乗っ取ってauth0-spa-jsを使ってみましょう。

# つらみを取り除くためには

はじめにでも述べた通り、auth0-spa-jsをnuxtで使うにあたって2つの辛みがあります。

* generateする時に怒られる
* ミドルウェアで使えない

それぞれの辛みを原因と解決方法を考えていきましょう。

## generateする時に怒られる

nuxtをSPAモードで使ってる限りは問題ないです。でもせっかくnuxtを使っているのであれば、SSR的なことしたいじゃないですか。SSRしなくてもLPを一緒のプロジェクトで管理してるならLPはちゃんとクローラーに拾ってもらいたいじゃないですか。ってなるとUniversalモードを使うことになるはずです。

しかし、サンプルコードの実装だと`window`を参照しているので怒られます。今回は`process.client`で処理を切り分けて対応します。

## ミドルウェアで使えない

nuxtにはルーティング中に処理を挟み込むミドルウェアという機能があります。例えば、ユーザー情報ページはログインしていないとログインページにリダイレクトするなどの処理を挟みこめるので、認証・認可と相性が良いです。

サンプルコードの実装の場合、auth0-spa-jsのインスタンスを非同期な`created`フックで生成しています。通常のVueコンポーネントであれば非同期`created`フックを待ってくれます。今回はサンプルコード的な働きをするものをプラグインとして実装しますが、こちらも非同期プラグインとして実装すればnuxtは待ってくれます。

問題なのは、サンプルコードが同期プラグインの中で非同期`created`フックを含むVueインスタンスを作成していることです。

この場合、Vueインスタンス自体は即座に作成されます。このとき非同期`created`はキューに乗っているはずです。ただプラグイン自体は実行完了しているため、ミドルウェアに処理が移ります。しかし、ミドルウェアで生成したVueインスタンスの中の`created`フックで生成するインスタンスにアクセスすると`undefined`になることがあります。だって`created`フックが実行完了している保証がないですから。

今回はログイン状態をVueインスタンスではなくVuexストアに保存して対応します。ちなみに、Vuexストアにアクセストークンは保存しちゃダメです。都度取得しましょう。auth0-spa-jsの実装を読む限りキャッシュされているようなので問題ないはずです。

# 実装

いよいよ実装です。ついでにRBACも実装しちゃいましょう。
auth0側の手順は[公式サンプル](https://auth0.com/docs/quickstart/spa/vuejs/01-login)にしたがって準備をお願いします。

## 下準備

nuxtプロジェクトを作成しましょう。

```shell
$ npx create-nuxt-app auth0-spa-nuxt
create-nuxt-app v2.11.1
✨  Generating Nuxt.js project in auth0-spa-nuxt
? Project name auth0-spa-nuxt
? Project description My geometric Nuxt.js project
? Author name Makoto Uju
? Choose the package manager Npm
? Choose UI framework Vuetify.js
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules Axios
? Choose linting tools ESLint, Prettier
? Choose test framework None
? Choose rendering mode Universal (SSR)
? Choose development tools jsconfig.json (Recommended for VS Code)
```

依存パッケージを追加します。

```shell
$ npm i --save jwt-decode lodash.intersection lodash.merge
$ npm i --save-dev @nuxtjs/dotenv
```

dotenvモジュールの初期設定をします。

```javascript:nuxt.config.js
import colors from 'vuetify/es5/util/colors'
import dotenv from 'dotenv' // dotenvロード

dotenv.config() // dotenv初期化

export default {
  ...
  buildModules: [
    // Doc: https://github.com/nuxt-community/eslint-module
    '@nuxtjs/eslint-module',
    '@nuxtjs/vuetify',
    '@nuxtjs/dotenv' // dotenvモジュール読み込み
  ],
  ...
  build: {
    extend(config, ctx) {
      config.node = {
        fs: 'empty' // fsがないと怒られるので追加
      }
    }
  },
}

```

## Vuex実装

まず、Vuexを実装します。シンプルにサンプルコードの`data`と同じもの＋RBAC用に`permissions`が入ります。

```javascript:store/auth0.js
export const state = () => ({
  loading: true,
  isAuthenticated: false,
  user: {},
  popupOpen: false,
  permissions: []
})

export const mutations = {
  setPopupOpen(state, payload) {
    state.popupOpen = !!payload
  },
  setUser(state, payload) {
    state.user = payload
  },
  setIsAuthenticated(state, payload) {
    state.isAuthenticated = !!payload
  },
  setLoading(state, payload) {
    state.loading = !!payload
  },
  setPermissions(state, payload) {
    state.permissions = payload
  }
}
```

## プラグイン実装

基本的に全てのロジックが入ります。Vueインスタンスを生成する箇所において`process.client`によって処理を分岐させます。SSR時はモックが返却されるので問題なくSSRできます。

生成したVueインスタンスは[`inject`メソッド](https://ja.nuxtjs.org/guide/plugins/#%E7%B5%B1%E5%90%88%E3%81%95%E3%82%8C%E3%81%9F%E6%B3%A8%E5%85%A5)で包括的に注入します。vuexやコンテキストなどにまとめて注入してくれるので便利です。

あと、`const $auth0 = await useAuth0(context.store, options)`で`store`を渡しています。nuxtのVueインスタンスでは`this.$store`でVuexにアクセスできるはずです。しかし、それはnuxtがいい感じに注入してくれているからできることで、今回のようにピュアなVueインスタンスを生成した場合注入されません。よって、意図的にVuexストアを渡すようにしています。

ちなみに例外は全て呼び出し元に戻るはずです。あとでちゃんとハンドルしましょう。

```javascript:plugins/auth0.js
import Vue from 'vue'
import createAuth0Client from '@auth0/auth0-spa-js'
import jwtDecode from 'jwt-decode'
import nuxtConfig from '~/nuxt.config'

// eslint-disable-next-line prefer-const
let instance = null

const useAuth0 = async (store, { onRedirectCallback, ...options }) => {
  if (process.client) {
    if (!instance) {
      const auth0Client = await createAuth0Client({
        domain: options.domain,
        client_id: options.clientId,
        audience: options.audience,
        scope: options.scope,
        redirect_uri: window.location.origin
      })

      instance = new Vue({
        data() {
          return {
            auth0Client: null,
            error: null
          }
        },
        async created() {
          this.auth0Client = auth0Client

          if (
            // eslint-disable-next-line nuxt/no-globals-in-created
            window.location.search.includes('code=') &&
            // eslint-disable-next-line nuxt/no-globals-in-created
            window.location.search.includes('state=')
          ) {
            try {
              const { appState } = await this.handleRedirectCallback()
              onRedirectCallback(appState)
            } catch (e) {
              this.error = e
            }
          } else {
            await this.load()
          }
        },
        methods: {
          async loginWithPopup(options) {
            store.commit('auth0/setPopupOpen', true)

            try {
              await this.auth0Client.loginWithPopup(options)
            } finally {
              store.commit('auth0/setPopupOpen', false)
            }

            store.commit('auth0/setUser', await this.auth0Client.getUser())
            store.commit('auth0/setIsAuthenticated', true)
          },
          loginWithRedirect(options) {
            return this.auth0Client.loginWithRedirect(options)
          },
          logout(options) {
            this.auth0Client.logout(options)
            store.commit('auth0/setIsAuthenticated', false)
          },
          getTokenSilently(options) {
            return this.auth0Client.auth0Client.getTokenSilently(options)
          },
          async getTokenWithPopup(options) {
            store.commit('auth0/setPopupOpen', true)

            try {
              const token = await this.auth0Client.getTokenWithPopup(options)
              return token
            } finally {
              store.commit('auth0/setPopupOpen', false)
            }
          },
          async handleRedirectCallback() {
            const result = await this.auth0Client.handleRedirectCallback()

            await this.load()

            return result
          },
          async load() {
            store.commit(
              'auth0/setIsAuthenticated',
              await this.auth0Client.isAuthenticated()
            )

            const isAuthenticated = await this.auth0Client.getUser()
            store.commit('auth0/setUser', isAuthenticated)

            if (isAuthenticated) {
              const token = await this.auth0Client.getTokenSilently()
              store.commit(
                'auth0/setPermissions',
                jwtDecode(token).permissions || []
              )
            }
          }
        }
      })
    }

    return instance
  } else {
    return new Vue({
      data() {
        return {
          auth0Client: null,
          error: null
        }
      }
    })
  }
}

export default async function(context, inject) {
  const options = {
    ...nuxtConfig.auth0,
    onRedirectCallback: (appState) => {
      context.app.router.push(
        appState && appState.targetUrl
          ? appState.targetUrl
          : window.location.pathname
      )
    }
  }

  const $auth0 = await useAuth0(context.store, options)

  inject('auth0', $auth0)
}
```

## ミドルウェアの実装

アクセス制御をするためにミドルウェアを実装しましょう。各ページごとに必要なパーミッションを定義したいので、クロージャにします。

```javascript:middleware/auth.js
import intersection from 'lodash/intersection'
import merge from 'lodash/merge'

export default {
  protect(options) {
    options = merge({ loginRequired: true, requiredPermissions: [] }, options)

    return ({ app, redirect, store }) => {
      if (options.loginRequired && !store.state.auth0.isAuthenticated) {
        return redirect('/')
      }

      if (options.loginRequired && options.requiredPermissions.length > 0) {
        if (
          intersection(
            options.requiredPermissions,
            store.state.auth0.permissions
          ).length !== options.requiredPermissions.length
        ) {
          return redirect('/')
        }
      }
    }
  }
}
```

## トップページの実装

いよいよトップページの実装です。ログイン状態はVuexストアを見ればわかります。癖のないコードのはずなので説明はスキップします。

```vue:pages/index.js
<template>
  <v-container>
    <v-row justify="center">
      <v-col cols="12" sm="8">
        <v-card v-if="isAuthenticated">
          <v-card-text>
            <div class="text-center py-3">
              <v-avatar size="100">
                <img :src="user.picture"/>
              </v-avatar>
            </div>

            <p class="display-1 text--primary text-center">
              {{ user.name }}
            </p>

            <v-simple-table>
              <template>
                <thead>
                  <tr>
                    <th class="text-left">Key</th>
                    <th class="text-left">Value</th>
                  </tr>
                </thead>
                <tbody>
                  <tr v-for="(value, key) in user" :key="key">
                    <td>{{ key }}</td>
                    <td>{{ value }}</td>
                  </tr>
                </tbody>
              </template>
            </v-simple-table>
          </v-card-text>

          <v-card-actions>
            <div class="mb-3 mx-auto">
              <v-btn class="" @click="logout">
                LOGOUT
              </v-btn>
            </div>
          </v-card-actions>
        </v-card>

        <v-card v-else>
          <v-card-text>
            <div class="text-center">
              <p class="display-1 text--primary text-center">
                Please login.
              </p>
            </div>
          </v-card-text>
          <v-card-actions>
            <div class="mb-3 mx-auto">
              <v-btn class="" @click="login">
                LOGIN
              </v-btn>
            </div>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script>
import { mapState } from 'vuex'

export default {
  computed: {
    ...mapState('auth0', ['user', 'isAuthenticated'])
  },
  methods: {
    login() {
      this.$auth0.loginWithRedirect()
    },
    logout() {
      this.$auth0.logout({ returnTo: location.href })
    }
  }
}
</script>
```

## 保護されたページの実装

ログインや適切なパーミッションを必要とするページを実装します。先ほど実装したミドルウェアを呼び出し、関数を生成しています。オプションで必要なパーミッションを渡すことでRBACを実現します。

SSR時はVuexストアが初期状態（未ログイン・パーミッションなし）なので、保護されたページに直接アクセスされても正常にリダイレクトできるはずです。

```vue:pages/protected.vue
<template>
  <v-layout>
    <v-flex class="text-center">
      <v-alert type="warning" icon="mdi-lock">
        Protected Content
      </v-alert>
    </v-flex>
  </v-layout>
</template>

<script>
import authMiddleware from '~/middleware/auth'

export default {
  // RBACする場合
  middleware: authMiddleware.protect({
    requiredPermissions: ['sample']
  })
  // ログインのみを必要とする場合は以下のように書く
  // middleware: authMiddleware.protect()
}
</script>
```

## 設定する

dotenvを使います。ちなみにauth0はaudienceを渡さないとアクセストークンがJWTで吐かれないので注意しましょう。また、`getTokenSilently`を使うので`offline_access`をスコープに設定します。

```javascript:nuxt.config.js
export default {
  ...
  auth0: {
    domain: process.env.AUTH0_DOMAIN,
    clientId: process.env.AUTH0_CLIENT_ID,
    audience: process.env.AUTH0_AUDIENCE,
    scope: process.env.AUTH0_SCOPE
  }
}
```

```text:.env
AUTH0_DOMAIN = "example.auth0.com"
AUTH0_CLIENT_ID = "SAmpleCl1endId"
AUTH0_AUDIENCE = "https://example.com"
AUTH0_SCOPE = "offline_access"
```

また、メニューにリンクを登録します。

```javascript:layouts/default.vue
...
<script>
export default {
  data() {
    return {
      clipped: false,
      drawer: false,
      fixed: false,
      items: [
        {
          icon: 'mdi-apps',
          title: 'Welcome',
          to: '/'
        },
        {
          icon: 'mdi-chart-bubble',
          title: 'Inspire',
          to: '/inspire'
        },
        {
          icon: 'mdi-lock',
          title: 'Protected Content',
          to: '/protected'
        }
      ],
      miniVariant: false,
      right: true,
      rightDrawer: false,
      title: 'Vuetify.js'
    }
  }
}
</script>
```

# 実行してみる

実行しましょう。devサーバーを立ち上げて、localhost:3000にアクセスします。

```shell
$ npm run dev
```
![not-logged-in.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/9e2bcd32-ffd0-2f86-1cde-3c1779102ba4.png)

ログインしましょう。

<img width="852" alt="スクリーンショット 2019-11-19 17.42.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/7ca50bed-8d4a-a287-a1b9-a2eb23ef75b3.png">

<img width="850" alt="スクリーンショット 2019-11-19 17.41.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/c4b595c4-8d7a-5f26-385e-cd3e0cec9e3c.png">

ユーザー情報が表示されれば成功です。

<img width="851" alt="スクリーンショット 2019-11-19 17.41.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/2fa88c64-c957-6f33-b8c9-9a7e3e88eb5a.png">

## RBACをためす

ではRBACを確認しましょう。まず、Auth0のAPIsからRBACを有効化します。`Add Permissions in the Access Token`を忘れずに。

<img width="869" alt="スクリーンショット 2019-11-19 17.45.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/bffe98cf-797d-c23e-c2ad-0ac967fe455b.png">

続いてPermissionsタブからパーミッションを作成します。

<img width="873" alt="スクリーンショット 2019-11-19 17.51.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/15686165-b654-2791-600a-2c67d57ee968.png">

ユーザーにパーミッションを紐付けます。

<img width="868" alt="スクリーンショット 2019-11-19 17.43.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/34847559-52f2-f101-8916-8b4eb30ea3fe.png">

<img width="657" alt="スクリーンショット 2019-11-19 17.43.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/34cfdf38-e9c1-7bf6-aa68-a0eee1ffae9b.png">

それではページにアクセスしてみましょう。左端のドロワーからProtected Contentをクリックして表示されれば正常です。

![drawer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/ce76fee3-f1e9-45f6-0319-f55b459de3dd.png)

![protected.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50147/bd59d5df-3372-d0f1-b93a-dab56c4b7891.png)

Auth0のコンソールからパーミッションを削除するとページが表示されなくなるはずです。

# おわりに

ちなみに、nuxt公式のauthモジュールを使うとつらみを感じることなくauth0を組み込むことができます。ただ、authモジュールではトークンをlocalStorageに保存するのでイヤンと感じる方の役に立てれば幸いです。

<small>そのうちちゃんとモジュール化してGithubにあげます。多分。</small>