🔖
Fetch APIで401が返ったときにトークンをいい感じに更新する
はじめに
以前、axios Interceptor を使用して401が生じた際にはトークンをリフレッシュさせる手法を記事にしました。
しかしaxiosでなくfetchで十分な時代になっており、余計な依存関係を含めないという意味でも置き換えたほうが良い、ということでだいぶ前に実装したもの概要をここに残しておきます。
memoize を使用する方法
まずはじめに、過去記事でもやっていた方法の踏襲です。memoize(旧名 mem)を利用して最初のリフレッシュが完了するまで他のリクエストは、その結果を待つということをさせていました。しかし、実は Promise キャッシュだけでやりたいことは実現できました。
なのでここは前記事を単純にfetchに置き換えた場合です。飛ばしていただいて大丈夫です。
import memoize from 'memoize';
export async function customFetchAuth(path: string, options: any) {
let token = getToken();
// トークンが既に期限切れなら、リクエストを送る前に更新を試みる
if (!isAccessTokenValid(token)) {
const success = await refresh();
if (!success) {
throw new Error('Unauthorized');
}
token = getToken(); // 更新後のトークンを取得
}
// リクエストヘッダーの設定など
const config = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
};
try {
const res = await fetch('url', config);
if (!res.ok) {
// 401 (Unauthorized) の場合、トークンをリフレッシュしてリトライ
if (res.status === 401) {
const success = await refresh();
if (success) {
// リフレッシュ成功時:自分自身を再度呼び出してリトライした結果を返す
return customFetchAuth(path, options);
} else {
throw new Error('Token refresh failed');
}
}
throw new Error(`Fetch failed: ${res.status}`);
}
return res;
} catch (error) {
/* エラー処理を適切に */
}
}
const refresh = async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
redirectToLogin(); // トークンが存在しない場合の処理
return false;
}
// メモイズされたリフレッシュ関数を実行
// 同時に複数のリクエストが 401 になっても、リフレッシュAPIは1回しか呼ばれない
const result = await memoizedRefreshToken();
if (!result.status) {
redirectToLogin();
return false;
}
return true;
};
const refreshTokenFn = async () => {
/* トークンをリフレッシュして再格納する */
};
// 30秒間は同じリフレッシュ処理の結果を再利用する(連続実行防止)
export const memoizedRefreshToken = memoize(refreshTokenFn, {
maxAge: 30000,
});
Promise キャッシュによる重複防止
status 401が返るときにトークンをリフレッシュし、そのリフレッシュを実行している間は他の fetch 実行を止めておく。リフレッシュが完了したら再開させる。という要件です。
memoize の利用よりも依存関係が減り、要件も満たしたうえで実装もシンプルになっています。
これは、Promise の、"一度 resolve すると、その後何度 .then() や await を呼んでも同じ結果を返す"という性質を利用しています。
また、memoizeで設定していた有効期限を、どの程度の長さにしたら良いかなど気にしなくてよいというのも良いところです。
// 実行中のリフレッシュ処理を保持する変数
let refreshTokenPromise: Promise<{ status: boolean }> | null = null;
export async function customFetchAuth(path: string, options: any) {
let token = getToken();
// トークンが既に期限切れなら、リクエストを送る前に更新を試みる
if (!isAccessTokenValid(token)) {
const success = await refresh();
if (!success) {
throw new Error('Unauthorized');
}
token = getToken(); // 更新後のトークンを取得
}
// リクエストヘッダーの設定など
const config = {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
};
try {
const res = await fetch('url', config);
if (!res.ok) {
// 401 (Unauthorized) の場合、トークンをリフレッシュしてリトライ
if (res.status === 401) {
const success = await refresh();
if (success) {
// リフレッシュ成功時:自分自身を再度呼び出してリトライした結果を返す
return customFetchAuth(path, options);
} else {
throw new Error('Token refresh failed');
}
}
throw new Error(`Fetch failed: ${res.status}`);
}
return res;
} catch (error) {
/* エラー処理を適切に */
}
}
const refresh = async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
redirectToLogin(); // トークンが存在しない場合の処理
return false;
}
// すでに実行中のリフレッシュ処理があれば、その Promise をそのまま返す
if (refreshTokenPromise) {
const result = await refreshTokenPromise;
return result.status;
}
// まだ実行中でなければ、新しいリフレッシュ処理を開始して変数に格納する
refreshTokenPromise = refreshTokenFn();
try {
const result = await refreshTokenPromise;
if (!result.status) {
redirectToLogin();
return false;
}
return true;
} finally {
// 成功・失敗に関わらず、処理が終わったら変数を空にする
// これにより、次回 401 が発生した際に再度リフレッシュが走るようになる
refreshTokenPromise = null;
}
};
const refreshTokenFn = async (): Promise<{ status: boolean }> => {
/* トークンをリフレッシュして再格納する */
};
Discussion
それだと複数タブの場合に対応できないので次の様なアプローチもあります。
指摘ありがとうございます。
実際、マルチタブ対応が必要かは、トークンを管理するバックエンド側(猶予期間の有無など)の仕様との兼ね合いになるか思います。私の場合は稀にに競合が起きても問題のない仕様でしたのでこれで十分ではあったのですが、必要な場合にはuhyoさんの記事はとても参考になりますね。
勿論 sessionStoraget で トークン管理してるとか タブ間で共有されないアプローチなら問題はありませんね。そういえば。