🤠

Next.js 13 App Router での Auth.js の使い方

2023/06/13に公開
2

本ブログは、App Router方式でのAuth.jsの使い方の説明になります。公式ページPage Router方式での手順のため、ご参考までに執筆しました。また、Next.jsで絶賛開発中のサーバ・アクションでの動きも調べました。ソースコードはこちらにあります。

https://authjs.dev/getting-started/oauth-tutorial

インストール
yarn add next-auth
ランダムな文字列
openssl rand -base64 32

.envに環境変数を設定します。

.env
NEXTAUTH_SECRET="vhXFoNfK (生成した文字列) N2hvFXaRKI="
NEXTAUTH_URL=http://localhost:3000

Route Handlerに認証処理のエンドポイントを作成します。Page Router方式とはパスが違うので注意してください。

app/api/auth/[...nextauth]/route.ts
import { options } from "@/app/options";
import NextAuth from "next-auth";

const handler = NextAuth(options);

export {handler as GET,handler as POST}

options.tsにサポートする認証方式を設定します。ここでは、メルアド認証とOAuth(Github,Google等)を設定します。
コンストラクターに渡すオプションです。Page Router方式と特に違いはありませんので、さらっと流します。各種OAuthの設定も同じなので説明は省きます。

options.ts
import type {NextAuthOptions} from "next-auth";
import GitHubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";
import CredentialsProvider from "next-auth/providers/credentials";

export const options: NextAuthOptions = {
        debug: true,
        session: {strategy: "jwt"},
        providers: [
            GitHubProvider({
                clientId: process.env.GITHUB_ID!,
                clientSecret: process.env.GITHUB_SECRET!,
            }),
            GoogleProvider({
                clientId: process.env.GOOGLE_CLIENT_ID!,
                clientSecret: process.env.GOOGLE_CLIENT_SECRET!
            }),
            CredentialsProvider({
                    name: "Sign in",
                    credentials: {
                        email: {
                            label: "Email",
                            type: "email",
                            placeholder: "example@example.com",
                        },
                        password: {label: "Password", type: "password"},
                    },
                    // メルアド認証処理
                    async authorize(credentials) {
                        const users = [
                            {id: "1", email: "user1@example.com", password: "password1"},
                            {id: "2", email: "user2@example.com", password: "password2"},
                            {id: "3", email: "abc@abc", password: "123"},
                        ];

                        const user = users.find(user => user.email === credentials?.email);

                        if (user && user?.password === credentials?.password) {
                            return {id: user.id, name: user.email, email: user.email, role: "admin"};
                        } else {
                            return null;
                        }
                    }
                }
            ),
        ],
        callbacks: {
            jwt: async ({token, user, account, profile, isNewUser}) => {
                // 注意: トークンをログ出力してはダメです。
                console.log('in jwt', {user, token, account, profile})

                if (user) {
                    token.user = user;
                    const u = user as any
                    token.role = u.role;
                }
                if (account) {
                    token.accessToken = account.access_token
                }
                return token;
            },
            session: ({session, token}) => {
                console.log("in session", {session, token});
                token.accessToken
                return {
                    ...session,
                    user: {
                        ...session.user,
                        role: token.role,
                    },
                };
            },
        }
    }
;

ログイン、ログアウトのボタンを用意します。使いまわせるようにコンポーネント化します。

app/components/buttons.tsx
"use client";

import {signIn, signOut} from "next-auth/react";

// ログインボタン
export const LoginButton = () => {
    return (
        <button style={{marginRight: 10}} onClick={() => signIn()}>
            Sign in
        </button>
    );
};

// ログアウトボタン
export const LogoutButton = () => {
    return (
        <button style={{marginRight: 10}} onClick={() => signOut()}>
            Sign Out
        </button>
    );
};

ログイン画面を作成します。(あとでもう少し真面目に実装します。)

app/login/page.tsx
import {
    LoginButton,
    LogoutButton,
} from "@/app/components/buttons";

export default async function Home() {

    return (
        <main
            style={{
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
                height: "70vh",
            }}
        >
            <div>
                <LoginButton/>
                <LogoutButton/>
            </div>
        </main>
    );
}

ここまでで一旦完了です。とりあえず、何か動きます。

つぎは認可処理

認証(Authentication)処理が通過したら、つぎは認可(Authorization)処理に進みます。

実際のWebサービスでは次のようなことをする必要があるかと思います。

ログインユーザがゲストか管理者によって、

  1. エンドポイントへのアクセス権を変える。403Forbiddenエラー
  2. ボタンやメニューなどのUIを変える
  3. JSONレスポンスのフィールドの出しわけ

1、3番目はmiddlewareRouter Handlerに仕掛けます。2番目はReactコンポーネントのレンダリング処理で分岐処理します。

それを踏まえて、ミドルウェアー、ラウター・ハンドラー、サーバー・コンポーネント、サーバー・アクション、クライアント・コンポーネントの順に見ていきたいと思います。

ミドルウェア

next-authが提供するnext-auth/middlewareをインポートすると簡単に実装できます。config.matcherにプロテクションを除外するURLパターンを設定します。ここでは、/loginと/apiを認可チェックから外します。

middleware.ts
export { default } from "next-auth/middleware"; // defaultをママ使う。

export const config = {
    matcher: ["/((?!register|api|login).*)"], // ?!で否定です。
};

Webブラウザから http://localhost:3000/hogehogeとタイプしてみてください。hogehogeは適当になんでも良いです。

すると以下のように、/api/auth/signinにリダイレクトされます。GETパラメータcallbackUrlに最初にアクセスを試みたページhogehogeがコールバック先として設定されます。

http://localhost:3000/api/auth/signin?callbackUrl=%2Fhogehoge

カスタマイズしたいときは、withAuthでラップします。第2引数のオプションのcallbacks.authorizedで認可処理をカスタマイズしています。デフォではJWTトークンがあれば許可し、middlewareに進みます。ここでは、roleadminの場合だけ許可しています。この場合、adminしか利用できないWebサービスになります。

middleware.ts
import {withAuth} from "next-auth/middleware"

export default withAuth(
    function middleware(req) {
        // callbacks.authorizedがtrueの場合のみ進入できる
        console.log('in middleware: ', req.nextauth.token)
    },
    {
        callbacks: {
            // 認可に関する処理。ロールが `admin` ならOK
            authorized: ({token}) => {
                console.log('in authorized: ', token)
                return token?.role === "admin"
		// if(token) return true // デフォ
            },
        },
    }
)
yarn dev コンソール出力
in jwt { id: '3', name: 'abc@abc', email: 'abc@abc', role: 'admin' }
in middleware:  {
  name: 'abc@abc',
  email: 'abc@abc',
  sub: '3',
  user: { id: '3', name: 'abc@abc', email: 'abc@abc', role: 'admin' },
  role: 'admin',
  iat: 1686210417,
  exp: 1688802417,
  jti: '88afc9a2-0203-4db6-8593-6000000000a'
}

Route Handlers (API)

Route Handlersに実装します。
app/my-api-auth/route.tsという名前で以下のようなRoute Handlerを作成します。

app/my-api-auth/route.ts
import {NextResponse} from 'next/server';

import {getServerSession} from "next-auth/next"
import {options} from "@/app/options";

export async function GET() {
    const session = await getServerSession(options) // セッション情報を取得

    console.log(session?.user) // ユーザ情報を取得

    return NextResponse.json({message: "ok"});
}

cURLでエンドポイントを叩いてみましょう。

cURL
curl -v http://localhost:3000/my-api-auth

すると、以下のようにmiddlewareのJWTトークンの存在チェックcallbacks.authorizedfalseになり、signinページに307リダイレクトされます。なぜなら、cURLコマンドでクッキーを飛ばしていないからです。

では今度はブラウザからアクセスして見てください。ログインページに転送され、ログインが成功するとクッキーにJWTトークンが保存されmiddlewareをパスし/my-api-authにアクセスできます。

この仕込まれたクッキー(JWT)をコピペして、cURLで-H 'Cookie: next-auth.session-token=...で飛ばしてあげればアクセスできます。

cURL
curl -v http://localhost:3000/my-api-auth -H 'Cookie: next-auth.session-token=eyJhbGc...'

さて、ここでsession.userの中身をデバッガーで見てみると、JWTトークンの内容とは少し違うようですね。

どうやらセキュリティ上の理由でトークンのプロパティの一部だけがuserオブジェクトにコピーされるそうです。そこで、Session Callbackでtokenの値をuserに追加でコピーしてあげます。ここの例ではroleをコピーしています。

app/options.ts
            session: ({ session, token }) => {
                console.log("in session", { session, token });
                return {
                    ...session,
                    user: {
                        ...session.user,
                        role: token.role, // tokenの値をコピーしとく
                    },
                };
            },

するとめでたく、roleがuserオブジェクトにも設定されたのが確認できました。

ログ出力。userオブジェクトの内容
{ name: 'abc@abc', email: 'abc@abc', image: undefined, role: 'admin' }

サーバー・コンポーネント

以前作成したログイン画面を改造します。getServerSession()をawaitで呼ぶとsessionが取得できます。先ほどsession callbackで仕込んだオブジェクトです。userオブジェクトの存在チェックでログイン/ログアウト状態を判定します。それ応じてボタンを出し分けます。

app/login/page.tsx
(説明に不要な箇所は削っています)
import {getServerSession} from "next-auth/next";
import {options} from "@/app/options";

export default async function Home() {
    const session = await getServerSession(options)
    const user = session?.user // ログインしていなければnullになる。

    return (
        <main
            style={{...}}
        >
            <div>
                <div>{`${JSON.stringify(user)}`}</div>
                {user ? <div>Logged in</div> : <div>Not logged in</div>}
                {user ? <LogoutButton/> : <LoginButton/>}
            </div>
        </main>
    );
}

他にも、ロールで分岐処理させたり、GitHubのトークンを取り出してGitHubのAPIを叩くなどのユースケースが考えられます。

サーバ・アクション

サーバ・アクションとはRPC風にクライアントからサーバーの関数を叩く仕組みです。APIの代替となる方式になります。この際に、middlewareやauthは機能するのか気になるところですので調査しました。

login-server-action/page.tsxを作成します。login*にマッチするのでmiddlewareはバイパスします。サーバ・アクションはRPC的なものですがエンドポイントはこのフォームを表示したパスになります。

login-server-action
import {getServerSession} from "next-auth/next";
import {options} from "@/app/options";

export default function ActionForm() {
    // サーバ・アクション
    async function addItem(formData: FormData) {
        'use server'; // This is required for server actions.

        const session = await getServerSession(options)
        const user = session?.user

        console.log('user:', user)
    }

    return (
        <div>
            <form action={addItem}>
                <input type="text" name="id" defaultValue="A001" className="bg-gray-500"/>
                <button type="submit">カートに追加</button>
            </form>
        </div>
    )
}

正常にuserが取得できました。

実行結果
user: {
  name: 'TF',
  email: 'tf@gmail.com',
  image: 'https://lh3.googleusercontent.com/...',
  role: undefined,
  accessToken: 'ya29.a0A...' // session callbackでセットしてます。
}

次に、ミドルウェアは呼ばれるのかを検証します。フォルダ名をserver-actionに変更し、middlewareを通るようにします。

デバッガーでmiddleware.tsにブレーク・ポイントを設置して確認します。ご覧のようにリクエストが通過したのが分かります。

クライアント・コンポーネント

SessionProvideruseSession()を使用します。使い回せるように、以下のようにNextAuthProviderを作ります。

app/providers.tsx
"use client";

import {SessionProvider} from "next-auth/react";

type Props = {
    children?: React.ReactNode;
};

export const NextAuthProvider = ({children}: Props) => {
    return <SessionProvider>{children}</SessionProvider>;
};

前回作成したログイン画面をクライアント・コンポーネントに改造してみましょう。
先ほど作成したNextAuthProviderでラップします。そうすると、ログイン画面・コンポーネントから、useSession()でセッション情報を取得できます。

app/login-client
'use client';

import {NextAuthProvider} from "@/app/providers";
import {useSession} from "next-auth/react";

export default async function Home() {
    return (
        <NextAuthProvider>
            <ClientHome/>    // ここにログイン画面部品を挟みます。
        </NextAuthProvider>
    )
}

function ClientHome() {
    const {data: session} = useSession();
    const user = session?.user

    useEffect(() => {
            console.log("session.user", session?.user)
        }
        , [session])

    return (
        <main
            style={{...}}
        >
            <div>
                <div>{`${JSON.stringify(user)}`}</div>
                {user ? <div>Logged in</div> : <div>Not logged in</div>}
                {user ? <LogoutButton/> : <LoginButton/>}
            </div>
        </main>
    );
}

Chrome Dev Toolで確認すると、HTTPリクエストを投げてサーバー側で認証処理をしているのが分かります。

Vercelへのデプロイ

環境変数をVercelのダッシュボード> Project Settings > Environment Variables
から設定する必要があります。

NEXTAUTH_SECRET
GITHUB_ID
GITHUB_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET

OAuthのコールバックのURLを本番環境のエンドポイントに変更してください。
ex. ) http://localhost:3000/api/auth/callback/github Vercelのドメインに向ける。

参考

https://codevoweb.com/setup-and-use-nextauth-in-nextjs-13-app-directory/

Discussion

Qiushi PanQiushi Pan

SessionProviderってsessionを引数に渡す必要ないんですか?