🍪

やっぱりCookieとかSameSiteとかCrossOriginとかが分からんから記事を書くことにした。

2023/11/19に公開

ごあいさつ

岩手一の独立系SIerで働く「せいや」と申します。「せいちゃん」と呼んでくれると喜びます。
今回の記事はたぶん2本目です。
自分でSPAをフルスタックで組もうとしたときに、「なんか知らんけどCookieが飛ばない」という状態に嵌ってしまいました。
一週間ほど格闘したので残しておこうと思いました。もう格闘したくないですしね。

おことわり

私と読者で扱っている言語やフレームワークと異なることがある可能性があることから、実装に関する詳細な部分は記述しておりません。予めご了承ください。

ことの経緯

ポートフォリオとして「俺のCMS」というヘッドレスCMSを制作しています。Zennのような技術ブログや一枚物のポートフォリオサイトを作りたかったためです。

システムを構築していく途中、認証認可について未経験で無知だったので、実際に手を動かしながらどんな実装になるか確認することにしました。
作業中は何度もCORSに絡むエラーと遭遇して苦しい思いをした。

この先は遭遇したエラーと対処についてを述べていきます。

遭遇したエラーとその対処

No 'Access-Control-Allow-Origin' header

このエラーは、フロントエンドアプリのオリジンと、非同期通信のAPIエンドポイントのオリジンが異なる場合に出るエラーです。このようなオリジンが異なる通信をCORSと表現します。

Access-Control-Allow-OriginはCORSの際に、バックエンドがCORSを許可しているオリジンを、ブラウザに教えるためのヘッダです。このヘッダがないと、ブラウザは「バックエンドにCORSを拒否された」と解釈して404 Not Foundのエラーとなります。

対応

バックエンドで対応する必要があります。CORSリクエストに対するレスポンスのヘッダにAccess-Control-Allow-Originを付与してください。

このとき、開発段階でも適切なオリジンを設定したほうが、後からセキュリティについて考えるときラクになります。

Response to preflight request doesn't pass access control check

プリフライトリクエストについては調べてみてください。難しいです。

原因は、Access-Control-Allow-Originリクエストヘッダに付与していたためです。
正しくはレスポンスヘッダに付与します。

では、なぜプリフライトリクエストが飛んでしまったのでしょうか?
プリフライトリクエストに関する言葉で、単純リクエストというものがあります。プリフライトリクエストは、単純リクエストと判断されるための条件を満たしていない場合に飛びます。
その条件のひとつに、「いくつかの指定されたヘッダのみ記述されている」というものがあります。
指定されたヘッダの中にはAccess-Control-Allow-Originはありませんので、プリフライトリクエストが飛んでいたということになります。

対応

リクエストヘッダに付与していたAccess-Control-Allow-Originを取り除くだけです。

認証後にアプリからアクセスしたときCookieを送信してない

認証にはOpenID Connectを使用しています。OpenID Connectのことを以降はOIDCと書きます。
OIDCの認証認可フローについては割愛させていただきます。

Axiosでは、WithCredentials: trueを付けてリクエストしています。

認証後、例えばAPIを叩いてユーザー情報を取得するとします。この時、APIサーバーにはセッションIDが入ったCookieを送信しますが、なぜか送られていませんでした。

OIDCのフローでは何回かリダイレクトをして、フロントエンドアプリを取得したり、APIサーバーにアクセスしたり、OIDCの認証サーバーにアクセスしたりします。この時、フロントエンドのURLをhttp://localhost:5173とし、APIサーバーのURLをhttp://127.0.0.1:8080としていました。

Cookieの属性にはSame-Siteという属性があります。
今回の場合はSame-Site属性は空欄でした。空欄の場合はLaxとなります。Laxの場合は画面遷移を伴うリクエストでのみCookieは送信されます。Axiosの場合は送信されません。

対応

http://localhost:5173と、http://127.0.0.1:8080は、Cross-OriginでありCross-Siteです。この場合でCookieを送りたいときは、CookieにSecure属性を付ける必要があります。HTTPSであれば、送信するというものです。開発環境なのにHTTPSにするのは、話が違いますね。。。

そもそも、Cross-Siteになっているのがおかしなところで、127.0.0.1はlocalhostなのでどちらかに統一すれば解決する話なのです。
ということで、ホスト名をlocalhostに統一しました。そうしたら解決しました。

CORSには関係ない(と思う)話

AxiosのmaxRedirectsはブラウザでは効かない

未ログイン状態のときに、ログイン必須のエンドポイントにアクセスしたときはログイン画面にリダイレクトさせたいですよね。バックエンドの実装では、ログイン画面にリダイレクトするようにレスポンスを生成しています。

フロントエンドでは、認証状態を気にせず判定せず、Axiosでエンドポイントを叩きます。
未認証状態であればリダイレクトレスポンスになると想定して、ステータスが302ならLocationヘッダの内容のURLに画面遷移するように記述しました。(then句の話)

さて、実行すると画面遷移せず非同期リダイレクトしていました。こちらとしては、非同期でリダイレクトするのではなく画面が変わってほしいです。(画面遷移をともなう動きを、top-level navigationといいます)
そこで、AxiosのリクエストのオプションにmaxRedirects: 0を指定しました。
けれども、動きは変わりませんでした。

Axiosのドキュメント読んだ限りでは動くと思っていました。でも動かないので、もう一度よく読んでみました。すると分かったのが、maxRedirectsオプションはNode.js限定だったということです。
Axiosはバックエンドでも使えるらしいです。ということは、Axiosはラッパーライブラリであり処理の実装はブラウザやNode.jsに委譲しているということになります。
ブラウザでは、maxRedirectsオプションは無視されます。また非同期でリダイレクトするのはXHRの仕様なので、どうもできないらしいです。
詳しく知りたい人はxhrとnode.jsのhttpモジュールの違いを調べてみてください。

対応

バックエンドでは未認証状態の時は403ステータスを返して、フロントでは403を受け取ったらログイン画面に遷移するように記述しました。403はリクエストによるエラーなのでcatch句で捉えることができます。つまり、catch句に画面遷移するように書きました。

Discussion