LoginSignup
14

More than 3 years have passed since last update.

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

Posted at

はじめに

auth0-spa-jsが7月にリリースされました。従来のJS向けSDKとしてauth0.jsの代わりに、よりシンプルに認証を行うためのライブラリです。名前の通り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側の手順は公式サンプルにしたがって準備をお願いします。

下準備

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

$ 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)

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

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

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

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が入ります。

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メソッドで包括的に注入します。vuexやコンテキストなどにまとめて注入してくれるので便利です。

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

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

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)
}

ミドルウェアの実装

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

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ストアを見ればわかります。癖のないコードのはずなので説明はスキップします。

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ストアが初期状態(未ログイン・パーミッションなし)なので、保護されたページに直接アクセスされても正常にリダイレクトできるはずです。

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をスコープに設定します。

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
  }
}
.env
AUTH0_DOMAIN = "example.auth0.com"
AUTH0_CLIENT_ID = "SAmpleCl1endId"
AUTH0_AUDIENCE = "https://example.com"
AUTH0_SCOPE = "offline_access"

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

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にアクセスします。

$ npm run dev

not-logged-in.png

ログインしましょう。

スクリーンショット 2019-11-19 17.42.52.png

スクリーンショット 2019-11-19 17.41.11.png

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

スクリーンショット 2019-11-19 17.41.23.png

RBACをためす

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

スクリーンショット 2019-11-19 17.45.43.png

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

スクリーンショット 2019-11-19 17.51.15.png

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

スクリーンショット 2019-11-19 17.43.20.png

スクリーンショット 2019-11-19 17.43.30.png

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

drawer.png

protected.png

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

おわりに

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14