【DeviseTokenAuth×OmniAuth】Google認証をポップアップで実装する【Rails×Next.js】

現在オンラインスクールにてプログラムの勉強をしているとぴ（[@topi_log](https://x.com/topi_log)）と申します。
DeviseTokenAuthを使ってNext.jsとトークンベースの認証を行っているのですが、Google認証をポップアップで実装することができたので記録としてまとめました。
他のSNS認証も同じようにできると思いますが試していないのでGoogle認証としています。
初学者ゆえ、間違いなどありましたらそっと教えていただけますと幸いです。

# 対象者
- RailsAPIモードで、DeviseTokenAuthによる認証を行っている人
- OmniAuthによるGoogle認証をポップアップで行いたい人

# 開発環境
バックエンド
- Ruby on Rails7.1.3 APIモード
- Ruby3.2.3
- DeviseTokenAuth

フロントエンド
- Next.js14 App Router
- TypeScript

インフラ
- Docker

その他
- WSL2
- Ubuntu22.4

### 前提
ポップアップの実装周りを中心に行うので、Rails側の実装はあまり触れません。

# 実装流れ
1. Railsのルーティング
1. RailsのOmniAuthのコールバック
1. ログインページ
1. 認証後のコールバックページ

# 実装
それでは実装していきます。

## 1. Railsのルーティング
実装状況によって変わると思いますので適宜修正してみてください。
今回はこのようなルーティングになっています。
```ruby
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        omniauth_callbacks: 'api/v1/auth/omniauth_callbacks',
      }
    end
  end
end
```
DeviseTokenAuthはUserモデルと紐づいています。
OmniAuthからのコールバック関数をカスタマイズするので、コントローラを指定します。
上記のルーティングから、コールバックのコントローラは`app/controllers/api/v1/auth/omniauth_callbacks_controller.rb`となります。

## 2. RailsのOmniAuthのコールバック
リダイレクト先と載せる情報を指定したいので実装します。
```ruby
class Api::V1::Auth::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
  # オーバーライド
  def redirect_callbacks
    user = User.find_or_create_by_oauth(request.env['omniauth.auth'])
    if user.persisted?
      sign_in(:user, user)
      # トークンを生成
      client_id = SecureRandom.urlsafe_base64(nil, false)
      token     = SecureRandom.urlsafe_base64(nil, false)
      token_hash = BCrypt::Password.create(token)
      expiry    = (Time.now + DeviseTokenAuth.token_lifespan).to_i

      user.tokens[client_id] = {
        token:  token_hash,
        expiry: expiry
      }
      user.save

      redirect_to "#{ENV['FRONT_URL']}/auth/callback?status=success&uid=#{user.uid}&token=#{token}&client=#{client_id}&expiry=#{expiry}", allow_other_host: true
    else
      redirect_to "#{ENV['FRONT_URL']}/auth/callback?status=failure", allow_other_host: true
    end
  end
end
```
Userモデルのクラスメソッドである`find_or_create_by_oauth`は自前で実装したものです。
この中で「ユーザーがいなければ登録して返却、いればそのユーザーを返却」する処理をしています。
フロントへのリダイレクトは認証が
- 成功 ⇨ 成功、uid、token、client、expiry
- 失敗 ⇨ 失敗

をクエリに乗せてフロントエンドにリダイレクトさせます。

## 3. ログインページ
Google認証のポップアップを表示するための親ウィンドウです。
ポップアップが子ウィンドウになります（以降、ポップアップを子ウィンドウと呼びます）
```tsx
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import * as Lib from "@/lib";

export default function Login() {
  const router = useRouter();
  const [isLogged, setIsLogged] = useState(false);
  const url = process.env.NEXT_PUBLIC_API_URL;
  const SECONDS = 1000;

  useEffect(() => {
    // 子ウィンドウからデータを受け取る関数
    const handleMessage = (event: MessageEvent) => {
      // セキュリティのためオリジンを確認
      if (event.origin !== process.env.NEXT_PUBLIC_URL) return;

      // 子ウィンドウから送られてきたデータからトークンを取得
      const data = event.data as
        | { accessToken: string; uid: string; expiry: string; client: string }
        | { status: string };

      // 今回はアクセストークンがあるかどうかで検知
      if ("accessToken" in data) {
        const { accessToken, uid, expiry, client } = data;
        // アクセストークンがあればCookieなどに保存
        Lib.setToken({ accessToken, uid, expiry, client });
      }

      // 認証終了のステート変化
      setIsLogged(true);
    };

    // イベントリスナーに関数をセット
    window.addEventListener("message", handleMessage);

    return () => {
      // イベントリスナーを開放
      window.removeEventListener("message", handleMessage);
    };
  }, []);

  useEffect(() => {
    // 認証をしたかどうか
    if (!isLogged) return;

    // 認証していたらトークンを取得
    const tokens = Lib.getToken();
    // トークン情報があるかどうか。あったら認証成功
    if (tokens.accessToken && tokens.uid && tokens.expiry && tokens.client) {
      // ログイン後に遷移させたいページを指定
      router.push("/");
      return;
    }

    // ログインに失敗した時に遷移させたいページを指定
    router.push("/auth/failure");
  }, [isLogged]);

  const handlePopup = () => {
    // 子ウィンドウの出現座標決め。左上の座標を決める
    // スクリーンにウィンドウの高さや幅から調整値を引いて2で割る。
    // Y軸は下方向が正の値
    const top = window.screenY + (window.outerHeight - 600) / 2;
    // Xは右方向が正の値
    const left = window.screenX + (window.outerWidth - 600) / 2;
    // 子ウィンドウを生成
    // 第1引数：表示したいURL、今回はバックエンドのOmniAuthURL
    // 第2引数：子ウィンドウのタイトル
    // 第3引数：幅, 高さ, 上の座標, 左の座標
    const popup = window.open(
      `${url}/auth/google_oauth2`,
      "GoogleLogin",
      `width=600,height=600,top=${top},left=${left}`,
    );

    // 子ウィンドウがなければ終了
    if (!popup) {
      return;
    }

    // 子ウィンドウが閉じたかどうかを検知
    // 1秒毎に検知処理を走らせる
    const intervalId = setInterval(() => {
      // 子ウィンドウが閉じたかどうか
      if (popup.closed) {
        // インターバルをクリア
        clearInterval(intervalId);
        // 認証が終了したとする
        setIsLogged(true);
      }
    }, SECONDS);
  };

  return (
    <button onClick={handlePopup}>Googleでログイン</button>
  );
}
```
`Lib.setToken`関数の中で認証に必要なトークンをCookieなりなんなりに保存しています。
実装方法に合わせて適宜改変してみてください。

## 4. 認証後のコールバックページ
子ウィンドウで認証が終わった後に遷移するページです。
そもそもの流れが
1. ログインページでボタン押下
1. 子ウィンドウが出現
1. 子ウィンドウでGoogle認証
1. 子ウィンドウでGoogle認証が終わった後にNext.jsにリダイレクト
1. 子ウィンドウから親ウィンドウへデータを送信
1. 子ウィンドウが自分を閉じる

です。
認証後のコールバックページはこの 3 ～ 7 の実装になります。
```tsx
"use client";

import { useSearchParams } from "next/navigation";
import { Suspense, useEffect } from "react";

function AuthCallbackPageContent() {
  const searchParams = useSearchParams();

  useEffect(() => {
    // 親ウィンドウを取得
    const parentWindow: Window | null = window.opener as (Window & typeof globalThis) | null;

    // 親ウィンドウがなければ閉じる
    if (!parentWindow) {
      window.close();
      return;
    }

    // URLから認証成功/失敗情報の載ってるstatusを取得
    const status = searchParams.get("status");

    // 認証失敗だったら親ウィンドウで失敗とデータを送信して閉じる
    if (status === "failure") {
      // 第1引数：送りたいデータ
      // 第2引数：送りたいURL
      parentWindow.postMessage({ status: "error" }, `${process.env.NEXT_PUBLIC_FRONT_URL}/login`);
      window.close();
      return;
    }

    // 認証失敗でなければトークン情報を取得
    const accessToken = searchParams.get("token");
    const uid = searchParams.get("uid");
    const expiry = searchParams.get("expiry");
    const client = searchParams.get("client");

    // 全てのトークン情報があったら親ウィンドウに送って閉じる
    if (accessToken && uid && expiry && client) {
      // 第1引数：送りたいデータ
      // 第2引数：送りたいURL
      parentWindow.postMessage({ accessToken, uid, expiry, client }, `${process.env.NEXT_PUBLIC_FRONT_URL}/login`);
      window.close();
    }
  }, []);
  return <></>;
}

export default function AuthCallbackPage() {
  return (
    // useSearchParamsは読み込むまでに時間かるときがあるのでSusbenseを用意する
    <Suspense fallback={<></>}>
      <AuthCallbackPageContent />
    </Suspense>
  );
}

```
useSearchParamsは読み込むまでに時間かかるときがあるのでSuspenseを用意する件については公式ドキュメントにある通りです。

https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

# 終わり
Google認証でポップアップを実装する場合Firebaseがやりやすいのですが、今回バックエンドで認証を完結させたかったので自前で用意してみました。
他のSNS認証でも同じようにできるような気がしています。
またTypeScriptがかなり厳格モードでやっているので型定義をしっかりしていますが、型定義除けばJavaScriptでも同じようにできるので他のフレームワークなどでも流用できるかと思います。
参考になれば幸いです。