Next.js × Rails × Auth.js でGoogle OAuth + JWTによる認証を実装する

ポートフォリオをNext.jsとRailsで作っています。
認証をどうするか迷いました。
自分で決めていいとなると、
いろんな認証方法があったり、
いろんなライブラリがあったりして、
選択するのが難しいです。
ポートフォリオの中で、認証が一番難しいかもしれません。
後で忘れる可能性が非常に高いので、
文章に残しておこうと思います。
リポジトリはこちら
https://github.com/shu915/digital_ichiba_frontend
https://github.com/shu915/digital_ichiba_backend
GoogleのOAuthで認証することに決める
最近、WEB上でお金が動くことが多く、
パスワードによるハッキングの被害は甚大です。
2段階認証やパスキーによる認証が望ましいです。
でもそれを実装するのも大変なので、
Googleの2段階認証やパスキーを通過した、
GoogleのアカウントによるOAuthがいいのではないかと思いました。
Emailやパスワードによる認証や、
Google以外のプロバイダーのOAuthを
あとから追加したくなったときに、
追加できるように設計しました。
Auth.jsを使うことに決める
Auth.jsはNext.jsの認証ライブラリです。
メールパスワード認証や、
GoogleなどのOAuthをサポートしています。
色々考慮した結果、
Auth.jsで認証を行い、
Next.js側でJWTを発行し、
Railsで検証する仕組みを実装することにしました。
RailsといえばDevise系が有名ですが、
メール認証や、パスワードリセットはこの設計では必要ないので、
今回はNext.jsとRailsのJWT認証は自力で実装することにしました。
バージョン情報
フロントエンド
- TypeScript 5.9.2
- React 19.16
- Next.js 15.5.0
- Auth.js 5.0.0-beta.29
- TailwindCSS 4.1.12
バックエンド
- Ruby 3.3.9
- Rails 8.0.2.1
- Postgresql 16.10
- Docker 28.4.0
Auth.jsはもともと、NextAuth.jsだったのですが、
バージョンが上がって、Auth.jsになりました。
フロー付きアーキテクチャ図

1.ユーザーがAuth.jsでGoogleのOAuthを使って、サインアップかログインする
2.Auth.jsがGoogleで認証を試みる
3.Googleから認証の成功が返ってきて、Auth.jsのセッションが作成される
4.Auth.jsのセッションをもとに、バックエンドに渡す署名付きJWTトークンをNext.jsで作る
5.バックエンド用JWTをRailsに渡して、Railsで署名を検証する
6.Postgresに新規登録するか、すでにある場合ユーザーの情報を取る
7.Postgresからユーザー情報をRailsへ返す
8.RailsからNext.jsへユーザー情報を返す
9.Next.jsから必要な情報をクライアントのCookieに挿入する
プロジェクトの準備をする
今回はAuth.jsによる認証がテーマなので、
Next.jsとRailsのインストールは割愛します。
私はRailsとPostgresはDockerで動かしています。
Auth.jsを準備する
Auth.jsはバージョンによって、書き方が違うので
注意が必要です。
https://authjs.dev/getting-started/installation?framework=Next.js
この公式ドキュメントを見ながら、
Next.js用に準備します。
npm install next-auth@beta
これでインストールします。
npx auth secret
これで、シークレットが生成されます。
.envや.env.localにAUTH_SECRET=に文字列で書き込まれます。
src/auth.js
Next.jsのsrcの中で作業しているので、
srcの直下にauth.tsファイルを作ります。
以下のように公式からコピペします。
import NextAuth from "next-auth"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
})
src/app/api/auth/[…nextauth]/route.ts
src/app/api/auth/[…nextauth]/route.tsというファイルを作り、
以下を公式からコピペします。
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
src/middleware.ts
src/middleware.tsに以下を公式からコピペします
export { auth as middleware } from "@/auth";
以上が公式ドキュメントによる、Auth.jsの環境構築です。
Auth.jsを書き換える
Googleを使って、Auth.jsを認証し、JWTトークンを生成する仕組みを作ります。
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, signIn, signOut, auth } = NextAuth({
secret: process.env.AUTH_SECRET,
providers: [Google],
session: {
strategy: "jwt",
maxAge: 60 * 60 * 24 * 3,
updateAge: 0,
},
callbacks: {
async jwt({ token, account, profile }) {
if (account?.provider === "google") {
token.provider = account.provider;
token.provider_subject = profile?.sub;
}
return token;
},
}
});
コードは以上のようになります。
これから解説します。
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
Auth.jsと言いつつここでは古い名前が使われています。
インポートします。
また、今回はgoogleによるOAuthを実装するので、
Googleをインポートします
export const { handlers, signIn, signOut, auth } = NextAuth({
NextAuthに設定を渡して、
handlers, signIn, signOut, authを定義します。
この行はコピペです。
handlersは、GETとPOSTをsrc/app/api/auth/[…nextauth]/route.tsで、
export const { GET, POST } = handlersのように、
取り出しています。
分かりづらいですが、このGETとPOSTは、
私達が使うものではなく、
signIn, signOut, callbackなどで使われるようです。
signInはログイン、
signOutはログアウト、
authはセッションの取り出しで使います。
secret: process.env.AUTH_SECRET,
初期設定したときに生成したAuth.jsのシークレットを、
ここで渡します。
providers: [Google],
今回はgoogleだけです。
session: {
strategy: "jwt",
maxAge: 60 * 60 * 24 * 3,
updateAge: 0,
},
JWTで認証します。
寿命は3日にしました。
長すぎるとセキュリティが弱くなり、
短すぎると、再ログインを求められて、
使いづらくなるので3日に決めました。
Auth.jsは使い続けていると自動で期限を更新しますが、
updateAge: 0で無効にします。
バックエンドから取ってきたデータをCookieにいれるので、
Cookieと3日で揃えます。
callbacks: {
async jwt({ token, account, profile }) {
if (account?.provider === "google") {
token.provider = account.provider;
token.provider_subject = profile?.sub;
}
return token;
},
}
Googleからコールバックで、
token, account, profileが引数に入るので
プロバイダーがGoogleならば
token.providerにaccount.providerを代入し
token.provider_subjectにprofileにsubがあれば代入します
最後に加工したtokenを返します。
この設定で、Googleを使った Auth.js の認証フローが完了し、
Auth.js が管理する セッション用の JWT トークン を作って保持できるようになりました。
Next.jsでJWTトークンを生成する
Next.jsでJWTを作り、署名をして、
Railsに送り、Railsで署名をチェックします。
src/lib/createBackendJwt.tsというファイルを作ります。
import { SignJWT, importPKCS8 } from "jose";
const PRIVATE_KEY_PEM = process.env.APP_JWT_PRIVATE_KEY!.replace(/\\n/g, "\n");
type MintPayload = {
email: string;
provider?: "google" | "email";
provider_subject?: string;
};
export default async function createBackendJwt({
email,
provider,
provider_subject,
}: MintPayload) {
const key = await importPKCS8(PRIVATE_KEY_PEM, "RS256");
const now = Math.floor(Date.now() / 1000);
return await new SignJWT({
email,
provider,
provider_subject,
})
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
.setIssuer("digital-ichiba-next")
.setAudience("digital-ichiba-rails")
.setSubject(provider_subject ?? email)
.setIssuedAt(now)
.setExpirationTime(now + 60 * 60)
.sign(key);
}
解説します
npm install jose
まずは、joseというJWTを扱うためのライブラリをインストールします。
import { SignJWT, importPKCS8 } from "jose";
joseからSignJWTとimportPKCS8をインポートします。
importPKCS8はPEM鍵を読み込めるようにするもので、
SignJWTは、その鍵をつかってJWTに署名するためのもです。
const PRIVATE_KEY_PEM = process.env.APP_JWT_PRIVATE_KEY!.replace(/\\n/g, "\n");
これは事前に作ったプライベートキーを、
Next.jsのenvに記入したものを読み込みます。
以下のことを実行してキーを準備します。
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
gitなどで共有されない場所で、このコマンドで、
プライベートキーを作成します。
openssl rsa -in private.pem -pubout -out public.pem
また、このコマンドで、ペアとなるpublicキーを生成できます。
Next.js側のenvファイルに、
APP_JWT_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n(略)
のようにプライベートキーを追記します。
もともとの、プライベートキーは改行されていますが、
改行を消して代わりに\nを入れて、
一行になるように書き換えます。
const PRIVATE_KEY_PEM = process.env.APP_JWT_PRIVATE_KEY!.replace(/\\n/g, "\n");
プライベートキーの準備ができたので、
このコードの解説に戻ります。
process.env.APP_JWT_PRIVATE_KEY!は、
envファイルからAPP_JWT_PRIVATE_KEYを取得します。
! がついていますが、これはNot-null assertion operatorというもので、
もともとstring | undefinedを受け付けるのですが、
undefinedの可能性はないとTSに伝えます。
replace()に関しては、
第一引数は正規表現で2文字のテキストである\nがあれば、
第二引数に”\n”に置き換えて、改行を有効化します。
これで一行にして収納されたプライベートキーを、
復元して、定数PRIVATE_KEY_PEMに代入できました。
type MintPayload = {
email: string;
provider?: "google" | "email";
provider_subject?: string;
};
これはcreateBackendJwt関数の、引数の型を定義しています。
オブジェクトリテラルに、3つ項目があります。
メールアドレスを文字列型、
プロバイダーは、Googleしか実装しませんが、
一応メールアドレスとパスワードで実装することを考えて、
“email”も記述しています。
provider_subjectは、
コールバックで返ってくる、
そのプロバイダーによる識別子です。
provider?とprovider_subject?に?がついているのは、
これらの項目はオプショナルで、
必須ではないことを意味します。
export default async function createBackendJwt({
email,
provider,
provider_subject,
}: MintPayload) {
ここでcreateBackendJwtという関数を定義しています。
defaultで、このファイルのデフォルト関数であることを示します。
asyncがついているのは、
内部でawaitを使ってPromiseを解決しているからです。
引数として、email, provider, provider_subjectを含んだ
オブジェクトリテラルを引き取ります。
先程定義した型をあてます。
const key = await importPKCS8(PRIVATE_KEY_PEM, "RS256");
importPKCS8 は非同期関数なので Promise を返します。
そこで await を使って、Promise が解決されるのを待ち、
結果を key に代入します。
第一引数には PEM 形式の秘密鍵、
第二引数には署名アルゴリズムを指定します。
実行されると CryptoKey オブジェクトが返り、
後の .sign(key) で利用されます。
const now = Math.floor(Date.now() / 1000);
ここで、現在時刻を秒単位で作っています。
あとでJWTトークンの期限を設定するのに使います。
return await new SignJWT({
SignJWT は jose に含まれるクラスで、
JWTトークンを生成し、
秘密鍵で署名するために使います。
return await newとすることで、
SignJWTクラスから生成したインスタンスを返り値として、
呼び出されたところへ、
署名済みJWTトークンを返します。
email,
provider,
provider_subject,
})
これは、引数として受け取った値を、
クラスのインスタンスを生成するために初期値としてセットしています。
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
これはJWTトークンのヘッダーのメタ情報で、
RailsにアルゴリズムはRS256で、タイプはJWTであることを伝えます。
.setIssuer("digital-ichiba-next")
これは、トークンの発行者を明記しています。
.setAudience("digital-ichiba-rails")
これは、トークンの受信者を明記しています。
.setSubject(provider_subject ?? email)
Subjectには個人を識別するものが入るので、
provider_subjectがあれば、それを使い、
メールパスワード認証の場合はないので、emailを使います。
.setIssuedAt(now)
.setExpirationTime(now + 60 * 60)
先程作った現在時刻のnowで、
発行日時と有効期限をセットしています。
BackendJWTは、その都度発行するので、
長さは1時間に設定します。
.sign(key);
ここまで設定したペイロードとヘッダーを、
秘密鍵で署名して JWT を完成させます。
バックエンドで、改ざんされてないことを確認できるようになります。
これでAuth.jsのログイン情報を使って、
Next.js側からRailsへ送信する、
署名つきJWTトークンを発行できるようになりました。
Auth.jsセッションから自作JWTを発行する
createBackendJwt()一つで完結させると、
複雑すぎるので、2つのファイルに分けました。
基本的に、route.tsなどで、
requestをこちらのcreateBackendJwtFromRequest()に渡して、
内部で、createBackendJwtで作ったJWTトークンを返します。
コードは以下のようになります。
import { getToken } from "next-auth/jwt";
import createBackendJwt from "@/lib/createBackendJwt";
export default async function createBackendJWTFromRequest(
request: Request
): Promise<string> {
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
});
if (!token?.email) throw new Error("unauthorized");
return createBackendJwt({
email: token.email as string,
provider: token.provider as "google" | "email" | undefined,
provider_subject: token.provider_subject as string | undefined,
});
}
詳細を解説します。
import { getToken } from "next-auth/jwt";
import createBackendJwt from "@/lib/createBackendJwt";
next-auth/jwtからgetTokenをインポートします。
また、先程の自作関数のcreateBackendJwtもインポートします。
export default async function createBackendJwtFromRequest(
request: Request
): Promise<string> {
defaultでデフォルト関数、
asyncで非同期関数として、
createBackendJwtFromRequestを作成します。
引数はRequest型で、requestを引き取ります。
ルート関数で受け取った request をそのまま引き取るイメージです。
ヘッダーやクッキーなどのリクエスト情報が入っています。
署名済みJWTトークンは文字列なので、
非同期に解決される署名済みJWTトークン(文字列)を返します。
const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
});
Auth.jsのgetTokenを使うと、
Auth.jsのログイン済みセッションから、ログイン情報を
JWTトークンとして取れます。
このトークンはNext.jsとAuth.jsの間のやり取りに使われるもので、
これは自作のBackendJWTと別物です。
Auth.jsにGoogleでログインした情報が入っています。
getTokenの引数はオブジェクトリテラルであり、
req:にrequestを渡して、
リクエストからクッキーを読み取り、セッションJWTを探します。
また、secret:にprocess.env.AUTH_SECRETを渡して、
JWTトークンが正しいのか検証します。
つまり、バックエンドへ送るJWTトークンは、
Next.jsで発行して、Railsで検証するのに対して、
こちらは、Auth.jsが発行し、Auth.jsが検証しています。
if (!token?.email) throw new Error("unauthorized");
token に Auth.js のセッション用 JWT を代入しました。
email が含まれていなければ正しいログイン状態ではないため、
ここでエラーを投げます。
実際の HTTP レスポンスは route.ts など上位層でキャッチして、
ステータスコードを返すようにします。
return createBackendJwt({
email: token.email as string,
provider: token.provider as "google" | "email" | undefined,
provider_subject: token.provider_subject as string | undefined,
});
asというのは型アサーションというTSの機能で、
TSの型推論を明示的に上書きします。
例えば、emailは必須なので、undefinedは許容しません。
Auth.jsから取り出したtokenで、
email: で文字列としてのメールアドレスを受取り、
providerで”google”, “email”, undefinedのいずれかに範囲を狭くし、
provider_subjectは、文字列かundefinedのいずれかのみ、
受け取るようにします。
この3つの要素を含んだオブジェクトリテラルを
createBackendJwtに渡すことで、
署名付きJWTトークンを返すようにします。
これで、route.tsなどの中で、
Railsに渡す署名付きJWTトークンを作る機能を共有化できました。
DBの構造を確認する
Railsのロジックを作る前に、
DBの構造を確認しておきましょう
認証に関係ないカラムは割愛します。
usersテーブル
idカラムは、Railsで自動生成、user_identitiesから外部参照します。
emailカラムは、PostgresのcitextでUNIQUEとして作成する
user_identitiesテーブル
idカラムは、Railsで自動生成
user_idカラムは、usersテーブルのidを外部参照します。
providerカラムは、”google”などの認証プロバイダーを入れる文字列カラムです。
provider_subjectは、プロバイダーから返ってくる識別番号を入れます。
モデルでアソシエーションを組む
class User < ApplicationRecord
has_many :user_identities, dependent: :destroy
一人のユーザーは、複数の認証方法を取りうるので、
has_many :user_identitiesします。
userが消えたら、関連するuser_identitiesも連鎖で消すようにします。
class UserIdentity < ApplicationRecord
belongs_to :user
enum :provider, { email: 0, google: 1 }, prefix: true
end
UserIdentityはこう書きます。
belongs_to :userで、userに従属するようにします。
またproviderはenumで、
将来認証方法が増えても対処できるようにします。
DBの構造を説明したので、
Railsのロジックに進みましょう。
RailsでJWTを検証する仕組みを作る
Next.jsで発行した署名付きJWTを、
Railsで検証できるようにします。
app/controllers/concerns/rails_jwt_auth.rbというファイルを作成します。
concernsは、Railsの共有モジュール置き場です。
module RailsJwtAuth
extend ActiveSupport::Concern
included do
before_action :authenticate_with_rails_jwt!
attr_reader :current_user
end
def authenticate_with_rails_jwt!
bearer = request.authorization&.split("Bearer ")&.last
head :unauthorized and return if bearer.blank?
public_key = OpenSSL::PKey::RSA.new(ENV["APP_JWT_PUBLIC_KEY"].gsub("\\n", "\n"))
payload, = JWT.decode(
bearer,
public_key,
true,
{ algorithms: ["RS256"],
iss: ENV["APP_JWT_ISS"],
verify_iss: true,
aud: ENV["APP_JWT_AUD"],
verify_aud: true,
verify_exp: true
})
email = payload["email"]
provider = payload["provider"]
provider_subject = payload["provider_subject"]
head :unauthorized and return if email.blank?
ActiveRecord::Base.transaction do
@current_user = User.find_or_create_by!(email: email)
@current_user.user_identities.find_or_create_by!(
provider: provider,
provider_subject: provider_subject
)
end
rescue JWT::DecodeError, JWT::ExpiredSignature
head :unauthorized
end
end
解説します。
module RailsJwtAuth
extend ActiveSupport::Concern
モジュールとしてRailsJwtAuthを定義します。
Concernを使うことを宣言することで、
下のincluded to … endの中身が、
呼び出されたら実行するようになります。
included do
before_action :authenticate_with_rails_jwt!
attr_reader :current_user
end
呼び出したコントローラの中で、
authenticate_with_rails_jwt!が実行されるようになります。
これは下の方で定義しています。
attr_readerは後ろに書いたインスタンス変数のゲッターを作り、
クラスの外からでも呼び出せるようにします。
このモジュールの呼び出し元で、
あとでcurrent_userをゲッターとして呼び出します。
def authenticate_with_rails_jwt!
関数を定義します
bearer = request.authorization&.split("Bearer ")&.last
head :unauthorized and return if bearer.blank?
Next.jsから来たrequestの中の
authorizationヘッダーがあれば、
それを”Bearer “で分割して、
最後の文字列を取ります。
これでJWTトークンの文字列が取れます。
&があるとnilが来たとしても、中断しないようにしています。
それをbearerに代入します。
もしJWTトークンの取得が失敗した場合は
head: unauthorizedで、
ステータスコード401を返します。
JWTトークンを読解する前に、
Railsの.envに準備します。
APP_JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\n略
APP_JWT_ISS=digital-ichiba-next
APP_JWT_AUD=digital-ichiba-rails
公開鍵の要領はNext.jsの時と同じで、
改行を\nに置き換えて一列にします。
JWTの送信者と受信者を定義して、
Next.jsと揃えます。
environment:
APP_JWT_PUBLIC_KEY: ${APP_JWT_PUBLIC_KEY}
APP_JWT_ISS: ${APP_JWT_ISS}
APP_JWT_AUD: ${APP_JWT_AUD}
docker-compose.ymlの中で、これらの変数を呼び出せるようにします。
public_key = OpenSSL::PKey::RSA.new(ENV["APP_JWT_PUBLIC_KEY"].gsub("\\n", "\n"))
.envにある文字列の公開キーの改行を復元してから、
鍵オブジェクトに変換して、
変数に入れます。
payload, = JWT.decode(
bearer,
public_key,
true,
{ algorithms: ["RS256"],
iss: ENV["APP_JWT_ISS"],
verify_iss: true,
aud: ENV["APP_JWT_AUD"],
verify_aud: true,
verify_exp: true
})
JWT.decodeで、
先程Next.jsから受け取ったJWTトークンを読解します。
第1引数に、JWTトークンの文字列を代入します。
第2引数に、先程の鍵オブジェクトを代入します。
第3引数が、trueならば署名を検証します。
第4引数にオブジェクトリテラルを渡します。
algorithmsでアルゴリズムを指定します。
issで送信者を指定します。
verify_iss: trueで送信者の検証を有効にします。
audで受信者を指定します。
verify_aud: trueで受信者の検証を有効にします。
verify_exp: trueで有効期限の検証を有効にします。
email = payload["email"]
provider = payload["provider"]
provider_subject = payload["provider_subject"]
head :unauthorized and return if email.blank?
検証に成功すると
payloadからemail, provider, provider_subjectをそれぞれ変数に代入し、
emailがない場合は無効なので401を返します。
ActiveRecord::Base.transaction do
@current_user = User.find_or_create_by!(email: email)
@current_user.user_identities.find_or_create_by!(
provider: provider,
provider_subject: provider_subject
)
end
ここでトランザクションを作成します。
emailをもとに検索し、
あればそのユーザーを@current_userに入れ、
なければ作成します。
@current_userとアソシエーションを組んでいる、
user_identitiesでまだない場合挿入します。
user_identitiesはDBの章で詳細を説明しています。
rescue JWT::DecodeError, JWT::ExpiredSignature
head :unauthorized
end
これは上の方の処理で、
デコード失敗か期限切れのエラーが発生した場合、
こちらでキャッチして、
401を返して、処理を中断します。
これでRailsでJWTをデコードするモジュールを作ることができました。
次に、このモジュールの呼び出し方について説明します。
class Api::LoginsController < ActionController::API
include RailsJwtAuth
include ResponseSerializers
def create
render json: {
user: user_json(current_user),
shop: current_user.shop ? shop_json(current_user.shop) : nil
},
status: :ok
end
end
これはLoginsControllerの中で、
先程つくったRailsJwtAuthを呼び出しています。
下の方で使っているcurrent_userというのが、
自分でattr_readerで定義したゲッターです。
ResponseSerializersは返す項目を共有化しているので、
ここでは割愛します。
これで、コントローラの処理に入る前に、
JWTを確認することができるようになりました。
ログインしていなければ、
見ることができないページなどの認可にもなります。
Auth.jsが認証したあとの処理
src/app/api/auth/callback/route.tsというファイルを作ります。
このファイルは、Auth.jsの認証後の、
自作のコールバックになります。
Auth.jsの公式コールバックが、
Googleから帰ってきたときにAuth.jsのセッションを作るのですが、
自作コールバックで、追加処理を書きます。
ここではバックエンドに送るJWTの作成と送信、
帰ってきたデータのCookieへに挿入などをしています。
ログインするときに、このファイルに飛ぶように命令します。
import { NextResponse } from "next/server";
import { auth, signOut } from "@/auth";
import createBackendJwtFromRequest from "@/lib/createBackendJwtFromRequest";
export async function GET(request: Request) {
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.redirect(new URL("/", request.url));
const backendJwt = await createBackendJwtFromRequest(request);
const backendRes = await fetch(`${process.env.RAILS_URL}/api/login`, {
method: "POST",
headers: { Authorization: `Bearer ${backendJwt}` },
cache: "no-store",
});
if (!backendRes.ok) {
await signOut();
return NextResponse.redirect(new URL("/", request.url));
}
const diData = await backendRes.text();
const nextRes = NextResponse.redirect(new URL("/", request.url));
nextRes.cookies.set("di_data", diData, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 3,
});
return nextRes;
}
解説します。
import { NextResponse } from "next/server";
import { auth, signOut } from "@/auth";
import createBackendJwtFromRequest from "@/lib/createBackendJwtFromRequest";
NextResponseを”next/server”からインポートします。
NextResponse.redirect()は、ページ遷移するのに使います。
手動で書き換えた、authとsignOutをインポートします。
auth()は、Auth.jsのセッションを取り出すのに使います。
signOut()はAuth.jsのログアウトに使います。
また、自作のcreateBackendJwtFromRequest()をインポートします。
export async function GET(request: Request) {
これはroute.tsの中でGETの処理の関数を書いています。
const session = await auth();
const email = session?.user?.email;
if (!email) return NextResponse.redirect(new URL("/", request.url));
auth()は非同期なので、awaitをつけて、
sessionを取り出します。
セッションのユーザーのメールアドレスをemailに代入します。
new URL(“/”, request.url)とすることで、
現在のurlのルートを生成します。
emailがなければ、
NextResponse.redirect()で、リダイレクトします。
const backendJwt = await createBackendJwtFromRequest(request);
リクエストを渡して、
バックエンドに送るJWTトークンを生成します。
const backendRes = await fetch(`${process.env.RAILS_URL}/api/login`, {
method: "POST",
headers: { Authorization: `Bearer ${backendJwt}` },
cache: "no-store",
});
if (!backendRes.ok) {
await signOut();
return NextResponse.redirect(new URL("/", request.url));
}
fetchで環境変数に設定したRailsの/api/loginに、
POSTで送信します。
headerにバックエンド用JWTトークンを載せます。
cache: “no-store”とすることで、
キャッシュしないようにします。
帰ってきたデータをbackendResに入れます。
レスポンスが2個あるので、
変数名でわかりやすく分けます。
失敗した場合、
ログアウトして、ルートに飛ばします。
私の場合ログインを、モーダルで作っていて、
個別ページがないので、こうしました。
const diData = encodeURIComponent(await backendRes.text());
backedRes.text()で、
jsonとして帰ってきたものを、
文字列として扱います。
それをencodeURIComponent()に渡して変換することで、
安全にCookieに挿入できるようになります。
const nextRes = NextResponse.redirect(new URL("/", request.url));
nextRes.cookies.set("di_data", diData, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 3,
});
return nextRes;
リダイレクト用のNextResponseのオブジェクトを作ります。
.cookies.set()を使ってCookieを設定します。
第一引数にキーとなるもの、
第二引数に、値になるもの、
第三引数のオブジェクトリテラルに、その他の設定を入れます。
httpOnly: trueでJSから操作できないようにします。
secureは本番環境の時だけtrueにします。
secureがtrueだと、HTTPSのときのみ、
Cookieが送信されます。
sameSite: “lax”は、
オリジンの違うサイトからの、
GETは受け付けますが、
POST、PATCH、DELETEなどを受け付けないようにする設定です。
pathはルートを指定して、
サイト全体で、Cookieを使えるようにします。
maxAgeはAuth.jsと3日で揃えます。
Auth.jsの方は自動で更新出ますが、
こちらはできないので、揃えておきます。
最後にオブジェクトをreturnすることで、
リダイレクトが実行されます。
ログイン+サインアップの機能の実装
Headerにログインボタンがあって、
それをクリックすると、
ログインモーダルが出てくる設計なので、
個別ページはありません。
また、ログインとサインアップは、
同じボタンで実行できるため、
2つに分ける必要がないです。
Header
src/components/layouts/Header.tsの中身を、
一部抜粋します。
export default async function Header() {
const session = await auth();
Headerの中でsessionを取ります
{session ? <Logout /> : <LoginDialog />}
jsxのなかで、sessionが無効ならloginボタンが出るようにしています。
ログインモーダル自体は省略しますが。
ログインボタンの説明をします。
ログインボタン
"use server";
import { signIn } from "@/auth";
export async function handleGoogleLogin() {
await signIn("google", { redirectTo: "/api/auth/callback" });
}
src/actions/handleGoogleLogin.tsというファイルで作ります。
これはformのactionの中で呼び出す関数です。
“user server”を宣言しているので、
別ファイルに切り出します。
サーバーサイドで実行されることを明示しています。
signInをauth.tsからインポートします。
これを使うとAuth.jsのセッションを作ります。
またsignIn()は非同期なので、awaitでPromiseを解決します。
第一引数にプロバイダーを入れて、
第二引数はオブジェクトリテラルで、
先程作ったコールバックのルートに、
明示的にリダイレクトさせます。
import { Button } from "@/components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { handleGoogleLogin } from "@/actions/handleGoogleLogin";
export default function LoginWithGoogle() {
return (
<form className="w-xs mx-auto" action={handleGoogleLogin}>
<Button type="submit" className="rounded-full w-full">
<FontAwesomeIcon icon={faGoogle} />
Googleで続行
</Button>
</form>
);
}
src/components/atoms/LoginWithGoogle.tsxとして作ります。
解説します。
import { Button } from "@/components/ui/button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGoogle } from "@fortawesome/free-brands-svg-icons";
import { handleGoogleLogin } from "@/actions/handleGoogleLogin";
UIコンポーネントはshadcn/uiを使っているので、
ボタンをインポートしています。
また、FontAwesomeからグーグルのアイコンをインポートしています。
先程定義したformのaction用の関数もインポートします。
export default function LoginWithGoogle() {
return (
<form className="w-xs mx-auto" action={handleGoogleLogin}>
<Button type="submit" className="rounded-full w-full">
<FontAwesomeIcon icon={faGoogle} />
Googleで続行
</Button>
</form>
);
}
呼び出し元にこの関数を返します。
ログインするためのボタンです。
actionに先程作った関数を()なしで入れます。
()があるとその場で実行してしまう意味になるので、
()をつけないようにします。
ログアウト
Header
{session ? <Logout /> : <LoginDialog />}
こう書いているので、
セッションが存在していれば、
ログアウトボタンが出現します。
ログアウトボタン
"use server";
import { cookies } from "next/headers";
import { signOut } from "@/auth";
export async function handleLogout() {
(await cookies()).delete("di_data");
await signOut({ redirectTo: "/" });
}
こちらもログイン同様、
formのactionに入れる関数を別ファイルに作ります。
src/actions/handleLogout.tsとします。
cookiesをnext/headersからインポートします。
また、auth.tsからsignOutをインポートします。
cookiesに対して、()を付けてawaitとやるのは、
Next.jsのバージョンが上がって、
cookiesが非同期になったためです。
自作でCookieにデータを挿入していたので、
ログアウト時にそれを消します。
またAuth.jsのsignOut()で、
Auth.jsのセッションを終わらせます。
サイトのルートにリダイレクトさせます。
"use client";
import { handleLogout } from "@/actions/handleLogout";
export default function Logout() {
return (
<form
action={handleLogout}
onSubmit={(e) => {
if (!confirm("ログアウトしますか?")) e.preventDefault();
}}
>
<button type="submit" className="text-white font-bold cursor-pointer">
ログアウト
</button>
</form>
);
}
src/components/atoms/Logout.tsはこのようになっています。
"use client";
“use client”としているのは、
ブラウザのconfirmというポップアップ機能を使って、
ユーザーに確認を取っているからです。
onSubmit={(e) => {
if (!confirm("ログアウトしますか?")) e.preventDefault();
}}
これはフォーム送信時に、
確認ポップアップを出して、
キャンセルさせたら、ログアウトを取り消します。
Auth.jsの機能のおさらい
export const { handlers, signIn, signOut, auth } = NextAuth({
src/auth.tsのなかで、
このようにhandlers, signIn, signOut, authを呼び出しました。
これらをおさらいします。
handlers
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
src/app/api/auth/[…nextauth]/route.tsにて、
このように記述しました。
これでhandlersがルーティングの一式を生成しますが、
これは私達が直接呼び出すものではなく、
Next.jsとAuth.jsがやり取りするもののようです。
ですので、このように記述しておけば大丈夫です。
signIn
await signIn("google", { redirectTo: "/api/auth/callback" });
これはAuth.jsのセッションを作ります。
作り終わったらどこにリダイレクトさせるか、
指定することができるので、
自作のコールバックにリダイレクトさせました。
signOut
await signOut({ redirectTo: "/" });
これはAuth.jsのセッションを終了させます。
リダイレクト先をトップに指定しています。
auth
const session = await auth();
こう書くことで、
すでにあるセッションの情報が取れます。
未ログイン時にリダイレクトする関数
ログインしていないと、
閲覧できないページが複数あるので、
関数を共有化します。
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export async function requireAuth(redirectTo = "/") {
const session = await auth();
if (!session) {
redirect(redirectTo);
}
return session;
}
セッションがなければ、
サイトのルートにリダイレクトさせ、
セッションがあれば、
セッションを返します。
やらなかったこと
様々な機能を実装してきましたが、
やらなかったこともあります。
メールパスワード認証と、
リフレッシュトークンはやりませんでした。
メールパスワード認証
セキュリティが弱くなるので実装しませんでした。
もし実装する場合はAuth.jsに、
Credentials Providerという機能があるので、
これを使います。
Auth.jsを通過してRailsで認証に成功すると、
Auth.jsのセッションを作成できるようです。
パスキーや2段階認証が有効になった、
GoogleのOAuthによる認証が堅牢だと思いました。
リフレッシュトークン
こちらはAIに提案されましたが、
複雑になりすぎるのでスルーしました。
まとめ
Auth.js を使って、Next.js と Rails の間で、
安全に認証できる仕組みを作りました。
Google の OAuth でログインすると、
Next.js が署名付き JWT を発行し、
Rails で公開鍵による検証を行います。
これにより、Next.js で認証を完結させつつ、
Rails 側でもユーザーを安全に特定できます。
Auth.js のセッション用 JWT と、
Rails に送る署名付き JWT は別物で、
それぞれが独立してセキュリティを担保するように設計しました。
認証は複雑ですが、
「どこで認証し、どこで検証するか」を整理できたのが、
今回の大きな収穫でした。