OpenID Connect の認可コードフローを、実装しながら理解する
OpenID Connect の仕様書を通読しても、実際にどのリクエストがどの順で飛ぶのかは掴みにくい。認可コードフローを、ブラウザ・クライアント・認可サーバーの3者のあいだで交わされるやり取りとして順に追いながら整理する。
なぜ認可コードを挟むのか
トークンを直接ブラウザに返してしまえば手順は短くなる。それでも一度「認可コード」という短命の引換券を経由するのは、アクセストークンをフロントチャネル、つまり URL やブラウザ履歴に載せないためだ。認可コードは一度きり・短時間しか使えないため、仮に漏れてもクライアント認証やコード検証子がなければトークンには交換できない。
この「危険なチャネルには短命の引換券だけを流し、価値のあるトークンはバックチャネルで受け取る」という分離が、認可コードフローの中心的な設計判断になっている。
認可リクエストのパラメータ
最初のリクエストは、ブラウザを認可サーバーへリダイレクトさせるだけの単純な GET だ。ただしここに載せるパラメータが、後続すべての安全性を決める。
GET /authorize? response_type=code &client_id=s6BhdRkqt3 &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb &scope=openid%20profile%20email &state=af0ifjsldkj &nonce=n-0S6_WzA2Mj &code_challenge=E9Melhoa2Ow... &code_challenge_method=S256
よく混同されるのが state と nonce の役割だ。state はリダイレクト先に戻ってきたリクエストが自分の始めたものかを確認するためのもので、CSRF を防ぐ。nonce は ID トークンに焼き込まれ、そのトークンが今回の認証に対して発行されたものかを検証するために使う。守っている対象が違う。
PKCE は当初モバイルアプリ向けの拡張だったが、現在はクライアント種別を問わず適用が推奨されている。認可コードの横取りに対する防御を、クライアント認証とは独立して持てるのが大きい。
トークンエンドポイントでの交換
認可コードを受け取ったら、サーバー側から POST でトークンエンドポイントに送る。ここで code_verifier を提示し、認可リクエスト時に送った code_challenge との対応を証明する。
grant_type=authorization_code code=SplxlOBeZQQYbYS6WxSbIA redirect_uri=https://app.example.com/cb client_id=s6BhdRkqt3 code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
返ってくるのはアクセストークン、ID トークン、必要に応じてリフレッシュトークン。ID トークンは JWT なので、署名・iss・aud・exp・nonce を必ず自分で検証する。ライブラリ任せにする場合でも、どの検証が有効になっているかは一度確認しておきたい。
実装時につまずいた点
- redirect_uri は完全一致で登録する。末尾スラッシュの有無だけでエラーになり、メッセージからは原因が読み取りにくい。
- ID トークンの aud に複数の値が入るケースを想定していないと、フェデレーション構成に広げた時点で検証が落ちる。
- code_verifier をセッションに保存し忘れると、複数タブから同時にログインした際にだけ再現する不具合になる。
まとめ
認可コードフローは、一見遠回りに見える手順のひとつひとつに理由がある。どのチャネルに何を流すかという観点で読み直すと、仕様の意図が繋がって見えてくる。