はじめに
AngularでJWT認証を実装する際、アクセストークンの有効期限切れ(401エラー)が発生したとき、自動でトークンをリフレッシュしてリクエストをリトライする処理が必要になります。
本記事では、その実装の中で特に重要な「トークンリフレッシュ中に別のリクエストが来た場合の待機処理」を、RxJSのBehaviorSubjectを使って実装する方法を紹介します。
川と水のイメージを使いながら、ObservableやBehaviorSubjectの概念も一緒に整理していきます。
対象読者:
- AngularでHTTPインターセプターを実装したい方
- RxJSのObservable・BehaviorSubjectの概念を理解したい方
問題
アクセストークンが切れたとき、複数のリクエストが同時に401エラーを返すことがあります。
リクエストA → 401 → refresh実行中...
リクエストB → 401 → refresh実行中... ← 同時にrefreshが走る
リクエストC → 401 → refresh実行中... ← 同時にrefreshが走る
この場合、refreshが何度も同時に実行されてしまうという問題が起きます。
サーバーによってはリフレッシュトークンを使い捨てにしているケースもあり、その場合はBとCのリクエストが失敗してログアウトされてしまいます。また使い捨てでなくとも、無駄なリクエストが増えてパフォーマンスが低下します。
理想の動きは以下です。
リクエストA → 401 → refresh実行
リクエストB → 401 → refreshの完了を待つ
リクエストC → 401 → refreshの完了を待つ
↓
refresh完了 → A、B、C全て新トークンでリトライ ✅
解決方法
全体のコード
import {
HttpErrorResponse,
HttpEvent,
HttpHandlerFn,
HttpRequest,
} from '@angular/common/http';
import { BehaviorSubject, catchError, filter, Observable, switchMap, take, throwError } from 'rxjs';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
const NOT_APPLICABLE_REDIRECT = ['/auth/refresh', '/auth/login', '/auth/logout'];
let isRefreshing = false;
const refreshToken$ = new BehaviorSubject<string | null>(null);
export function authInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn,
): Observable<HttpEvent<unknown>> {
const auth = inject(AuthService);
const router = inject(Router);
// リダイレクト対象外URLはスキップ
if (NOT_APPLICABLE_REDIRECT.some((x) => req.url.includes(x))) {
return next(req);
}
// アクセストークンをヘッダーに付与
const accessToken = auth.getAccessToken();
const reqWithToken = accessToken
? req.clone({
headers: req.headers.set('Authorization', `Bearer ${accessToken}`),
})
: req;
return next(reqWithToken).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// リフレッシュ中でない場合 → refreshを実行
if (!isRefreshing) {
isRefreshing = true;
refreshToken$.next(null); // 古いトークンをリセット
return auth.refresh().pipe(
switchMap((res) => {
isRefreshing = false;
localStorage.setItem('accessToken', res.accessToken);
refreshToken$.next(res.accessToken); // 待機中のリクエストに新トークンを流す
const newReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${res.accessToken}`),
});
return next(newReq);
}),
catchError(() => {
isRefreshing = false;
router.navigate(['/login']);
return throwError(() => error);
}),
);
}
// リフレッシュ中の場合 → 完了を待つ
return refreshToken$.pipe(
filter((token) => token !== null), // 新トークンが来るまで待機
take(1), // 1回受け取ったら購読終了
switchMap((token) => {
const newReq = req.clone({
headers: req.headers.set('Authorization', `Bearer ${token}`),
});
return next(newReq);
}),
);
}
return throwError(() => error);
}),
);
}
重要な概念の整理
ObservableはHTTPの非同期処理を管理する川
Observableはデータが流れてくる川のようなイメージです。
川(Observable)= データが流れてくる仕組み
購読(subscribe)= 川に耳を立てて、データが来たら受け取る
next(req) を呼ぶことで、以下の2つが同時に行われます。
① HTTPリクエストが送信される
② レスポンスが流れてくるObservable(川)が作られる
Observableはリクエストごとに独立して作られます。
next(reqA) → リクエストAのレスポンスが流れる川
next(reqB) → リクエストBのレスポンスが流れる川
next(reqC) → リクエストCのレスポンスが流れる川
BehaviorSubjectとは
BehaviorSubject は 最新の値を記憶できるObservable です。
const refreshToken$ = new BehaviorSubject<string | null>(null);
通常のObservableと違い、購読を開始した瞬間に最新の値を即座に流してくれます。
購読開始
↓
BehaviorSubjectが最新値を即座に流す
↓
filterで値を判定
また、外から next() でデータを流せるのも特徴です。
refreshToken$.next(null); // nullを流す(リセット)
refreshToken$.next(res.accessToken); // 新トークンを流す
待機処理の仕組み
待機リクエストはここで聞き耳を立てています。
return refreshToken$.pipe(
filter((token) => token !== null), // ← ここで詰まっている(待機)
take(1),
switchMap((token) => {
return next(newReq);
}),
);
filter がダムの役割を果たしています。
川(refreshToken$)
↓
filterというダム → nullは通さない、詰まっている(待機)
↓(新トークンが来たら放流)
take(1) → 1回受け取ったら耳を塞ぐ
↓
switchMap → 新トークンでリトライ
なぜnullでリセットするのか
BehaviorSubject は購読開始時に最新値を即座に流します。
nullでリセットしないと、古いトークンが残っていて待機リクエストがそれを受け取ってしまいます。
// nullでリセットしない場合
refreshToken$の最新値 = 古いトークン
↓
待機リクエストが購読開始
↓
古いトークンを即座に受け取ってしまう ❌
// nullでリセットした場合
refreshToken$の最新値 = null
↓
待機リクエストが購読開始
↓
nullはfilterで弾かれる → 新トークンが来るまで待機 ✅
take(1)が必要な理由
refreshToken$ はアプリが生きている間ずっと流れ続ける川です。
take(1) がないと、将来また別のトークンが流れてきたときに再び反応してしまいます。
take(1)なし
→ 新トークン1を受け取る → リトライ ✅
→ 新トークン2が流れてくる → またリトライ ❌
take(1)あり
→ 新トークン1を受け取る → 耳を塞ぐ → リトライ ✅
→ 新トークン2が流れてきても聞こえない ✅
全体の流れまとめ
リクエストA → 401
↓
isRefreshing=true、refreshToken$.next(null)
↓
refresh実行中...
リクエストB → 401
↓
isRefreshing=true → refreshToken$に耳を立てて待機
リクエストC → 401
↓
isRefreshing=true → refreshToken$に耳を立てて待機
↓
refresh完了 → refreshToken$.next(newToken)
↓
B、C が新トークンを受け取り → take(1)で耳を塞ぐ → リトライ ✅
待機リクエストが複数いても、共有しているのはrefreshToken$という川だけで、それぞれが独立して動いているのがポイントです。
リダイレクト対象外URLのスキップ
refreshエンドポイント自体もインターセプターを通るため、スキップしないと無限ループになります。
const NOT_APPLICABLE_REDIRECT = ['/auth/refresh', '/auth/login', '/auth/logout'];
if (NOT_APPLICABLE_REDIRECT.some((x) => req.url.includes(x))) {
return next(req); // インターセプターをスキップ
}
refreshが401を返す
↓
スキップなし → インターセプターが横取り → またrefresh → ∞ループ ❌
スキップあり → catchErrorに正しく届く → ログイン画面へ ✅
おわりに
今回はAngularのHTTPインターセプターで、BehaviorSubjectを使ったトークンリフレッシュの待機処理を実装しました。
学んだことをまとめると:
- Observable = データが流れてくる川。リクエストごとに独立して作られる
- BehaviorSubject = 最新値を記憶できる川。外からnext()でデータを流せる
- filter() = ダムの役割。条件を満たす値が来るまで処理を止める(待機)
- take(1) = 1回受け取ったら耳を塞ぐ(購読終了)
- switchMap() = ObservableをフラットにするMap
川と耳のイメージで捉えると、RxJSのストリームの動きが直感的に理解しやすくなります。
参考
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp