はじめに
auth0-spa-jsが7月にリリースされました。従来のJS向けSDKとしてauth0.jsの代わりに、よりシンプルに認証を行うためのライブラリです。名前の通りSPA向けのライブラリで良い感じにログインしてくれます。
2019年11月19日現在Vue用のサンプルコードは存在しているみたいです
ただ、このまま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モジュールの初期設定をします。
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
が入ります。
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ストアを渡すようにしています。
ちなみに例外は全て呼び出し元に戻るはずです。あとでちゃんとハンドルしましょう。
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)
}
ミドルウェアの実装
アクセス制御をするためにミドルウェアを実装しましょう。各ページごとに必要なパーミッションを定義したいので、クロージャにします。
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ストアを見ればわかります。癖のないコードのはずなので説明はスキップします。
<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ストアが初期状態(未ログイン・パーミッションなし)なので、保護されたページに直接アクセスされても正常にリダイレクトできるはずです。
<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
をスコープに設定します。
export default {
...
auth0: {
domain: process.env.AUTH0_DOMAIN,
clientId: process.env.AUTH0_CLIENT_ID,
audience: process.env.AUTH0_AUDIENCE,
scope: process.env.AUTH0_SCOPE
}
}
AUTH0_DOMAIN = "example.auth0.com"
AUTH0_CLIENT_ID = "SAmpleCl1endId"
AUTH0_AUDIENCE = "https://example.com"
AUTH0_SCOPE = "offline_access"
また、メニューにリンクを登録します。
...
<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
ログインしましょう。


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

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

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

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


それではページにアクセスしてみましょう。左端のドロワーからProtected Contentをクリックして表示されれば正常です。
Auth0のコンソールからパーミッションを削除するとページが表示されなくなるはずです。
おわりに
ちなみに、nuxt公式のauthモジュールを使うとつらみを感じることなくauth0を組み込むことができます。ただ、authモジュールではトークンをlocalStorageに保存するのでイヤンと感じる方の役に立てれば幸いです。
そのうちちゃんとモジュール化してGithubにあげます。多分。