BFF でトークンをブラウザの外に置く
目標
1つのPodの中に発行者(8312)・下流API(8311)・BFF(8310)を立て、access tokenがブラウザーから見える場所に一度も現れないこと、そしてBFFを経由した呼び出しだけが下流で通ることを、実際のHTTPリクエストで証明します。
なぜ重要なのか
SPAがトークンを直接持つと、そのページで動くスクリプトが1つ汚染されるだけでトークンが外に出てしまい、攻撃者は有効期限までそのトークンを使えます。RFC 10017(OAuth 2.0 for Browser-Based Applications)6.1節のBFFは、トークンをサーバー側のセッションだけに置き、ブラウザーにはHttpOnlyのセッションCookieだけを渡します。BFFは機密クライアントとして、コードをトークンに交換し、下流へ渡すときにCookieを外してBearerトークンを付けます。この構造は、1か所でもゆるいと崩れます。セッション確認のレスポンスにトークンを載せる、下流がトークンなしのリクエストを受け付ける、ログアウトでトークンを取り消さない、のどれでもBFFは名前だけになります。このラボは、その3つの隙間をそれぞれリクエストで確認させます。
ステップ
/root/st/bff/app.py1つで、発行者(127.0.0.1:8312)、下流API(127.0.0.1:8311)、BFF(127.0.0.1:8310)を起動してください。3つともGET /healthは200と{"ok":true}を返します。GET /login→発行者のPOST /authorize→BFFの/callbackの順でログインし、コールバックが返したSet-Cookieの1行を/root/st/bff/cookie.txtに保存してください。Cookieは__Host-Http-bffという名前で、HttpOnly、Secure、SameSite=Strict、Path=/を持ちます。- Redisの
bff:sess:<세션ID>(プレースホルダーはセッションIDです)にaccess_tokenがあるかどうかと、その値がブラウザーが受け取ったレスポンスのヘッダー・本文のどこにもないかどうかを、/root/st/bff/exposure.txtに書いてください。 - bobでログインしたCookieで
GET /bff/api/meを呼び、BFFがBearerを付けて下流で200とsub=bobを受け取ること、下流にCookieが渡らないことを/root/st/bff/proxy.txtに書いてください。 - 下流の
/meを、トークンなし、セッションCookieのみ、でたらめなBearerで直接呼び、すべて401とWWW-Authenticate: Bearerになるかどうかを/root/st/bff/direct.txtに書いてください。 POST /bff/api/notesが、正しいX-CSRF-Tokenのときだけ201になり、ないか間違っていると403になるかどうかを/root/st/bff/csrf.txtに書いてください。curl -X POSTでログアウトした後、BFFのセッションが消え、古いaccess tokenが下流で401になるかどうかを/root/st/bff/logout.txtに書いてください。/root/st/bff/e2e.shで全体の流れを試し、/root/st/bff/e2e.outに6行を残してください。
参考
- サーバーは
setsid nohup python3 /root/st/bff/app.py > /root/st/bff/app.log 2>&1 &で起動します。コードを直した場合は、pkill -f /root/st/bff/app.pyで停止してから、もう一度起動します。 - ラボのPodにはpython-multipartがないため、フォーム本文は
urllib.parse.parse_qsで直接読みます。 - ラボでは流れを短くするために、PKCEと発行者のログイン画面を省略しています。実務で使う発行者であれば、その設定から確認してください。
- よくある失敗1: セッション確認のレスポンスやJSが読めるCookieで、トークンをもう一度返してしまうケースです。トークンをブラウザーから取り除いた意味がなくなります。
- よくある失敗2: ログアウトでBFFのセッションだけを消すケースです。漏れたことのないトークンでも、発行者側では有効期限まで生きています。
発行者・下流API・BFFを起動する
/root/st/bff/app.py1つで、発行者(127.0.0.1:8312)、下流API(127.0.0.1:8311)、BFF(127.0.0.1:8310)を起動してください。3つともGET /healthは200と{"ok":true}を返します。
1つのプロセスの中でThreadingHTTPServerを3つスレッドで動かすと、起動と停止が一度に済みます。サーバーはバックグラウンドで起動し、3つのポートがすべて応答するまでポーリングしてください。
ブラウザーが受け取るのはセッションCookie1つだけ
GET /login→発行者のPOST /authorize(フォームuser=alice&pw=wonderland)→BFFの/callbackの順でログインし、コールバックが返したSet-Cookieの1行を/root/st/bff/cookie.txtに保存してください。Cookie名は__Host-Http-bffで、HttpOnly、Secure、SameSite=Strict、Path=/を持ち、Domainはありません。
curl -w '%{redirect_url}'で302のLocationを受け取り、次のリクエストに渡すと、ブラウザーのように流れをたどれます。発行者にアカウントを送るリクエストはPOSTです。RFC 10017 6.1.3.2がHttpOnlyとSecureをMUSTとしています。
トークンはBFFのセッションにだけある
ログイン後、Redisのbff:sess:<세션ID>(JSON。プレースホルダーはセッションIDです)にaccess_tokenがあり、その値がコールバックのレスポンスとGET /bff/sessionのレスポンスのヘッダー・本文のどこにもないかどうかを突き合わせ、/root/st/bff/exposure.txtにtoken_in_store=yes、token_in_body=no、token_in_cookie=noと書いてください。GET /bff/sessionはauth・user・csrfだけを返します。
セッションIDはCookie jarから、本物のトークンはredis-cli GETで取り出します。その文字列をgrep -Fでレスポンスのファイル群から探してみてください。セッション確認のレスポンスに利便性のためトークンを載せた瞬間、BFFは名前だけになります。
BFFがBearerを付けて下流を呼ぶ
bob/builderでログインしたCookieでGET /bff/api/meを呼ぶと、BFFがCookieを外してAuthorization: Bearer <토큰>(プレースホルダーはトークンです)を付け、下流のGET /meへ渡して、200と{"sub":"bob"}を受け取ります。下流のGET /inspectは、cookie_forwardedでCookieが渡ってきたかどうかを教えます。結果を/root/st/bff/proxy.txtにme=200、sub=bob、cookie_forwarded=Falseと書いてください。
BFFは/bff/api/ の後ろのパスを下流へ移し、リクエストヘッダーを丸ごとコピーせずにAuthorizationを新しく作ってください。下流がトークンなしのリクエストを401で防いでいれば、BFF経由の200は、BFFがトークンを付けた証拠になります。
下流を直接呼ぶと401
BFFを飛ばして下流のGET http://127.0.0.1:8311/meを、トークンなし、BFFのセッションCookieのみ、でたらめなBearer値で、それぞれ直接呼び、すべて401になりWWW-Authenticate: Bearerが付くかどうかを/root/st/bff/direct.txtにno_token=401、cookie_only=401、bogus_bearer=401、www_authenticate=Bearerと書いてください。トークンなしのPOST /notesも401である必要があります。
下流はCookieを見ず、Authorizationヘッダーだけを見ます。トークンが生きているかは、発行者のintrospectionに問い合わせて判断してください。RFC 6750 3節が、401のレスポンスに付けるヘッダーを定めています。
BFFの書き込みはCSRFトークンで守る
GET /bff/sessionのcsrfの値をX-CSRF-Tokenヘッダーで送ったときだけ、POST /bff/api/notes(フォームtext=...)が下流へ渡って201になり、ヘッダーがないか間違っていると403になって下流に記録されないかどうかを、/root/st/bff/csrf.txtにno_header=403、wrong=403、right=201と書いてください。
ブラウザーはCookieを自動で載せるので、Cookieだけでは偽造リクエストと区別できません。セッションにCSRFトークンを一緒に入れておき、hmac.compare_digestで比較してください。拒否は下流を呼ぶ前に行う必要があります。
ログアウトでセッションとトークンを一緒に終わらせる
ログイン後、curl -X POSTでPOST /logout(CookieとX-CSRF-Tokenを含む)を呼び、同じCookieのGET /bff/sessionがauthのFalseになること、Redisのbff:sess:<세션ID>(プレースホルダーはセッションIDです)が消えること、ログアウト前に取り出しておいたaccess tokenで下流の/meを直接呼ぶと401になることを、/root/st/bff/logout.txtにsession_auth=False、store_key_gone=yes、old_token_direct=401と書いてください。
セッションキーだけを消すと、発行者側のトークンは有効期限まで生きています。ログアウトでBFFが、発行者の取り消しエンドポイント(RFC 7009)を先に呼んでください。-X POSTを外すと、curlはGETを送ります。
全体の流れを一度に証明する
/root/st/bff/e2e.shでログインからログアウトまでを試し、/root/st/bff/e2e.outにcookie_httponly=yes、token_exposed=no、via_bff=200、direct_no_token=401、csrf_missing=403、after_logout_token=401の6行を残してください。採点ツールはe2e.shをもう一度実行し、同じ結果になるかも確認します。
前のステップを1つのスクリプトにつなげます。スクリプトは自分が作った一時ファイルを消し、結果を標準出力だけに出せば、再実行しても安全です。