Nuxt.js(SSR)でAmplify Frameworkを使おうとした話


この記事はAWS Amplify Advent Calendar 2019の13日目(になるはずだった記事)です。
気づいたらクリスマスどころか正月まで終わっていた...(陳謝)。

概要

業務の中で、Nuxt.jsを使ってSSRをしつつAmplify Authモジュールを使った認証機能を実装するという
ケースがありました。そこで若干困ったことがあったので共有しようと思った次第。

Nuxt.jsとAmplify Framework

僕の稚拙な説明よりも分かりやすい記事がWEBにごろごろあると思いますが一応ざっと説明

Nuxt.js

Vue.jsを使ったフロントエンドの開発で利用できるフレームワーク。
Vueを使ったアプリ開発においてSSR(サーバサイドレンダリング)を手軽に実現することができます。
他にも色々と便利な機能はありますが今回は割愛。

Amplify Framework

AWSが提供しているフロントエンド向けのjavascriptライブラリ(iOS,Android向けもある)です。
機能ごとに色々とモジュールが分かれていますが今回はAWSの認証サービスである
Cognitoと連携してフロントエンドで認証機能を手軽に実装できるAmplify Authモジュールをメインとします。
Amplify Authモジュールは以下のような感じでフロントエンドからCognitoを利用するための便利な関数が生えているものになります。詳しい使い方はリファレンスを参照ください。

authSample.js
import { Auth } from 'aws-amplify'

// サインイン
await Auth.SignIn('username', 'password')

// サインイン中のユーザーの情報を取得
const user = await Auth.currentAuthenticatedUser()

// パスワード変更
await Auth.changePassword(user, 'oldPassword', 'newPassword');

// サインアウト
await Auth.signOut()

これらの他にもMFAやサインアップ時のユーザー検証などに対応した関数も用意されており
Cognitoを使った認証をフロントエンドで実装する際はほぼマストなモジュールとなっています。

困ったこと

Nuxtを使わないVue+AmplifyのSPAについては以前に開発した経験があったため
当初は「Nuxtといっても中身はただのVueのSPAなんやから前と同じ感じでやればエエか」と思っていたのですが
開発の過程でAmplify Authの実装における以下2つの要素が問題を起こすことに気付きました。

  1. Local Storageを利用している
  2. Amplifyが依存するamazon-cognito-identity-jsがブラウザランタイム固有のfetch APIを利用している

問題はどちらもNuxtによってSSRが行われる際に発生するものです。

1.Local Storageを利用している

Amplify Authはサインインしたユーザーの情報をブラウザのLocalStorageに保持するような作りとなっています。
実際Amplify Authを使ったアプリでサインインした後にChromeの開発者ツール⇒Applicationタブ⇒LocalStorageで確認してみるとCognitoIdentityServiceProvider.<CognitoのclientId>.<ユーザー名>.idTokenといったようなキーを持つ
データがいくつか登録されていることが分かるかと思います。
この挙動により、例えばサインインしたユーザーが一度ブラウザを閉じた後でも次に同じページにアクセスした際は
サインイン状態が保持されるといった機能を簡単に実現できるようになっています。

一方でSSRはクライアント(ブラウザ)ではなくサーバ上でVueのコンポーネントのレンダリング処理が走ります。
LocalStorageはブラウザのランタイム固有の機能であるためSSR時にLocalStorageを参照するような処理を書いていると意図した挙動にならないというわけです。
例えば以下のようにSSRによる初回レンダリング時にサインイン済みユーザーの情報を使ってなんかしたい、という場合を考えます。
Auth.currentAuthenticatedUserの処理の中でLocalStorageを参照していることで
エラーが発生し、意図した通りの挙動にはなりません。(サインイン済みの場合でもuserdataが取得できない)

index.vue
<template>
  <div class="container">
    <!-- サインインしているユーザーのidトークンを表示する -->
    <div>{{ userdata ? userdata.signInUserSession.idToken.jwtToken : '' }}</div>
  </div>
</template>

<script>
import { Auth } from 'aws-amplify'
export default {
  data() {
    return {
      userdata: null
    }
  },
  async asyncData() {

      // 既にサインイン済みの場合はSSR時にユーザーの情報を取得してdataに設定する
      const userdata = await Auth.currentAuthenticatedUser()

      return {
        userdata
      }
  }
}
</script>

2. Amplifyが依存するamazon-cognito-identity-jsがブラウザランタイム固有のfetch APIを利用している

これについてもほぼ1と同様で、SSRの際にブラウザのjavascriptランタイム固有のグローバル関数であるfetch
参照しにいっていることが原因でエラーとなっています。
fetch APIはサインイン処理でCognitoのAPIをコールするときなどに利用されています。参考

解決策

と呼んでよいのか怪しいところですが一応NuxtでもAmplify Authを動かす方法はあるため紹介していきます。

SSRをあきらめる

いきなり諦めてるやんけ!というツッコミが聞こえてきそうですがSSRはアプリケーションに
それなりの複雑さをもたらします。
SSRによる恩恵(初回レンダリングの速さなど)をあまり重要としないようなアプリの場合はいっそ諦めて
NuxtのSPAモードを使うことをオススメします。そうすればAmplify Frameworkを
なんの心配もなく利用することができるしね!

SSRはしたいけどAmplify Authの処理周りだけ妥協してクライアント側に寄せてもOK、という場合は
Nuxtのpluginを使うという手もあります。
例えば前述のように「初回レンダリング時にサインイン済みユーザーの情報を取得したい」ような場合は
asyncDataなどのサーバ側で処理が走るようなところではなく、クライアント側のみでの実行が補償されている
client modeのpluginを使うとよいのではないかと思います。

nuxt.config.js
export default {
  //mode:'client'を指定することでSSRではなくクライアント側の初期処理でのみ呼び出される
  plugins: ['~/plugins/amplify-init.js',mode: 'client' ]
}
amplify-init.js
import { Auth } from 'aws-amplify'

Auth.configure({
    //省略
})

export default ({ app, store }) => {

 // 既にサインイン済みの場合はSSR時にユーザーの情報を取得してdataに設定する
 const userdata = await Auth.currentAuthenticatedUser()

 //取得したuserdataはVuexのstoreに渡すなどして各コンポーネントから参照できるようにする
 store.dispatch('initUser',userdata)
}

LocalStorage,Fetch APIの代替となる実装を準備する

そうはいってもSSR時にAuthの処理も入れたいんや!というかたは
ブラウザランタイム固有のこれら2つを自前で用意することで一応SSRでもAuthの処理を動かすことができます。

LocalStorage

Amplify Auth側で既存の挙動(LocalStorageにユーザー情報を保存する)をカスタマイズする
方法を提供してくれています。以下の通り、configure関数のオプションにstorageプロパティを指定することで
デフォルトのstorageであるLocalStrageからカスタマイズしたstorageに差し替えることができます。

実行時のランタイムがクライアント/サーバ側のどちらかを判定しつつ
LocalStorageとCookieを使い分けることでどちらでAuthの処理が走っても問題が無いにすることができます。

amplify-init.js
import { Auth } from 'aws-amplify'

export default ({ app }) => {

  class CustomStorage {

    // setItemやgetItemなどLocalStorageで定義されているAPIに合わせてクラスを定義する
    static setItem(key, value) {

      if (process.server) {

        // NuxtにおけるCookieの操作には「cookie-universal-nuxt」モジュールを使うと便利
        app.$cookies.set(key, value)
      } else {
        app.$cookies.set(key, value)
        localStorage.setItem(key, value)
      }
    }

    static getItem(key) {

      let value

      if (process.server) {
        value = app.$cookies.get(key)

        if (typeof value === 'object') {

          // getItemの戻り値の方はLocalStorageの仕様に合わせてstringにする必要がある
          value = JSON.stringify(value)
        }

      } else {
        value = localStorage.getItem(key)
      }

      return value
    }

    static removeItem(key) {

      if (!process.server) {
        localStorage.removeItem(key)
      } else {
        app.$cookies.remove(key)
      }
    }

    static clear() {
      if (!process.server) {
        localStorage.clear()
      } else {
        app.$cookies.removeAll()
      }
    }
  }

  const config = {
    region: 'ap-northeast-1',
    userPoolId: 'ap-northeast-1_XXXXXXXX',
    userPoolWebClientId: 'XXXXXXXX'

    // 独自に定義したstorageクラスを指定する
    storage: CustomStorage
  }

  Auth.configure(config)
}

Fetch API

こちらはAmplify Authが依存するamazon-cognito-identity-jsで使われておりstorageのようにカスタマイズする方法が
ライブラリ側から提供されていません。
結構詰んでいる感はあるのですがamazon-cognito-identity-jsのソース(具体的にはClient.js)のfetch変数を参照している部分を
node.jsランタイムのfetch API実装であるnode-fetchに置き換えることで
クライアント/サーバどちらにも対応させることが可能です。

...がモンキーパッチ感満載の回避策のためあまりおススメはできません。
Auth.signInなどがSSR時に動作することは確認していますが適用して何かあった場合も責任は取れないので悪しからず...。

まとめ

もともとSSRでの利用を想定していないっぽいAmplify FrameworkのAuthモジュールを
がんばってNuxt.jsに適用してみようという話でした。

繰り返しになりますがどーしてもNuxtでSSRをしたい!というような場合でない限りは
おとなしくSPAモードを利用するのがよい、というのがやってみての感想です。パッとしないまとめですいません。
今回は僕がよく使うAuthモジュールのみについての書きましたがおそらく他のモジュール(Analyticsとか)も
似たような問題は起きそうな気がするのでその際の参考にもなれば幸いです。

Amplifyのissueを覗いてるとちょいちょいNuxtに関するものが上がっているようなので
もしからしたら近い将来、今回紹介したような苦肉の策を取らずともNuxt(SSR)でAmplifyを利用できるように
なるかもしれません。(AWSさんお願いします)

それでは皆さん2020年もよいAmplifyライフをお過ごしください!