やりたかったこと
- SCR, SSR で Cognito が発行したアクセストークンの期限が切れていた場合、新しいアクセストークンへ更新したい。
事の発端
Cognito で認証機能を作った後、動作確認をしている間に気づいたのですが、1時間でログイン状態が切れてしまいます。これは Cognito のアクセストークンの期限が1時間と決まっているようで、調べてもアクセストークンの有効期限を変更する方法が見つかりませんでした。
そこで、リフレッシュトークンを利用して新しいアクセストークンへ更新するアプローチへ切り替えたのでした。
aws-amplify が推奨するアクセストークンの更新方法
※正しくは aws-amplify が推奨していると思われるアクセストークンの更新方法です。ドキュメントに明確に recommend という単語はありませんでした。あくまでこのドキュメントを読んだ私の認識です。
sdk は aws-amplify を利用しているので このドキュメント を参考にしました。
そこにある 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 を更新するとの事です。
そして さらにその下のドキュメント には太字で以下のようにあります。
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
にトークンなどの保存先を指定できるオプションが存在するのでそれを利用します。
ドキュメントには以下のようなサンプルが載っています。
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
というオプションが用意されています。
Auth.configure({
cookieStorage: {
domain: '.your.domain',
path: '/',
expires: 365,
secure: true
}
});
初期化処理のソースを見た所、domain
に関しては必須パラメータで設定しないとエラーになります。 path
, expires
, secure
はオプショナルでそれぞれのデフォルト値は上で設定したものと同じです。
※ここ を見る限り storage
と cookieStorage
はどちらか1つしか設定できません。両方設定した場合は、 storage
が優先されるようです。
ですので、今回は cookieStorage
を利用しました。
ユーザーを登録し Auth.signIn(email, password)
をコールしたところ無事トークンを Cookie に保存してくれました。
# 保存される Cookie のキー名は [プレフィックス + accessToken や refreshToken] となっているのですが、このプレフィックスが長いせいで全体が長くなります。命名規則はこちら。
トークンを Cookie に保存するところまでできました。しかし、この後さらなる壁が...。
さらなる壁
上の図をご覧いただいて気づいた方もいらっしゃると思いますが。保存された Cookie の総量をご覧ください。
1120 + 105 + 1073 + 1907 + 397 + 105 + 33 = 4740
合計で 4740 bytes です。
Cookie の上限はブラウザによって異なるようですが、ブラウザの Cookie をエミュレートするサイトには以下のような記載がありました。
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 というライブラリを利用していて、 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 に保存することが可能です。
こんなイメージです。
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()
currentSession() の中から読んでいる currentUserPoolUser() の中にそれっぽいコメントがあります。セッションが切れていたら更新するというコメントに見えます。
AuthClass#currentUserPoolUser()
// 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 を参照しています。
const sessionData = {
IdToken: idToken,
AccessToken: accessToken,
RefreshToken: refreshToken,
ClockDrift: clockDrift,
};
const cachedSession = new CognitoUserSession(sessionData);
どうやらコンストラクタで accessToken, IdToken がない場合は、 Exception を throw するようです。
CognitoUserSession#constructor
if (AccessToken == null || IdToken == null) {
throw new Error('Id token and Access Token must be present.');
}
さらにその下で refreshToken をチェックしています。
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.