<-- mermaid -->

Next.js App Router で Keycloak と @auth0/nextjs-auth0 を利用してマルチテナント認証機能を実装する

初めまして、 @takano-hi です。 2023年2月に AlphaDrive にジョインして、主にフロントエンド領域を中心に設計・実装などの業務を担当しています。

最近、Next.js のプロジェクトを新たに立ち上げる機会があり、せっかくなので App Router を採用しました。 そのプロジェクトの認証機能の実装に当たり、今まで他プロジェクトでも利用していた Keycloak@auth0/nextjs-auth0 の組み合わせを試したところいくつかの困難に遭遇したので、その解決方法についてまとめようと思います。

環境

  • next v13.4.9
  • @auth0/nextjs-auth0 v3.1.0
  • keycloak v20.0.1

ライブラリの選定背景

私が所属しているチームでは、認証基盤(IDプロバイダー)に Keycloak を利用しています。 Keycloak は OpenID Connect の仕様に則って動作するため、フロントエンドでも OpenID Connect に沿ったクライアントライブラリであれば正常に動作することが期待されます。 その意味では NextAuth.js でも良かったのですが、このライブラリは(少なくとも私たちのサービス開発を開始した当初は)サービスに必要な下記の機能要件に対応していませんでした。

私たちが開発しているプロダクトはマルチテナント型の SaaS で、テナントごとにサブドメインが割り当てられています。 そのためログイン後のリダイレクト URL を認証の度に動的に切り替えることができる必要があります。 この要件に対応したライブラリとして @auth0/nextjs-auth0 が選定されました。

@auth0/nextjs-auth0 の導入方法

さて、それでは App Router に @auth0/nextjs-auth0 を導入する方法を見ていきましょう。 まずはライブラリをインストールします。

$ npm install --save @auth0/nextjs-auth0

本記事の執筆時点の最新バージョンである v3.1.0 がインストールされました。 最初は app/layout.tsx に UserProvider を追加します。

// app/layout.tsx
import { UserProvider } from "@auth0/nextjs-auth0/client";

import { Inter } from "next/font/google";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <UserProvider>
        <body className={inter.className}>{children}</body>
      </UserProvider>
    </html>
  );
}

※ UserProvider は React の Context に依存しますが、 RSC である app/layout.tsx 上でも利用できるようにライブラリ側でケアされています。

github.com

次に認証用のエンドポイントを Route Handler で実装していきます。通常は下記のようにシンプルな記述で全ての認証用エンドポイントが実装完了します。

// app/api/auth/[auth0]/route.ts
import { handleAuth } from "@auth0/nextjs-auth0";

export const GET = handleAuth();

ただ今回はマルチテナントでサブドメインが動的に切り替わるという要件があるため、ここに手を加えていきます。

// app/api/auth/[auth0]/route.ts
export const GET = handleAuth({
  login: (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
    const returnTo = new URL("/", req.url); // ログイン完了後に遷移するページ
    const callbackUrl = new URL("/api/auth/callback", req.url); // Keycloak での認証後にリダイレクトされる Callback URL

    return handleLogin(req, ctx, {
      returnTo,
      authorizationParams: {
        redirect_uri: callbackUrl,
      },
    });
  },
  callback: (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
    const callbackUrl = new URL("/api/auth/callback", req.url);

    return handleCallback(req, ctx, { redirectUri: callbackUrl });
  },
  logout: (req: NextRequest, ctx: AppRouteHandlerFnContext) => {
    const returnTo = new URL("/", req.url);

    return handleLogout(req, ctx, { returnTo });
  },
});

また必要な環境変数を追加していきます。

# .env
AUTH0_CLIENT_ID=client-id # Keycloak 側に登録した realm の ID
AUTH0_SCOPE=openid profile email offline_access
AUTH0_LOGOUT=false # ←ポイント

AUTH0_BASE_URL=xxx
AUTH0_ISSUER_BASE_URL=xxx
AUTH0_SECRET=xxx
AUTH0_CLIENT_SECRET=xxx

ここで AUTH0_LOGOUT=false という環境変数を設定していますが、ここがポイントです。 @auth0/nextjs-auth0 はあくまで Auth0 と統合するためのライブラリなので、今回のように Auth0 以外の ID プロバイダーを利用する場合には、 Auth0 向けの挙動を OFF にする必要があります。

詳細はこちらのドキュメントを参照してください。 auth0.github.io

最後に Keycloak 側の管理画面で、該当の realm に対して Valid redirect URIs と Valid post logout redirect URIs を登録すれば完成です。 Next.js で立ち上げたアプリケーションの /api/auth/login にアクセスすると Keycloak の認証画面が表示され、正しい ID/PW を入力すると元のアプリケーションに遷移します。 UserProvider が自動で /api/auth/me を fetch して、ユーザー情報が返ってくることからログインできていることが確認できます。 また /api/auth/logout にアクセスすると一瞬 Keycloak に遷移したと思ったら元のアプリケーションに戻ってきて、上記の /api/auth/me が 204 レスポンスを返していることを確認できます。

ビルド時に TypeError: "secret" is required というエラーが出る問題

ここまでで基本的な実装方法については終わりなのですが、余談として私が遭遇したエラーについて解説させてください。

ローカル開発環境でうまく動作したのでステージング環境にデプロイしようとしたところ、ビルド時に TypeError: "secret" is required というエラーが発生しました。 これは環境変数 AUTH0_SECRET が設定されていない実行環境で @auth0/nextjs-auth0 を import すると発生するエラーです。 App Router を採用した Next.js アプリケーションをビルドしようとする際、どうやら app/**/route.ts が評価されるタイミングがあるようで、その中で @auth0/nextjs-auth0 の初期化処理が実行されているようでした。

私たちのアプリケーションは、秘匿すべき環境変数を AWS Secrets Manager で管理し、デプロイ時に ECS 上のコンテナに注入しています。 イメージをビルドする際は CodeBuild で npm run build を実行しているため、そのビルド実行環境には AUTH0_SECRET をはじめ @auth0/nextjs-auth0 の利用に必要な環境変数は設定されていません。

もちろん CodeBuild の実行環境に AWS Secrets Manager から環境変数を読み込むことは可能ですが、本来そこで使う必要のないシークレットをビルドのためだけに渡すことには抵抗がありました。 そこで実装側で、ビルド時は @auth0/nextjs-auth0 を評価せず、実行時のみ評価されるようにするために dynamic import を利用する形式に書き換えました。

// app/api/auth/[auth0]/route.ts
import { NextRequest } from "next/server";

type RequestContext = {
  params: Record<string, string | string[]>;
}

export const GET = async (request: NextRequest, context: RequestContext) => {
  const {
    handleAuth,
    handleCallback,
    handleLogin,
    handleLogout,
  } = await import("@auth0/nextjs-auth0");

  return handleAuth({
    login: (req: NextRequest, ctx: RequestContext) => {
      const returnTo = new URL("/", req.url);
      const callbackUrl = new URL("/api/auth/callback", req.url);

      return handleLogin(req, ctx, {
        returnTo,
        authorizationParams: {
          redirect_uri: callbackUrl,
        },
      });
    },
    callback: (req: NextRequest, ctx: RequestContext) => {
      const callbackUrl = new URL("/api/auth/callback", req.url);

      return handleCallback(req, ctx, { redirectUri: callbackUrl });
    },
    logout: (req: NextRequest, ctx: RequestContext) => {
      const returnTo = new URL("/", req.url);

      return handleLogout(req, ctx, { returnTo });
    },
  })(request, context);
}

こうすることでビルド時に @auth0/nextjs-auth0 が評価されることはなくなり、当初のエラーを回避することができます。

Page top