はじめに
現在のプロジェクトでは、Keycloakを基盤としたログインおよびアカウント関連画面の保守を担当しています。今回は、従業員アカウントの初回設定画面で、リロードすると通常のパスワード再設定画面に切り替わる問題を確認し、その原因を分析しました。
最初は一般的なReact SPAと同じように考え、画面を区別する値をURLのクエリパラメータに設定するだけでよいと思っていました。しかしコードを追ってみると、Keycloakifyではその方法だけでは不十分でした。原因は、Keycloakifyの画面遷移方式とKeycloakのラウンドトリップ処理にありました。
URLだけでは不十分だった画面状態の管理
最初に問題を見たときは、単純に考えていました。従業員アカウントの初回設定画面と通常のパスワード再設定画面を区別する必要があるため、URLにpage_hint=isTempのような値を付け、その値を基準に画面を分ければよいと思っていました。
一般的なReact SPAであれば、この方法はそれほど不自然ではありません。画面遷移は通常react-routerで処理し、URL pathやquery parameterが変わると、それに応じたコンポーネントをレンダリングできます。画面の状態もReact stateやquery parameterで管理することが多くあります。しかし、Keycloakifyの画面は少し異なる動作をします。Keycloakは、ログイン、パスワードの再設定、パスワードの変更などの画面をpageId単位で返します。
login.ftl
login-reset-password.ftl
login-update-password.ftl
Keycloakifyは、このpageIdに対応するReactコンポーネントをレンダリングします。つまり、画面はReactで作られていますが、大きな流れはKeycloakの認証フローの中で動きます。たとえばパスワード再設定画面へ移動するときも、単にReact内部のルーターで遷移するのではなく、Keycloakが提供したURLへページ全体を遷移させます。
const findPassword = useCallback(() => {
if (!("loginResetCredentialsUrl" in kcContext.url)) return;
window.location.href = kcContext.url.loginResetCredentialsUrl;
}, [kcContext.url]);
この方式では、画面が切り替わる際にページ全体が再ロードされるため、React stateにだけ保存された値は維持されません。一般的なSPAでは自然に維持されるような状態も、Keycloakifyでは画面遷移の過程で失われる可能性があります。
Keycloakのラウンドトリップと状態の消失
それならURLのクエリに値を入れればよさそうに思えますが、Keycloakifyではそれだけでも不十分です。理由を理解するには、まず「ラウンドトリップ」とは何かを確認する必要があります。
ここでいうラウンドトリップとは、リクエストがブラウザを離れてKeycloakサーバーまで送られ、新しい画面となって戻ってくる一連の往復を指します。一般的なSPAでは画面が切り替わっても、ブラウザは同じページにとどまり、JavaScriptが画面だけを差し替えます。一方、Keycloakの認証フローはそうではありません。ログインやパスワード再設定などのフローでは、フロントエンドが直接APIを呼び出すのではなく、Keycloakから提供されたaction URLに対してform POSTを行います。
<form method="post" action={kcContext.url.loginAction}>
このformがsubmitされると、流れはおおむね次のように進みます。
-
ブラウザが現在のReact画面を離れ、KeycloakサーバーへPOSTリクエストを送信します。
-
Keycloakがサーバー上で認証フローを処理します。
-
サーバーが処理結果に応じた次の画面(次のpageId)を新たに生成して返します。
-
ブラウザはそのレスポンスを受け取り、ページを最初から再びレンダリングします。
つまり、画面遷移の主導権はReactではなくKeycloakサーバー側にあり、その過程でページ全体が新しく描画されます。この一連の往復がラウンドトリップです。
問題は、このときブラウザのURLもKeycloakが再生成して返すという点です。そのため、前の画面で付けた?page_hint=isTempのようなquery parameterが次の画面までそのまま残るとは限りません。一方、React stateはページが再ロードされた瞬間に当然失われます。そのため、このプロジェクトではpage_hintをURLとsessionStorageの両方で管理していました。
let page = params.get("page_hint") || sessionStorage.getItem("page_hint");
同じ値を2か所から読み取る理由は、ラウンドトリップを考慮すると明確になります。
-
URL queryは現在の画面状態を示すのに適していますが、ラウンドトリップ中に消える可能性があります。
-
sessionStorageはリロードやラウンドトリップの後も残りますが、長く残りすぎると別のフローに影響を与えます。
つまり、URLだけでは不十分で、sessionStorageだけでも危険です。そのため、sessionStorageに値をバックアップしておき、レンダリング時に再びURLへ反映する構成が存在していました。
function syncPageHintToUrl(pageHint: string) {
const url = new URL(window.location.href);
url.searchParams.set("page_hint", pageHint);
window.history.replaceState(null, "", url.toString());
}
page_hintで詳細画面を区別する方式
実際のサービスでは、Keycloakの1つのpageIdの中でもpage_hintを使って複数の画面を表示し分ける必要がありました。パスワード再設定画面についても、通常のパスワード再設定画面と従業員アカウントの初回設定画面を区別する必要がありました。従業員アカウントの初回設定に入るときは、次のようにpage_hintをisTempとして保存した後、パスワード再設定フローへ移動していました。
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
遷移先のコンテナでは、この値を基準にどの画面を表示するかを決定していました。
return isTemp
? <ResetVerifyStaff kcContext={kcContext} />
: <FindPassword kcContext={kcContext} />;
リロード時に画面が切り替わった原因
問題は、従業員アカウントの初回設定画面でリロードしたときに発生しました。最初にアクセスしたときは従業員向け画面が正常に表示されましたが、リロード後には通常のパスワード再設定画面に切り替わりました。原因を追ってみると、同じコンテナ内に次のコードがありました。
useEffect(() => {
sessionStorage.removeItem("page_hint");
const url = new URL(window.location.href);
url.searchParams.delete("page_hint");
window.history.replaceState(null, "", url.toString());
}, []);
コンテナがマウントされた直後に、従業員向け画面を区別するpage_hintがsessionStorageとURLの両方から削除されていたため、リロード時に従業員向け画面であることを判定できなかったのです。
削除コードが入った理由を確認
すぐに削除しようとしましたが、認証・アカウント画面には複数のフローが絡んでいるため、まずコミット履歴を確認しました。この削除コードは、マイページのパスワード変更画面で発生するエラーを防ぐための防御コードでした。page_hint=isTempがsessionStorageに残り続けると、その後に別の経路からパスワード変更画面へ入った場合にも従業員向け画面だと誤判定される可能性があるため、画面に入った後で削除していました。
ここで問題の本質が明らかになりました。page_hint=isTempは、同時に2つの役割を担っていました。
-
従業員アカウントの初回設定フローを維持するための値
-
別の通常フローには残っていてはいけない値
一方では維持し、もう一方では削除する必要があるため、単純に「削除ロジックを削除」すると、別のフローに影響を与える可能性がありました。
改善方針
改めて遷移経路を確認すると、パスワード再設定画面へ入る主な入口では、すでに開始時点でpage_hintを明示的に設定していました。
// 일반 비밀번호 찾기
onClick={() => {
sessionStorage.setItem("page_hint", "");
links.findPassword();
}}
// 직원 계정 최초 설정
onClick={() => {
sessionStorage.setItem("page_hint", "isTemp");
links.findPassword();
}}
つまりLoginResetPasswordContainerでは、入口の時点ですでにフローが決まっているにもかかわらず、到着後にpage_hintを無条件で削除していたため、リロード時に画面状態が維持されていませんでした。LoginUpdatePasswordContainerはマイページのパスワード変更フローと接続されているため、既存の防御ロジックが引き続き必要でした。一方、LoginResetPasswordContainerでは入口でpage_hintを明示的に設定していたため、到着後に無条件で削除する必要はないと判断し、次のように修正しました。
-
LoginResetPasswordContainerのマウント時にpage_hintを削除するロジックを削除
-
LoginUpdatePasswordContainerの削除ロジックは維持
大きな変更ではありませんでしたが、安全に削除するために、Keycloakifyのルーティング構造、ラウンドトリップの流れ、既存の防御コードが入った理由を併せて確認する必要がありました。
まとめ
今回の問題は、画面を区別する値であるpage_hintがマウント直後に削除され、リロード時に従業員向け画面であることを判定できなかったことが原因でした。この作業を通じて、Keycloakifyを基盤とする画面では、一般的なReact SPAとは異なる方法で状態を管理する必要があると理解しました。Keycloakが画面遷移を主導し、form POSTの後にサーバーを経由して画面が再び返されるラウンドトリップがあるため、URL queryやReact stateだけで画面状態を安定して維持するのは困難です。
また、sessionStorageのように長く残るストレージを使用する場合は、状態が別のフローへ漏れ出さないよう注意する必要があります。今回のように、1つのpage_hintの値が「画面の維持」と「フローの区別」という2つの役割を同時に持つと、ある時点では維持し、別の時点では削除しなければならないという衝突が生じる可能性があります。
結果として今回の修正は数行を削除する作業でしたが、その数行を安全に削除するためには、まずKeycloakifyのルーティング構造と既存の状態管理の意図を理解する必要がありました。
Lynn