🔑

React Router v7 と remix-auth-auth0 を使った認証実装

に公開

はじめに

本記事では、React Router v7とremix-authおよびremix-auth-auth0を組み合わせて、Auth0による認証システムを実装する方法を段階的に解説します。

プロジェクト作成

まずは、React Router v7のプロジェクトを作成します。
ここでは、remix-auth0-sampleとします。

# プロジェクトを作成
npx create-react-router@latest --template remix-run/react-router-templates/default remix-auth0-sample

# プロジェクトディレクトリに移動
cd remix-auth0-sample

# 認証関連のパッケージをインストール
npm install remix-auth remix-auth-auth0

# 環境変数ファイルを作成
touch .env

# 開発サーバーを起動
npm run dev

このコマンドで、React Router v7のテンプレートを使用したプロジェクトが作成されます。次に、Auth0認証に必要なパッケージをインストールします。

認証サービスの設定

まずは認証の基盤となるサービスを作成します。app/services/auth.server.tsファイルを作成しましょう。

import { Authenticator } from "remix-auth";
import { Auth0Strategy } from "remix-auth-auth0";
import { createCookieSessionStorage } from "react-router";

// ユーザータイプの定義
export type User = {
  id: string;
  email: string;
  name: string;
  picture: string;
  accessToken: string;
  refreshToken?: string | null;
};

// セッションストレージの設定
export const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__auth_session",
    secrets: [process.env.SESSION_SECRET || "s3cr3t"], // 実際の環境では適切なシークレットに変更してください
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30, // 30日
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
  },
});

// ユーザーセッションのヘルパー関数
export async function getUserSession(request: Request) {
  return sessionStorage.getSession(request.headers.get("cookie"));
}

// セッションへのユーザー情報保存
export async function setUserSession(request: Request, userId: string, userData: any) {
  const session = await getUserSession(request);
  session.set("userId", userId);
  session.set("userData", userData);

  return session;
}

// 認証インスタンスの作成
export const authenticator = new Authenticator<User>();

// セッションからユーザー情報を取得する関数
export async function isAuthenticated(request: Request) {
  const session = await getUserSession(request);
  const userId = session.get("userId");
  const userData = session.get("userData");
  
  if (!userId || !userData) {
    return null;
  }
  
  return userData as User;
}

// Auth0ストラテジーの設定
const auth0Strategy = new Auth0Strategy<User>(
  {
    domain: process.env.AUTH0_DOMAIN || "",
    clientId: process.env.AUTH0_CLIENT_ID || "",
    clientSecret: process.env.AUTH0_CLIENT_SECRET || "",
    redirectURI: process.env.AUTH0_CALLBACK_URL || "",
    scopes: ["openid", "email", "profile"],
  },
  async ({ tokens }) => {
    const userResponse = await fetch(
      `https://${process.env.AUTH0_DOMAIN}/userinfo`,
      {
        headers: {
          Authorization: `Bearer ${tokens.accessToken()}`,
        },
      },
    );

    if (!userResponse.ok) {
      throw new Error("Failed to fetch user data");
    }

    const userData = await userResponse.json();
    
    return {
      id: userData.sub,
      email: userData.email,
      name: userData.name,
      picture: userData.picture,
      accessToken: tokens.accessToken(),
      refreshToken: tokens.refreshToken(),
    };
  }
);

// 認証インスタンスにAuth0ストラテジーを追加
authenticator.use(auth0Strategy);

// ユーザー認証ヘルパー関数
export async function requireUser(request: Request, redirectTo: string = "/login") {
  try {
    // まずセッションから認証済みユーザーを確認
    const sessionUser = await isAuthenticated(request);
    if (sessionUser) {
      return sessionUser;
    }

    const user = await authenticator.authenticate("auth0", request);
    
    if (user) {
      // 認証成功したらセッションを更新
      const session = await setUserSession(request, user.id, user);
      throw new Response(null, {
        status: 302,
        headers: {
          Location: request.url,
          "Set-Cookie": await sessionStorage.commitSession(session),
        },
      });
    }
    
    return user;
  } catch (error) {    
    const url = new URL(request.url);
    const searchParams = new URLSearchParams([["redirectTo", url.pathname]]);
    throw new Response(null, {
      status: 302,
      headers: {
        Location: `${redirectTo}?${searchParams}`,
      },
    });
  }
}

このファイルでは以下の重要な機能を実装しています:

  1. セッション管理: Cookieベースのセッション管理をcreateCookieSessionStorageで設定
  2. ユーザー型定義: Auth0から取得するユーザー情報の型を定義
  3. 認証ロジック: Auth0認証を処理するストラテジーを設定
  4. ヘルパー関数: セッション操作や認証状態確認のための便利な関数を提供

特に重要なのはrequireUser関数で、保護されたルートでユーザーの認証状態を確認し、未認証の場合はログインページにリダイレクトします。

ログインページの作成

次に、ユーザーがAuth0認証を開始するためのログインページ(app/routes/login.tsx)を作成します。

import { Form, redirect, useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { authenticator } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // すでに認証されている場合はリダイレクト
  try {
    const user = await authenticator.authenticate("auth0", request);
    return redirect("/dashboard");
  } catch (error) {
    // 認証されていないので何もしない
  }

  // URLからエラーパラメータを取得
  const url = new URL(request.url);
  const error = url.searchParams.get("error");
  
  return { error };
}

export default function Login() {
  const { error } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>ログイン</h1>
      {error && <div>{error}</div>}

      <Form method="post" action="/auth/auth0">
        <button type="submit">
          Auth0でログイン
        </button>
      </Form>
    </div>
  );
}

このページでは、すでに認証されているユーザーはダッシュボードにリダイレクトし、そうでなければAuth0認証を開始するボタンを表示します。エラーメッセージも表示できる仕組みが組み込まれています。

Auth0認証フローの実装

Auth0認証フローを処理するためのルート(app/routes/auth.auth0.tsx)を作成します。まず、認証を開始するためのルート:

import type { ActionFunctionArgs } from "react-router";
import { authenticator } from "~/services/auth.server";

export async function action({ request }: ActionFunctionArgs) {
  return authenticator.authenticate("auth0", request);
}

次に、Auth0からのコールバックを処理するルート(app/routes/auth.auth0.callback.tsx):

import type { LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { authenticator, sessionStorage, setUserSession } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // URLパラメータを抽出
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  
  try {
    const user = await authenticator.authenticate("auth0", request);
    const session = await setUserSession(request, user.id, user);
    return redirect("/dashboard", {
      headers: {
        "Set-Cookie": await sessionStorage.commitSession(session)
      }
    });
  } catch (error) {
    console.error("Authentication error:", error);    

    return redirect("/login?error=auth_failed");
  }
}

この2つのルートで認証フローを実現します:

  1. /auth/auth0:Auth0のログイン画面にリダイレクト
  2. /auth/auth0/callback:Auth0からの応答を処理し、認証情報をセッションに保存

ダッシュボードページの作成

認証が必要なダッシュボードページ(app/routes/dashboard.tsx)を作成しましょう:

import { useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // ユーザーが認証されていない場合は/loginにリダイレクト
  const user = await requireUser(request);
  return { user };
}

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>ダッシュボード</h1>
      <div>
        <img src={user.picture} alt={user.name} />
        <h2>{user.name}</h2>
        <p>{user.email}</p>
      </div>
    </div>
  );
}

このページでは、requireUser関数を使って認証を要求し、認証済みユーザーの情報(名前、メール、プロフィール画像)を表示します。未認証ユーザーはログインページにリダイレクトされます。

ログアウト処理の実装

ログアウト処理を行うルート(app/routes/logout.tsx)を作成します:

import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { redirect } from "react-router";

// サーバー側のloaderで実行される処理
export async function loader({ request }: LoaderFunctionArgs) {
  // サーバー側のモジュールをここでインポートする
  const { sessionStorage } = await import("~/services/auth.server");
  
  // セッションを取得して破棄する
  const session = await sessionStorage.getSession(request.headers.get("cookie"));
  
  // Auth0のドメインを取得
  const auth0Domain = process.env.AUTH0_DOMAIN;
  const clientId = process.env.AUTH0_CLIENT_ID;
  
  // アプリケーションのルートURL(ログアウト後のリダイレクト先)
  const returnTo = new URL("/login", request.url).toString();
  
  // Auth0のログアウトURLを構築
  const logoutURL = `https://${auth0Domain}/v2/logout?client_id=${clientId}&returnTo=${encodeURIComponent(returnTo)}`;
  
  // まずRemixのセッションを破棄し、次にAuth0のログアウトURLにリダイレクト
  return redirect(logoutURL, {
    headers: {
      "Set-Cookie": await sessionStorage.destroySession(session)
    }
  });
}

// サーバー側のactionで実行される処理
export async function action(args: ActionFunctionArgs) {
  // loaderと同じ処理を実行
  return loader(args as unknown as LoaderFunctionArgs);
}

// クライアント側のコンポーネント(空)
export default function LogoutRoute() {
  return null;
}

ログアウト処理では、以下の2ステップを実行します:

  1. アプリケーションのセッションを破棄
  2. Auth0のログアウトエンドポイントにリダイレクトして、Auth0のセッションも終了

ルーティング設定の更新

作成したルートを登録するために、ルーティング設定(app/routes.ts)を更新します:

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("login", "routes/login.tsx"),
  route("dashboard", "routes/dashboard.tsx"),
  route("logout", "routes/logout.tsx"),
  route("auth/auth0", "routes/auth.auth0.tsx"),
  route("auth/auth0/callback", "routes/auth.auth0.callback.tsx")
] satisfies RouteConfig;

この設定により、各ルートが適切なコンポーネントにマッピングされます。

ルートコンポーネントの更新

アプリケーションのレイアウトを提供するルートコンポーネント(app/root.tsx)を更新します:

import {
  Link,
  isRouteErrorResponse,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useLocation,
} from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { authenticator, isAuthenticated } from "~/services/auth.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // ユーザーの認証状態を確認
  try {
    // まずセッションからユーザー情報を取得
    let user = await isAuthenticated(request);
    
    if (user) {
      return { user, isAuthenticated: true };
    }

    try {
      user = await authenticator.authenticate("auth0", request);
      return { user, isAuthenticated: true };
    } catch (authError) { 
      return { user: null, isAuthenticated: false };
    }
  } catch (error) {
    return { user: null, isAuthenticated: false };
  }
}

import type { Route } from "./+types/root";
import "./app.css";

export const links: Route.LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
  {
    rel: "preconnect",
    href: "https://fonts.gstatic.com",
    crossOrigin: "anonymous",
  },
  {
    rel: "stylesheet",
    href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
  },
];

export function Layout({ children }: { children: React.ReactNode }) {
  const { isAuthenticated } = useLoaderData<typeof loader>();
  const location = useLocation();
  const isLoginPage = location.pathname === "/login";

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <nav>
            <Link to="/">ホーム</Link>
            {isAuthenticated && !isLoginPage && <Link to="/dashboard">ダッシュボード</Link>}
            {isAuthenticated ? (
              <Link to="/logout">ログアウト</Link>
            ) : (
              !isLoginPage && <Link to="/login">ログイン</Link>
            )}
          </nav>
        </header>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  let message = "Oops!";
  let details = "An unexpected error occurred.";
  let stack: string | undefined;

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? "404" : "Error";
    details =
      error.status === 404
        ? "The requested page could not be found."
        : error.statusText || details;
  } else if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message;
    stack = error.stack;
  }

  return (
    <main className="pt-16 p-4 container mx-auto">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre className="w-full p-4 overflow-x-auto">
          <code>{stack}</code>
        </pre>
      )}
    </main>
  );
}

ルートコンポーネントでは、ナビゲーションバーを実装し、ユーザーの認証状態に応じて適切なリンク(ログイン/ログアウト、ダッシュボード)を表示します。また、エラーバウンダリーも設定しています。

環境変数の設定

.envファイルに必要な環境変数を設定します:

# Auth0の設定
AUTH0_DOMAIN=your-domain.auth0.com
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
AUTH0_CALLBACK_URL=http://localhost:5173/auth/auth0/callback

# セッション設定
SESSION_SECRET=your-session-secret

これらの値は実際のAuth0アプリケーション設定に合わせて変更する必要があります。

Auth0のセットアップ

Auth0でアプリケーションを設定する手順は以下の通りです:

  1. Auth0のウェブサイトにアクセスしてアカウントを作成
  2. ダッシュボードから「Applications」→「Create Application」を選択
  3. アプリケーション名を入力し、「Regular Web Applications」を選択
  4. 作成したアプリケーションの設定ページで以下を設定:
    • Allowed Callback URLs: http://localhost:5173/auth/auth0/callback
    • Allowed Logout URLs: http://localhost:5173/login
    • Allowed Web Origins: http://localhost:5173
  5. Domain、Client ID、Client Secretを.envファイルにコピー

認証フローの概要

実装した認証フローは以下の流れになります:

  1. ユーザーがログインページで「Auth0でログイン」ボタンをクリック
  2. /auth/auth0ルートで認証プロセスを開始
  3. Auth0のログイン画面にリダイレクト
  4. 認証成功後、コールバックURLにリダイレクト
  5. /auth/auth0/callbackルートでユーザー情報を取得・保存
  6. ダッシュボードページにリダイレクト
  7. ダッシュボードページでユーザー情報を表示
  8. ログアウトボタンをクリックすると、セッションを削除してログインページにリダイレクト

動作確認

実装したアプリケーションが正しく動作するか確認しましょう。

  1. 開発サーバーを起動します。
npm run dev
  1. ブラウザで http://localhost:5173 にアクセスします。

    トップページ

  2. 左上のナビゲーションにある「ログイン」リンクをクリックします。

    ログインリンク

  3. ログインページで「Auth0でログイン」ボタンをクリックします。

    ログインボタン

  4. Auth0のログイン画面が表示されます。ここでは様々な認証方法が選択できます。

    Auth0ログイン画面

  5. 「Googleでログイン」などの選択肢をクリックし、認証情報を入力します。

  6. 認証に成功すると、自動的にダッシュボード画面に遷移します。ここではユーザーのプロフィール情報(名前、メールアドレス、プロフィール画像)が表示されます。

    ダッシュボード画面

  7. ナビゲーションバーには「ダッシュボード」と「ログアウト」のリンクが表示されるようになります。「ログアウト」をクリックすると、セッションが終了し、ログインページに戻ります。

以上の手順で、実装した認証システムが正しく機能していることを確認できます。

まとめ

React Router v7は、Remixの機能を取り入れた強力なルーティングライブラリです。remix-authremix-auth-auth0を組み合わせることで、簡単にAuth0認証を実装できることがわかりました。

このサンプルでは、React Router v7の新機能(react-routerパッケージからのインポート)を使用して、セッション管理、リダイレクト、JSONレスポンスなどの機能を活用しています。

既存のReact Routerプロジェクトからv7へのアップグレードも、非破壊的なアップグレードパスが提供されているため比較的簡単です。

注意点:

  • 実際の環境では、.envファイルのシークレットを適切に設定してください
  • 本番環境ではURLを実際のドメインに変更する必要があります
  • デプロイ前に、Auth0の設定で本番環境のURLを追加してください

この実装を参考に、あなたのプロジェクトに最適な認証システムを構築してみてください!

参考

remix-auth

https://github.com/sergiodxa/remix-auth

https://remix.run/resources/remix-auth

remix-auth-auth0

https://github.com/danestves/remix-auth-auth0

Discussion