Nuxt.js × Firebase な環境で Auth0 の埋め込みログイン (Embedded Login) を使う

Nuxt.js Advent Calendar 2019 の 11 日目の記事です。

Nuxt.js を Firebase で動かしつつ、ユーザー管理できる仕組みにする場合は Firebase Authentication を使うのが一般的です。
ですが、ベンダーロックインを避けたり、Facebook, Twitter, Google アカウント以外のソーシャルログインを手軽に使いたい等の理由から、現在のプロジェクトで Auth0 を採用しました。また、ログイン画面設計の柔軟性等の観点から Universal Login (ユニバーサルログイン) ではなく、 Lock を利用しない Embedded Login (埋め込みログイン) 方式にて認証しています。

Universal Login を利用するほうが手軽に Auth0 を利用できるのですが、ネイティブアプリ対応も予定されていたこともあり、知見を貯めるという意味で Embedded Login 方式ですすめることにしたのですが、これがまた大変でして…この記事を以て供養したいと思います。

# 免責事項
コードがぼかしてあるのでそのままでは動きません。概念が伝われば幸いです。

# Auth0 とは
IDaaS の一つです。
https://auth0.com/jp/

# 前提条件
- Nuxt.js 2.8.1 (プロジェクト開始時点の Nuxt の最新版でした)
- Node 8.16.0 (Cloud Functions の node のサポートバージョンに合わせています)
- Vue Property Decorator 8.2.x
- TypeScript 3.5.3

# やりたいこと
- Auth0 の Embedded Login でログインしたい
- Auth0 にログインした時に Firebase にもログインしたい

# Embedded Login とは
Auth0 を利用する際のログイン方法の一つです。通常は Universal Login というログイン画面も Auth0 に提供してもらう方法があるのですが、Embedded Login では Lock と呼ばれる Auth0 のログイン画面を自信のドメインで提供できるライブラリを使用するか、 SDK を使用して自前でログイン画面を用意します。また。資格情報を引き回す仕組みも自前で用意します。
https://auth0.com/docs/login/embedded

# 手始め
実は、[Nuxt.js x Auth0 の公式サンプルコードが動かない](https://github.com/nuxt/example-auth0) ので、ほかのサンプルコードやリファレンスを参考にゼロベースで組み上げる必要がありました。 ([これ去年から動かなかったのか…](https://www.slideshare.net/TakahiroTsuchiya/spa-86441099) )
また、 [auth0-spa-js](https://github.com/auth0/auth0-spa-js) という、いかにもお誂え向きと思えるライブラリがありますが、こちらは Embedded Login に対応していないようです。
今回は Firebase にもログインしたいという要件もあったので、[こちらの Angular のサンプルコード](https://auth0.com/blog/how-to-authenticate-firebase-and-angular-with-auth0-part-1/) を参考にしています。

# 認証用サービスクラスの作成

都合上コードの全てはお見せできませんが、参考になりそうな部分だけ抜粋して掲載してます。
ほぼ [Angular のサンプルコード](https://github.com/auth0-blog/angular-firebase/blob/master/src/app/auth/auth.service.ts) と同じです。

変えた点としては 
- 格納場所、ファイル名は `utils/authService.ts` とした
- メアド、パスワードでログインできるようにした
- Angular 独自の機能は省いた（ただし RxJS は一部そのまま使ってます。このへんがつらい）
- IE11 でトークン取得処理がうまく動かなかったので、エンドポイントをコールする際にダミーパラメーターを付けた

```utils/authService.ts
// ※抜粋コードです

import auth0, { WebAuth } from 'auth0-js';
import { EventEmitter } from 'events';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { from, of, timer, Subscription } from 'rxjs';

const localStorageLoggedIn = 'loggedIn';
const localStorageRedirect = 'auth_redirect';
const localStorageExpire = 'expires_at';

class AuthService {
  public idToken: string;
  public profile: any;
  public emitter: EventEmitter;

  private webAuth: auth0.WebAuth;
  private connectionName: string;
  private accessToken: string;

  private firebaseSub: Subscription | null;
  private refreshFirebaseSub: Subscription | null;

  constructor() {
    this.emitter = new EventEmitter();

    // Create Auth0 web auth instance
    const auth0Config = (process.env.auth0 as any) || {};

    this.webAuth = new auth0.WebAuth({
      domain: auth0Config.domain,
      redirectUri: `${window.location.origin}/callback`,
      clientID: auth0Config.clientId,
      responseType: 'token id_token',
      audience: auth0Config.audience,
      scope: 'openid profile email'
    });

    // Auth0 Connection Name
    this.connectionName = auth0Config.connection;

    // Auth0 Access Token
    this.accessToken = '';

    // Subscribe to the Firebase token stream
    this.firebaseSub = null;

    // Subscribe to Firebase renewal timer stream
    this.refreshFirebaseSub = null;

    // 略
  }

  // User Login
  public login(email: string, password: string): Promise<any> {
    return new Promise(resolve => {
      this.webAuth.login(
        {
          realm: this.connectionName,
          email,
          password,
          redirectUri: `${window.location.origin}/callback`
        },
        err => {
          if (err) {
            console.error(err);
            resolve({ errorMessage: err.description });
          } else {
            resolve({ success: true });
          }
        }
      );
    });
  }

  // Handles the callback request from Auth0
  public handleAuthentication(): Promise<any> {
    return new Promise((resolve, reject) => {
      this.webAuth.parseHash((err, authResult) => {
        if (err) {
          reject(err);
        } else if (authResult) {
          this.localLogin(authResult);
          resolve(authResult.idToken || '');
        } else {
          resolve();
        }
      });
    });
  }

  // Local login after Auth0 authentication
  private localLogin(authResult: auth0.Auth0DecodedHash): void {
    this.idToken = authResult.idToken || '';
    this.profile = authResult.idTokenPayload || {};

    // Convert the JWT expiry time from seconds to milliseconds
    const tokenExpiry = this.profile.exp * 1000;
    localStorage.setItem(localStorageExpire, tokenExpiry.toString());

    window.location.hash = '';

    // Store access token
    this.accessToken = authResult.accessToken || '';

    localStorage.setItem(localStorageLoggedIn, 'true');

    // Get Firebase token
    this.getFirebaseToken();
  }

// Get firebase token and start firebase authentication
  private getFirebaseToken(): void {
    // Prompt for login if no access token
    if (!this.accessToken) {
      this.emitloginEvent();
      return;
    }
    const getToken$ = () => {
      const url = `${process.env.apiRoot}auth-authApi/firebase`;
      const options = {
        method: 'get',
        url,
        headers: { Authorization: `Bearer ${this.accessToken}` },
        // NOTE: IE11キャッシュ問題に対応するため、paramsへタイムスタンプ付与
        params: {
          _: Date.now()
        }
      } as AxiosRequestConfig;
      return from(
        axios(options)
          .then(res => res)
          .catch(err => {
            console.error(`An error occurred fetching Firebase token: ${err.message}`);
          })
      );
    };
    this.firebaseSub = getToken$().subscribe(
      res => this.firebaseAuth(res as AxiosResponse),
      err => console.error(`An error occurred fetching Firebase token: ${err.message}`)
    );
  }

  // Emit Event for finished login
  private emitloginEvent(): void {
    this.emitter.emit(this.loginEventName, {
      loggedIn: this.loggedInFirebase,
      state: {}
    });
  }

  // Firebase authentication
  private firebaseAuth(tokenObj: AxiosResponse): void {
    const auth = firebase.auth();
    auth
      .signInWithCustomToken(tokenObj.data.firebaseToken)
      .then(() => {
        this.loggedInFirebase = true;
        // Schedule token renewal
        this.scheduleFirebaseRenewal(); // https://github.com/auth0-blog/angular-firebase/blob/master/src/app/auth/auth.service.ts#L123
        this.loading = false;
      })
      .catch(() => {
        this.loggedInFirebase = false;
      })
      .finally(() => {
        this.emitloginEvent();
      });
  }
}
```

# サービスクラスのインスタンスを Nuxt に注入

`plugins/auth.ts` を作成し、上記のサービスクラスのインスタンスを `this.$auth` に突っ込んで、どこでも呼び出せるようにしました。

```plugins/auth.ts
import AuthService from '~/utils/authService';
import { Plugin } from '@nuxt/types';

declare module 'vue/types/vue' {
  interface Vue {
    $auth: AuthService;
  }
}

const auth0Plugin: Plugin = (ctx, inject) => {
  const auth = new AuthService();
  inject('auth', auth);
}

export default auth0Plugin;
```

あとは `nuxt.config.ts` にもプラグインを読み込ませてやるようにします

```nuxt.config.ts
  plugins: [
    '@/plugins/auth'
  ],
```

# ログイン処理
あとは普通にコンポーネントの方でログインしてあげます。

```login.vue
<template>
  <div>
    <div>
      <label>メルアド</label>
      <input type="text" v-model="email" />
    </div>
    <div>
      <label>パスワード</label>
      <input type="password" v-model="password" />
    </div>
    <div>
      {{ errorMessage }}
    </div>
    <div>
      <button @click="execLogin">ログイン</button>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class Login extends Vue {
  // data
  email = '';
  password = '';
  errorMessage = '';

  // methods
  async execLogin(): Promise<void> {
    const result = await this.$auth.login(this.mail, this.password, '/top');
    if (!result.success) {
      if (result.errorMessage === 'Wrong email or password.') {
        this.errorMessage = 'メールアドレスまたはパスワードが間違っています。';
      } else if (!result.errorMessage.indexOf('Your account has been blocked after multiple consecutive login attempts.')) {
        this.errorMessage = 'このアカウントはロックされています。';
      } else {
        this.errorMessage = result.errorMessage;
      }
    }
  }
}
</script>
```

```callback.vue
<template>
  <div>Loading...</div>
</template>

<script type="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class Callback extends Vue {
  // lifecycle
  public created(): void {
    this.$auth.emitter.addListener(this.$auth.loginEventName, this.handleLoginEvent);

    try {
      this.$auth.handleAuthentication();
    } catch (error) {
      console.error(error);
      alert('認証に失敗しました');
      this.$router.push('/');
    }
  }

  // methods
  private handleLoginEvent(data) {
    if (!data.error) {
      this.$router.push('/memberOnly');
    }
  }
}
</script>
```

※あくまでNuxt.js側だけの実装です。Allow Calback URL に `/callback` `/memberOnly` を追加する等 Auth0 側での設定も必要です。

# いい点
ログイン画面のデザインに制約を受けません。

# 悪い点
Auth0のログイン画面が許せない（カスタマイズできる範囲では満足できない）という場合以外に Web で Embedded Login を使うメリットはないです。
今回の抜粋コードも愚直な感じな上に Angular と Vue のキメラ感がキモく、うまい具合にリファクタしたいとずっと考えています。
もっとキレイにできたら全容を GitHub で公開にしたいと思っていますので、今しばらくお待ちいただければと思ってます…が、いつになるかは未定です:bow:

Auth0 自体、 Universal Login の利用を推奨しています。
https://auth0.com/docs/guides/login/universal-vs-embedded

Nuxt で Auth0 を使う場合は auth0-spa-js で Universal Login を使ったほうが非常に楽です。

# おまけ: auth0-spa-js を使う場合

Vueの公式サンプルコードがあるので、そちらを参考にして欲しいのですが、極限までシンプルにするとここまでお気軽に使えます。もちろん Embedded Login ではなく Universal Login になります。もちろん Firebase へのログインも加えるともうちょっと複雑になってきます。

```plugins/auth.ts
import { Plugin } from '@nuxt/types';
import createAuth0Client from '@auth0/auth0-spa-js';
import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client';

let auth0!: Auth0Client;
const domain = process.env.domain;
const clientId = process.env.clientId;
const redirectUrl = `${window.location.origin}/callback`;

class AuthService {

  auth0!: Auth0Client;

  constructor() {}

  public async init(): Promise<void> {
    const res = await createAuth0Client({
      domain,
      client_id: clientId,
      redirect_uri: redirectUrl
    });
    this.auth0 = res;
  }

  public async login(): Promise<void> {
    await this.auth0.loginWithPopup();
  }

  public async logout(): Promise<void> {
    await this.auth0.logout();
  }

  public async getUser(): Promise<any> {
    const user = await this.auth0.getUser();
    return user;
  }

  public async isLogin(): Promise<boolean> {
    const result = await this.auth0.isAuthenticated();
    return result;
  }
}

declare module 'vue/types/vue' {
  interface Vue {
    $auth: AuthService;
  }
}

let authInstance!: AuthService

const auth0Plugin: Plugin = async (_, inject) => {
  authInstance = new AuthService();
  await authInstance.init();
  inject('auth', authInstance);
}

export default auth0Plugin;
```


```pages/login.vue
<template>
  <div>
    <div v-if="!auth0Pending">
      <button @click="auth0Login" v-if="!isLogin">
        Login
      </button>
      <button @click="auth0Logout" v-if="isLogin">
        logout
      </button>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';

@Component
export default class PagesLogin extends Vue {
  // data
  auth0Pending = true;
  isLogin = false;

  // lifecycle
  async created() {
    await this.checkLogin();
  }

  // methods
  async checkLogin() {
    const isAuthed = await this.$auth.isLogin();

    const user = await this.$auth.getUser();
    if (user) {
      this.isLogin = true;
    }
    this.auth0Pending = false;
  }

  async auth0Login() {
    await this.$auth.login();
    await this.checkLogin();
  }

  async auth0Logout() {
    await this.$auth.logout();
  }
}
</script>
```

状態管理については Auth0 Web SDK のほうが使い勝手がいいかもしれません。

#  まとめ

Nuxt.js でサービスを作る際の認証システムに Auth0 を使う際は Universal Login, Embedded Login ともに利用可能ですが、ログイン画面にこだわりたい場合以外は Universal Login を使いましょうという話でした。
IDaaS が盛り上がってきているのを感じているので、採用時の比較検証の一助になれば幸いです。それでは！