react-router(v7) x remix-auth(v4)でOAuth2認証する
はじめに
最近 remix-auth を使おうと思って調査していたところ、v3 から v4 へのアップデートにより使用方法が大きく変更されていることを知りました。インターネット上の記事やチュートリアルの多くが v3 向けに書かれているため、その情報をもとに実装しようとしてもうまく機能しません。
このような経験をされた方のために、v4 での実装方法についてまとめました。
書いてないこと
- OAuthの話
- 実装の細かいところ
- パラメータの説明
- リフレッシュトークンやトークン取り消しの実装
使用したライブラリ
実装方法
React RouterにおけるSessionとCookieの管理は、公式ドキュメントを確認してください。
1. authenticator の設定
以下のように authenticator
を設定します:
import { Authenticator } from "remix-auth";
import { OAuth2Strategy } from "remix-auth-oauth2";
import { commitSession, getSession } from "./session.ts";
// 取得するユーザー情報の型(適宜修正してください。)
type User = {
id: number;
name: string;
roleType: number;
mailAddress: string;
userId?: string;
};
// セッションに保存するユーザー情報の型
export type SessionUser = User & {
accessToken: string;
refreshToken: string;
};
// 認証を行うインスタンスを作成
export const authenticator = new Authenticator<SessionUser>();
// Strategyの登録
authenticator.use(
// OAuth2を利用する
new OAuth2Strategy(
{
authorizationEndpoint: "<OAUTH_END_POINT>",
tokenEndpoint: "<TOKEN_END_POIND>",
clientId: "your_app_client_id",
clientSecret: "your_app_client_secret",
redirectURI: "redirect_uri",
},
async ({ tokens }) => {
// ユーザ情報を取得(適宜修正してください。)
const userResponse = await fetch(
"<GET_USER_END_POIND>",
{
headers: {
Authorization: `Bearer ${tokens.accessToken()}`,
},
},
);
if (!userResponse.ok) {
throw new Error("Failed to fetch user data");
}
const user: SessionUser = await userResponse.json();
// トークン情報をユーザーオブジェクトに含めて返す
return {
...user,
accessToken: tokens.accessToken() || "",
refreshToken: tokens.refreshToken() || "",
};
},
),
// 任意ですが、複数ストラテジーを登録する場合は必要です。
"provider_name",
);
2. ログインページの実装
v4 からは自分で getSession
を呼び出してセッションの状態を確認する必要があります:
export const action = async ({ request }: Route.ActionArgs) => {
try {
// 認証
// redirect_uriにリダイレクトします。
return await authenticator.authenticate("provider_name", request);
} catch (error) {
if (error instanceof Response) {
throw error;
}
logger.error(error);
return {
error: "認証エラーが発生しました。",
};
}
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const session = await getSession(request.headers.get("Cookie"));
// セッションがある場合はトップページにリダイレクト
if (session.has("user")) {
return redirect("/");
}
return null;
};
...
3. コールバックページの実装
認証後のリダイレクト先は以下のように実装します。v4 では自分でセッションを保存する必要があります:
import { authenticator, saveSession } from "@/.server/auth";
import { redirect } from "react-router";
import type { Route } from "./+types/callback.ts";
export async function loader({ request }: Route.LoaderArgs) {
// 認証
// login.tsx でリダイレクトされた場合、ここでアクセストークンの取得処理などが行われます。
const user = await authenticator.authenticate("provider_name", request);
// セッションを保存 - v4では自分で実装します
const headers = await saveSession(request, user);
// Set-Cookieをつけて"/"にリダイレクト
return redirect("/", { headers });
}
export default function CallbackPage() {
...
}
login.tsx
とcallback.tsx
の両方でauthenticator.authenticate()
を呼び出してます。そのため無限にリダイレクトされるじゃんと思われるかもしれません。
実際そんなことは起きなくて、ログイン画面からリダイレクトされてきたコールバックURL には state
パラメータと code
パラメータが付与されます。remix-auth-oauth2 はこの stateパラメータの有無を確認し、存在すれば認証サーバーからの正規のコールバックと判断してアクセストークン取得処理を行い、なければ直接アクセスされたと判断して認証プロセスを開始します。このような状態管理により、コールバック URLに何度アクセスしても適切に処理が振り分けられ、無限にリダイレクトされることはありません。
4. ログアウト機能の実装
v4 では、ログアウト処理も自分で実装する必要があります。
import { destroySession, getSession } from "@/.server/session";
import { Button } from "@/components/ui/button";
import { Form, redirect } from "react-router";
import type { Route } from "./+types/logout.ts";
export async function action({ request }: Route.LoaderArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: {
// セッションをクリア
"Set-Cookie": await destroySession(session),
},
});
}
// 直接アクセスされた場合はトップページにリダイレクト
export function loader() {
return redirect("/");
}
export default function LogoutPage() {
...
}
以上です。
少しでも誰かの役に立てれば嬉しいです。
参考
Discussion