Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

アプリ側のパスワード最小文字数がIdentity Platformの下限より緩くて本番だけ落ちた

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

アプリ側の検証を通過した4〜5文字のパスワードも、実体を保管するIdentity Platformのemail/password認証には6文字未満として拒否され、その拒否をcatchしていなかったためエラーバウンダリまで吹き上がっていた。

結論

アプリ側のパスワード検証は「4文字以上」を許容していたが、パスワードの実体を保管しているIdentity Platformのemail/password認証は6文字未満をauth/invalid-passwordで拒否する。 system_adminが4〜5文字の学校共通パスワードを設定すると、アプリ側の検証は通過したうえで、IdP側の拒否がcatchされないままNext.jsのエラーバウンダリまで吹き上がり、設定操作自体が失敗していた。

症状

  • system_adminが学校の「教員共通パスワード」設定画面で4〜5文字のパスワードを入力すると、保存操作が失敗する
  • アプリ側のバリデーションはこの時点で通過している(下限が4文字だったため、フォーム上はエラーにならない)
  • 失敗の見え方は、通常のフォームの検証エラー表示ではなく、Next.jsのエラーバウンダリ(digest 912693075)に落ちる。想定外の例外として扱われ、何が悪かったのかが画面から分からない

原因

原因は2層になっていて、片方だけ直しても同種の事故を防ぎきれない。

層1: アプリ側の下限がIdPの下限より緩かった

アプリのポリシー関数validateTeacherPasswordPolicyは、当初MIN_TEACHER_PASSWORD_LENGTH = 4を下限としていた。「学校の運用負荷を最優先する」という判断のもと、4文字以上を許容する設計だった。

しかし、この共通パスワードの実体はIdentity Platformのemail/passwordアカウントのパスワードそのものである。Identity Platformのパスワードポリシーは最小長を6〜30文字の範囲でしか設定できず、6文字未満は原理的に許可されない。アプリ側の検証を通過した4〜5文字のパスワードは、アカウントを冪等に用意/更新するprovisionSharedTeacherAccountの内部でIdPのcreateUser/updateUserを呼んだ時点で、auth/invalid-passwordとして拒否される。アプリと、実際に値を保管する外部システムとで、検証の基準がずれていた。

層2: IdPの拒否をcatchしていなかった

setSchoolTeacherPasswordActionは、当初provisionSharedTeacherAccountの呼び出し結果をそのままawaitしているだけで、auth/invalid-passwordを専用に捕まえる分岐を持っていなかった。IdPが投げた例外はServer Actionの外側までそのまま伝播し、Next.jsのエラーバウンダリに落ちる。system_adminからすれば、フォームは一見正常に見えるまま「何かのエラーが起きた」としか分からない状態になる。

直し方

  • MIN_TEACHER_PASSWORD_LENGTHを4から6へ引き上げ、IdPの下限に整合させた。アプリ側の検証を通過すれば、少なくとも文字数だけを理由にIdPへ拒否されることはなくなる
  • それだけでは将来また基準がずれたときに同じ事故が再発するため、isPasswordRejectedError()を新設した。auth/invalid-password(6文字未満・非文字列)とauth/weak-password(IdPプロジェクトのパスワードポリシー違反全般)を「利用者が入力を直せばよいエラー」として分類し、setSchoolTeacherPasswordAction側でこれをcatchしてinvalid()のフォーム検証エラーへ整形する。エラーバウンダリまで吹き上げず、画面上の通常のエラーメッセージとして表示できるようにした
  • 一方で、権限エラーやインフラ起因の分類できない例外はこの分岐に含めず、そのまま再throwする。原因不明のエラーまで「入力ミス」として握りつぶすと、可観測性を失うほうが危険になるため
  • ADR-032やアクションのJSDocに残っていた「4文字」という記述も、6文字へ書き直した

再発防止

アプリ側のバリデーションだけを見て「入力条件は揃っている」と判断すると、値を最終的に受け取る外部システム側の制約とずれていても気づけない。今回は、パスワードの実体をIdentity Platformが保持しているにもかかわらず、アプリ側の下限は運用負荷という別の基準で決められていた。値を最終的に保存する先の制約に、アプリ側の検証を揃えておく必要がある。

もう一つは、検証の基準を揃え直しただけでは足りないという点。基準は将来また変わりうる(IdP側のパスワードポリシーが変更される、アプリ側の要件が緩められる、等)。外部システムからの拒否をcatchして利用者に伝わるメッセージへ変換する層を別に持たせておけば、アプリ側の検証が漏れたり緩んだりしても、最終的にエラーバウンダリまで落ちる事態そのものは避けられる。検証の一致と、拒否時の多層防御は、別々に効く対策として両方入れておく価値がある。

よくある質問

Q1なぜアプリ側の検証を通過したのに保存が失敗したのですか?

パスワードの実体を保管しているのはアプリのDBではなくIdentity Platformで、アプリ側の検証は事前チェックに過ぎなかったからです。アプリの下限は4文字、Identity Platform側の下限は6文字で、その間の4〜5文字はアプリの検証を通過したあとcreateUser/updateUserがauth/invalid-passwordで拒否していました。

Q2Identity Platformのパスワード最小長はどこまで下げられますか?

6文字が下限です。プロジェクトのパスワードポリシーとして設定できる最小長の範囲は6〜30文字で、6文字未満に設定することはできません。

Q3なぜこの拒否が画面のエラーメッセージではなくエラーバウンダリに落ちたのですか?

パスワード設定処理がIdP呼び出しの結果をそのままawaitするだけで、auth/invalid-passwordを専用にcatchしていなかったためです。Server Actionの外側まで例外が素通りし、Next.jsのエラーバウンダリまで届いていました。

Q4アプリ側の下限を6文字に揃えれば、この種の事故は再発しませんか?

下限を揃えるだけでは将来また同じ事故が起きえます。あわせて、IdP側からの拒否(auth/invalid-password・auth/weak-password)を利用者向けの検証エラーに変換して返すcatch層を用意し、権限やインフラが原因の予期しないエラーはそこでは握らず再throwするようにしています。

この記事の根拠

  • TypeScriptファイル 1〜14行目コミット fb01ce6
  • TypeScriptファイル 86〜97行目コミット fb01ce6
  • TypeScriptファイル 299〜334行目コミット fb01ce6
  • ドキュメントファイル 40〜40行目コミット 3086d02

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。