【Next.js/Rails】Next.js、RailsでCookieによるログイン認証を行う時にハマったこと

## はじめに
フロントエンドにNext.js、バックエンドにRailsを使用した構成で、Cookieを使った認証時に発生した問題とその解決方法をメモとしてまとめました！

## 結論
結論を先に言うと
Next.jsのApp Routerを使用した場合、サーバーサイド(Rails)へのリクエストはNode.jsから送信されるため、クライアント側で保持されているCookieが正しく含まれないことがあります
これを解決するためには、cookies()関数など使用してサーバーサイドでCookieを取得し、リクエストヘッダーに設定する必要がありました

## 問題
- フロントエンドでログイン後、データを取得しようとすると、認証エラー（401 Unauthorized）が返却される
- Postmanでは正常に動作する
- RSpecでの認証に関するテストはパスしている

### サーバー側の処理
**関係箇所のみ抜粋**（Deviseなどのgemは使用していません）

```ruby
class SessionsController < ApplicationController
  skip_before_action :authenticate_user!, only: %i[create]

  def create
    user = User.find_by(account_name: session_params[:account_name])
    if user&.authenticate(session_params[:password])
      session[:user_id] = user.id
      head :ok
    else
      render json: { message: 'アカウント名とパスワードの組み合わせが不正です' }, status: :unauthorized
    end
  end

  def destroy
    reset_session
    render json: { message: 'ログアウトしました' }, status: :ok
  end

  private

  def session_params
    params.require(:session).permit(:account_name, :password)
  end
end
```
このコードでは、アカウント名とパスワードを受け取り、認証に成功すればセッションに`user_id`を保存し、Cookieを返すようになっています

```ruby
class ApplicationController < ActionController::API
  include ActionController::Cookies
  before_action :authenticate_user!

  private

  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def authenticate_user!
    render_unauthorized_error('ログインしてください') unless current_user
  end
end
```
共通の前処理として`:authenticate_user!`を設定し、`current_user`メソッドで認証確認を行なっています！

### 原因調査
現状、ログイン後にメモの一覧取得APIを叩くと、`401 Unauthorized`エラーが返ってくることが分かっています
まずは、サーバー側のログを見てみます！

#### サーバー側のログ確認
```ruby
Started GET “/memos” for XXX.XXX.XX.X at 2024-11-02 15:47:39 +0900
Processing by MemosController#index as */*
  Parameters: {“memo”=>{}}
  [1m[36mUser Load (44.0ms)[0m  [1m[34mSELECT `users`.* FROM `users` WHERE `users`.`id` IS NULL LIMIT 1[0m
  ↳ app/controllers/application_controller.rb:9:in `current_user’
Filter chain halted as :authenticate_user! rendered or redirected
Completed 401 Unauthorized in 104ms (Views: 3.8ms | ActiveRecord: 50.7ms (1 query, 0 cached) | GC: 0.0ms)
```
ユーザーIDが`NULL`となっているため、認証エラーが発生しているみたいです
フロント側から渡ってきたCookieが空である可能性がありそうです

#### Cookieの確認
`current_user`メソッドの前にリクエストヘッダーのCookieをログに出力して確認してみます

```ruby
def authenticate_user!
  logger.debug "Cookie Header: #{request.headers['Cookie']}"
  render_unauthorized_error('ログインしてください') unless current_user
end
```

**結果**:
```
Cookie Header:
```
リクエストヘッダーのCookieはやっぱり空でした
ここから2つの可能性が考えられそうです
- ログイン時にサーバー側できちんとCookieを返していない
- ブラウザ側でCookieが保持されていない

#### サーバーでのCookieの返却を確認
ログイン時にCookieが返されているかを確認するため、またログを出力するようにします！
愚直にベタベタ書いてきます笑

```ruby
def create
  user = User.find_by(account_name: session_params[:account_name])
  if user&.authenticate(session_params[:password])
    session[:user_id] = user.id
    logger.debug "ログイン時：バックエンドのクッキー"
    cookies.each { |key, value| logger.debug "Cookie #{key}: #{value}" }
    logger.debug "ログイン時：バックエンドのセッション"
    session.each { |key, value| logger.debug "Session #{key}: #{value}" }
    head :ok
  else
    render json: { message: 'アカウント名とパスワードの組み合わせが不正です' }, status: :unauthorized
  end
end
```

**結果**:
```
ログイン時：バックエンドのクッキー
Cookie_Header: _progaku_archive_session=...
ログイン時：バックエンドのセッション
Session session_id: ...
Session user_id: 3
```
サーバー側で正しくCookieとセッションが生成されていることは確認できました！


#### ブラウザにCookieが保持されているか？
ブラウザの検証ツールで確認したところ、ログイン直後はCookieが保持されていることも確認できました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3301763/9c23302b-1f10-6596-a3a1-5958c1df776a.png)

**注意**
**SameSiteをnoneにしてクロスドメインでのリクエスト許可しているのにもかかわらずSecureをTrueにしていませんが開発環境だからです。本番ではSecureをTrueにしてHttps通信時のみCookieを使用するように制限してください！**


#### どういうこと！？
この辺りから、思考が停止し始めました笑
サーバー側でCookie、Sessionも問題なく生成され、ブラウザ側にも保持されている
にも関わらずリクエスト時には渡ってこない
何故？？


#### 念の為Fetch APIの設定確認
```typescript
const response = await fetch(`${process.env.NEXT_PUBLIC_API}/memos`, {
  method: 'GET',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
  },
});
```

`credentials: 'include'`　を設定してCookieを受け付け、リクエストに含める設定にはなっています。

https://developer.mozilla.org/ja/docs/Web/API/Request/credentials#:~:text=include,%E5%90%AB%E3%82%81%E3%81%BE%E3%81%99%E3%80%82

ちなみに `include` はクロスオリジン間でもCookieなどの資格情報を含めるオプションです。
　
#### Postmanの動作確認
```
Cookie Header: _progaku_archive_session=eZLeQLjfX2urWEA2mdVw3%2Bh...(以下省略)
```
やはりPostmanでは問題なくCookieが渡ってきてmemoも取得できてしまいました

#### リロードでCookieが消える問題
検証ツールで色々試していると、ログイン後、リロードするとCookieが消えてしまうことに気づきました
Cookieの期限が適切にヘッダーに含まれてない？それともCORS周りの設定が起因している？
わからなかったので、とりあえずHTTPの全てのHeaderをログに出力して
ログイン時と、メモ取得時とで比較して確認することにしました。

#### ログイン時とメモ取得時のHeaderログ比較
各処理内に下記のコードを追加
```Ruby
      request.headers.each do |key, value|
        # ヘッダーのキーが HTTP_ で始まるものだけを出力する
        if key.start_with?('HTTP_') || key == 'CONTENT_TYPE' || key == 'CONTENT_LENGTH'
          logger.debug "#{key}: #{value}"
        end
      end
```

#### ログイン時
```
=== Request Headers ===
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_LENGTH: 79
HTTP_SEC_CH_UA_PLATFORM: "macOS"
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
HTTP_SEC_CH_UA: "Chromium";v="130", "Google Chrome";v="130", "Not?A_Brand";v="99"
CONTENT_TYPE: application/json
HTTP_SEC_CH_UA_MOBILE: ?0
HTTP_ACCEPT: */*
HTTP_ORIGIN: http://localhost:3000
HTTP_SEC_FETCH_SITE: cross-site
HTTP_SEC_FETCH_MODE: cors
HTTP_SEC_FETCH_DEST: empty
HTTP_REFERER: http://localhost:3000/
HTTP_ACCEPT_ENCODING: gzip, deflate, br, zstd
HTTP_ACCEPT_LANGUAGE: ja,en-US;q=0.9,en;q=0.8
HTTP_COOKIE: _progaku_archive_session=96jfLN3gVfkjxnpaC%2FpJGNqYL4CvOz8v5CcfL6CP67St4Pa8DoYumNXxt43%2B%2B%2FnNNCvmQt%2BYtUEEOBXb3YH0JIJ36Z51JVvkymsokFvGTaWJjtLmqr%2F%2F3dhTqNmXrWW2evweRmd1HwrcrsdT2Cp%2B6tgpPqnoiLt2hv2FRWBsYPdImqtwOW0PG5Mf4T9j8IFSo7SGVGGwMyJLGGxak89uyg%3D%3D--DAQiHiIvY%2FOLbWuw--swdD3Hnyfd04UtqhM%2BkFlQ%3D%3D
HTTP_VERSION: HTTP/1.1
========================
```

#### メモ取得時
```
=== Request Headers ===
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_TYPE: application/json
HTTP_ACCEPT: */*
HTTP_ACCEPT_LANGUAGE: *
HTTP_SEC_FETCH_MODE: cors
HTTP_USER_AGENT: node
HTTP_ACCEPT_ENCODING: br, gzip, deflate
HTTP_VERSION: HTTP/1.1
========================
```
ここであることに気づきました

#### User-Agentが違う
User-Agentは簡単にいうとクライアントの情報になります

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/User-Agent


ログイン時は、ブラウザからリクエストが送られています
```
HTTP_USER_AGENT: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36
```

でもメモ取得時は
```
HTTP_USER_AGENT: node
```
nodeとなっていました

#### User-Agentのnodeって？
Node.jsのことのようです
何故とこうなるのか調べると
Next.jsでApp Routerを使用するとサーバーサイドでレンダリングが行われ、Node.jsがリクエストを送信される仕様になっているようです

https://nextjs.org/docs/app/building-your-application/rendering/server-components#using-server-components-in-nextjs:~:text=on%20the%20server.-,Using%20Server%20Components%20in%20Next.js,opt%20into%20using%20Client%20Components%20when%20needed%2C%20see%20Client%20Components.,-How%20are%20Server

https://qiita.com/akira__0924/items/65ea51f62d9b99825388#page-router:~:text=%E5%BE%8C%E8%BF%B0%E3%81%97%E3%81%BE%E3%81%99%EF%BC%89-,App%20Router,%E3%83%87%E3%83%95%E3%82%A9%E3%83%AB%E3%83%88%E3%81%8C%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%EF%BC%89%EF%BC%88%E6%AC%A1%E3%81%AE%E3%82%BB%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%A7%E8%AA%AC%E6%98%8E%E3%81%97%E3%81%BE%E3%81%99,-%EF%BC%89

### 解決方法
```
credentials: 'include',
```
この設定だけでは、サーバーサイド(Node)側ではブラウザのCookieを参照できないため、Cookieを含めてのリクエストが正常に行われていなかったということだと考えられます（間違っていたらすみません）

そのためCookieのヘッダーを明示的に指定してあげる必要があるようです。

NextでブラウザのCookieを取得する関数は下記に載っていたので

https://nextjs.org/docs/app/api-reference/functions/cookies

https://techjourney-code.com/cookie-nextjs/

このようにCookie関数で取得して、設定するようにしました。
```typescript
export async function getMemos() {
	const cookieHeader = cookies().toString();
	const response = await fetch(`${process.env.NEXT_PUBLIC_API}/memos`, {
		method: 'GET',
		credentials: 'include',
		headers: {
			'Content-Type': 'application/json',
			Cookie: cookieHeader,
		},
	});
```

これで再度実行すると

```ruby
Started GET "/memos" for XXX.XXX.XX.X at 2024-11-03 16:42:28 +0900
Processing by MemosController#index as */*
  Parameters: {"memo"=>{}}
=== Request Headers ===
User-Agent: node
HTTP_HOST: localhost:8080
HTTP_CONNECTION: keep-alive
CONTENT_TYPE: application/json
HTTP_COOKIE: _progaku_archive_session=eZLeQLjfX2urWEA2mdVw3%2BhUAPykM%2B5NpC6%2B2efjdpisVzXAgwLJfPzgbPE0AXRntsZ%2B53ZM7uQIrKLVljKPZbeQ7YU%2FapsCFpKU8E1QJ5K9cN0w0pG0MyE3HRRqSo%2BZ4JYzdOX7L%2F1ToOQrsphUDsx0aMx62PGiUIX7D%2FaA9wiXXfbNxMf8sc90jY8%2F%2BvIoodOUXFWXLp55dxcc3a2f4A%3D%3D--c2hJS4hYIkKBFprJ--YpkXKIGNqrAC365ERRV5bQ%3D%3D
HTTP_ACCEPT: */*
HTTP_ACCEPT_LANGUAGE: *
HTTP_SEC_FETCH_MODE: cors
HTTP_USER_AGENT: node
HTTP_ACCEPT_ENCODING: br, gzip, deflate
HTTP_VERSION: HTTP/1.1
========================
  User Load (1.0ms)  SELECT `users`.* FROM `users` WHERE `users`.`id` = 3 LIMIT 1
  ↳ app/controllers/application_controller.rb:33:in `current_user'
  Memo Count (2.4ms)  SELECT COUNT(*) FROM `memos`
  ↳ app/models/memo.rb:39:in `call'
  Rendering memos/index.json.jbuilder
  Memo Load (0.6ms)  SELECT `memos`.* FROM `memos` ORDER BY `memos`.`id` DESC LIMIT 10
  ↳ app/views/memos/index.json.jbuilder:3
  MemoTag Load (1.1ms)  SELECT `memo_tags`.* FROM `memo_tags` WHERE `memo_tags`.`memo_id` IN (5840, 5839, 5838, 5837, 5836, 5835, 5834, 5833, 5832, 5831)
  ↳ app/views/memos/index.json.jbuilder:3
  Tag Load (0.4ms)  SELECT `tags`.* FROM `tags` WHERE `tags`.`id` = 233
  ↳ app/views/memos/index.json.jbuilder:3
  Rendered memos/index.json.jbuilder (Duration: 6.5ms | GC: 1.2ms)
Completed 200 OK in 13ms (Views: 4.7ms | ActiveRecord: 5.5ms (5 queries, 0 cached) | GC: 1.2ms)
```

無事にCookieによるログイン認証を行い、メモを取得することができました！

### まとめ
今回の問題では、Next.jsのApp Routerを使用する際、サーバーサイドでリクエストを送信するために、ブラウザのCookieが正しく含まれずに認証エラーが発生することがわかりました！
そして、この時のHeaderを見た感じ、CORS関連のHEADERが付与されてないので、POSTやDELETEした時CORSエラーが発生しそうですね・・・
その辺の設定もまたする必要がありそうです！
フレームワークの内部仕様など理解した上で使うことの大切さを身を持って感じました。