Nuxt Universal モードで Cognito のアクセストークンを更新できなかった話

やりたかったこと
-----
- SCR, SSR で Cognito が発行したアクセストークンの期限が切れていた場合、新しいアクセストークンへ更新したい。

事の発端
-----
Cognito で認証機能を作った後、動作確認をしている間に気づいたのですが、1時間でログイン状態が切れてしまいます。これは Cognito のアクセストークンの期限が1時間と決まっているようで、調べてもアクセストークンの有効期限を変更する方法が見つかりませんでした。

そこで、リフレッシュトークンを利用して新しいアクセストークンへ更新するアプローチへ切り替えたのでした。

aws-amplify が推奨するアクセストークンの更新方法
---

※正しくは aws-amplify が推奨している**と思われる**アクセストークンの更新方法です。ドキュメントに明確に recommend という単語はありませんでした。あくまでこのドキュメントを読んだ私の認識です。

sdk は aws-amplify を利用しているので [このドキュメント](https://aws-amplify.github.io/docs/js/authentication#retrieve-current-session) を参考にしました。

そこにある `Auth.currentSession()` というメソッドの説明文には以下のような一文があります。
>This method will automatically refresh the accessToken and idToken if tokens are expired and a valid refreshToken presented. So you can use this method to refresh the session if needed.

つまり、このメソッドをコールすると、リフレッシュトークンが正しければ accessToken, idToken を更新するとの事です。

そして [さらにその下のドキュメント](https://aws-amplify.github.io/docs/js/authentication#managing-security-tokens) には**太字**で以下のようにあります。

>When using Authentication with AWS Amplify, you don’t need to refresh Amazon Cognito tokens manually. The tokens are automatically refreshed by the library when necessary.

「こちらでいい感じにやっておくので、手動で更新はいりませんよ」とのこと。頼もしい限りです。

aws-amplify の問題
----

一見していい感じに見えますが、続きのドキュメント見ると大きな問題がありました。

>Security Tokens like IdToken or AccessToken are stored in localStorage for the browser and in AsyncStorage for React Native. If you want to store those tokens in a more secure place or you are using Amplify in server side, then you can provide your own storage object to store those tokens.

アクセストークンやリフレッシュトークンなどのトークンはデフォルトでローカルストレージに保存されます。

クライアントサイドレンダリング(以下CSR)であれば問題ありませんが、サーバーサイドレンダリング(以下SSR)の場合 `window` オブジェクトを参照できないので自動更新もできないということになります。

トークンの保存先を変更する
-----

`Auth.configure` にトークンなどの保存先を指定できるオプションが存在するのでそれを利用します。

ドキュメントには以下のようなサンプルが載っています。

```javascript

class MyStorage {
    // the promise returned from sync function
    static syncPromise = null;
    // set item with the key
    static setItem(key: string, value: string): string;
    // get item with the key
    static getItem(key: string): string;
    // remove item with the key
    static removeItem(key: string): void;
    // clear out the storage
    static clear(): void;
    // If the storage operations are async(i.e AsyncStorage)
    // Then you need to sync those items into the memory in this method
    static sync(): Promise<void> {
        if (!MyStorage.syncPromise) {
            MyStorage.syncPromise = new Promise((res, rej) => {});
        }
        return MyStorage.syncPromise;
    }
}

// tell Auth to use your storage object
Auth.configure({
    storage: MyStorage
});
```

CSR, SSR 共に利用可能なリソースは Cookie しかない見つからなかったのでここで保存先を Cookie に変更します。

CookieClass のようなクラス作成して `setItem` など必要なメソッドを実装して行くのもいいですが、さらに調べたところ `cookieStorage` というオプションが用意されています。

```javascript
Auth.configure({
    cookieStorage: {
      domain: '.your.domain',
      path: '/',
      expires: 365,
      secure: true
  }
});
```

[初期化処理のソース](https://github.com/aws-amplify/amplify-js/blob/master/packages/amazon-cognito-identity-js/src/CookieStorage.js#L15)を見た所、`domain` に関しては必須パラメータで設定しないとエラーになります。 `path`, `expires`, `secure` はオプショナルでそれぞれのデフォルト値は上で設定したものと同じです。


※[ここ](https://github.com/aws-amplify/amplify-js/blob/90d342c5a6a27556d57326c18ca4f5f68319bf89/packages/auth/src/Auth.ts#L148) を見る限り `storage` と `cookieStorage` はどちらか1つしか設定できません。両方設定した場合は、 `storage` が優先されるようです。

ですので、今回は `cookieStorage` を利用しました。

ユーザーを登録し `Auth.signIn(email, password)` をコールしたところ無事トークンを Cookie に保存してくれました。

<img width="972" alt="スクリーンショット 2019-08-15 18.28.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/49585/6852a508-d224-bd8e-3378-560a54719bdc.png">

＃ 保存される Cookie のキー名は [プレフィックス + accessToken や refreshToken] となっているのですが、このプレフィックスが長いせいで全体が長くなります。命名規則は[こちら](https://github.com/aws-amplify/amplify-js/blob/b997327d43417a5fbca663d2a58e299340e74bea/packages/amazon-cognito-identity-js/src/CognitoUser.js#L1163)。

トークンを Cookie に保存するところまでできました。しかし、この後さらなる壁が...。

さらなる壁
----

上の図をご覧いただいて気づいた方もいらっしゃると思いますが。保存された Cookie の総量をご覧ください。

1120 + 105 + 1073 + 1907 + 397 + 105 + 33 = 4740

合計で **4740 bytes** です。

Cookie の上限はブラウザによって異なるようですが、[ブラウザの Cookie をエミュレートするサイト](http://browsercookielimits.squawky.net/)には以下のような記載がありました。

>If you want to support most browsers, then don't exceed 50 cookies per domain, and don't exceed 4093** bytes per domain (i.e. total size of all cookies <= 4093 bytes).


**ほとんどのブラウザーをサポートする場合はドメインごとに50 Cookieを超えないようにしドメインごとに4093バイトを超えないようにします** と書いてあります。

なので今回の場合は後者の制限に引っかかってしまいます。引っかかるとどうなるのか、つまり、上限を越えるとどういう挙動になるかというのもブラウザごとに異なるようです。

Cookie の保存上限を超えた場合はブラウザごとの挙動はこちらのブログが大変参考になりました。
http://please-sleep.cou929.nu/study-of-cookie-restrictions.html

1度に削除される件数や削除する Cookie の選定方法などは異なるにせよ、 **上限を超えた Cookie は消える** という事です。

こういったブラウザの挙動を考慮して利用するライブラリ側で Exception を throw する場合もあります。というか、今回これに気づいたのもライブラリの Exception からでした。
[js-cookie](https://github.com/js-cookie/js-cookie) というライブラリを利用していて、 `nuxtServerInit` で Cookie をパースしようとした時に `RangeError: str is too large (CookieParser.maxLength=4096)` というエラーが出て Cookie を取得できませんでした。


粘る(れなかった)
----
どうにかサイズを減らすことはできないかと思い、トークンの更新に必要なデータだけを　Cookie に保存することでこの問題を回避できないかと考えました。

アプリとして必要なのはリソースサーバーへアクセスするのに必要なアクセストークンとその更新に必要なリフレッシュトークンだけなので、この2つを Cookie に保存してそれ以外は別領域へ保存するというアプローチです。 

`setItem(key, value)`、`getItem(key, value)` を実装した HybridStorage クラスを作成すれば、key を見て accessToken, refreshToken だけを Cookie に保存することが可能です。

こんなイメージです。

```javascript

class HybridStorage {
  static setItem(key: string, value: string): string {
    const splittedKey = key.split('.') // prefix の命名規則があるのでそれを利用した方が綺麗に書けそう
    if (splittedKey[splittedKey.length - 1] === "accessToken") {
      // todo: Cookie に保存
    }
  }
}

```

と、実装する前にまずは実現可能なのか sdk のコードを眺めて見ました。

トークンの更新処理を追う
----

ドキュメントにアクセストークンを更新する旨が記載されているメソッド currentSession() を起点とします。
[AuthClass#currentSession()](https://github.com/aws-amplify/amplify-js/blob/90d342c5a6a27556d57326c18ca4f5f68319bf89/packages/auth/src/Auth.ts#L1072)

currentSession() の中から読んでいる currentUserPoolUser() の中にそれっぽいコメントがあります。セッションが切れていたら更新するというコメントに見えます。

[AuthClass#currentUserPoolUser()](https://github.com/aws-amplify/amplify-js/blob/90d342c5a6a27556d57326c18ca4f5f68319bf89/packages/auth/src/Auth.ts#L967)

```AuthClass.js
                // refresh the session if the session expired.
                user.getSession((err, session) => {
                    if (err) {
                        logger.debug('Failed to get the user session', err);
                        rej(err);
                        return;
                    }

```

次に getSession 情報の取得です。用意した storage を参照しています。

[CognitoUser#getSession()](https://github.com/aws-amplify/amplify-js/blob/b997327d43417a5fbca663d2a58e299340e74bea/packages/amazon-cognito-identity-js/src/CognitoUser.js#L1154)

```CognitoUser.js
      const sessionData = {
        IdToken: idToken,
        AccessToken: accessToken,
        RefreshToken: refreshToken,
        ClockDrift: clockDrift,
      };
      const cachedSession = new CognitoUserSession(sessionData);
```

どうやらコンストラクタで **accessToken, IdToken** がない場合は、 Exception を throw するようです。

[CognitoUserSession#constructor](https://github.com/aws-amplify/amplify-js/blob/b997327d43417a5fbca663d2a58e299340e74bea/packages/amazon-cognito-identity-js/src/CognitoUserSession.js#L29)

```CognitoUserSession.js

    if (AccessToken == null || IdToken == null) {
      throw new Error('Id token and Access Token must be present.');
    }
```

さらに[その下](https://github.com/aws-amplify/amplify-js/blob/b997327d43417a5fbca663d2a58e299340e74bea/packages/amazon-cognito-identity-js/src/CognitoUser.js#L1193)で **refreshToken** をチェックしています。

```CognitoUser.js
      if (!refreshToken.getToken()) {
        return callback(new Error('Cannot retrieve a new session. Please authenticate.'), null);
      }
```

つまり、 ここまで調べた限り、トークンの自動更新では accessToken, IdToken, refreshToken が必要そうです。

合わせると 1120 + 1073 + 1907 = **4100 (bytes)** となります。

結果、全く削減できませんでした。

最後に
----
しばらく試行錯誤しましたが、結果的にうまくいきませんでした。以下のどちらかが見つかれば解決しそうですが、難しそうです。

- Cookie 以外の CSR, SSR 共に利用可能なリソースを見つける
- Cognito(amplify) が保存するトークンのサイズが小さくなる

余談
---

もしかして Chrome DevTools の表示がおかしくて、この 4740 は byte じゃないかもしれないと淡い期待を抱きましたが、しっかり byte でした。
https://developers.google.com/web/tools/chrome-devtools/storage/cookies
>Size. The cookie's size, in bytes.