KeycloakとNext.jsによるシングルサインオン(SSO)実装ガイド:OpenID Connect(OIDC)標準統合

Development tutorial - IT technology blog
Development tutorial - IT technology blog

午前2時のセッション悪夢

午前2時15分、PagerDutyのアラートがけたたましく鳴り響きました。夜勤スタッフ300名以上が一斉にERPシステムへログインしたところ、完全にスタックしてしまったのです。メインポータルから分析ダッシュボードへ遷移しようとすると、即座にログイン画面へ弾き返され、無限リダイレクトループが発生。5分で切れるアクセストークンをクライアント側でリフレッシュできず、同時に10以上のマイクロサービスから認証問い合わせが殺到した結果、ユーザーデータベースのコネクションプールは上限の100に達していました。

以前は各開発チームが個別に認証モジュールを構築していました。あるチームは独自生成のJWTを使い、別のチームはRedis上のセッションCookieを採用するといった具合です。システム規模の拡大に伴い、こうした継ぎはぎのアーキテクチャの保守には毎週何時間ものデバッグ工数が奪われていました。この混沌とした状況を根本から一掃するため、シングルサインオン(SSO)への移行はもはや不可避の選択でした。

当時、私たちのチームは50,000行に及ぶコードベースからレガシーな認証層を完全に分離するミッションを任されました。そこで得た教訓は、「認証基盤に手を入れる際は、コードを書く前に徹底したテストカバレッジを用意すべき」ということです。サーバー間でわずか3秒のタイムラグ(時刻ズレ)があるだけで、あるいはペイロード内のクレーム名を1文字打ち間違えるだけで、すべてのユーザーが即座にシステムから締め出されてしまいます。

KeycloakとNext.js間のOIDCフローを正しく理解する

迅速なデバッグを行うには、OAuth 2.0上でアイデンティティレイヤーを提供するOpenID Connect(OIDC)の仕組みを正確に把握しておく必要があります。

1. Identity Provider(IdP)とRelying Party(RP)

このアーキテクチャは主に2つのアクターで構成されます。

  • Keycloak(IdP: Identity Provider): ユーザー情報の保持、トークンの発行、二要素認証(2FA)の処理、ロールベースのアクセス制御(Realm Roles)を担当します。
  • Next.js(RP: Relying Party): パスワードの保持やハッシュ化は行いません。認証処理のすべてをKeycloakへ委譲し、返却されたトークンの電子署名のみを検証・信頼します。

2. PKCE対応 Authorization Code Flow

認証シーケンスは以下の5ステップで進行します。

  1. ユーザーがNext.js上の保護されたルート(例: /dashboard)にアクセスします。
  2. Next.js側でセッションを検証します。存在しない場合、client_id、redirect_uri、PKCEのコードチャレンジを付与してブラウザをKeycloakへリダイレクトします。
  3. ユーザーがKeycloakのログイン画面で認証情報を入力します。
  4. 認証完了後、Keycloakは使い捨てのauthorization_codeを伴ってNext.jsへリダイレクトバックします。
  5. Next.jsサーバーがバックエンド間通信(Backend-to-Backend)でこのコードをKeycloakへ送信します。Keycloakが検証を行い、IDトークンとアクセストークンのペアを返却します。

インフラ構築からソースコード実装までの手順

ステップ1: Docker ComposeによるローカルKeycloak環境の構築

ステージング環境を忠実に再現するため、PostgreSQLデータベースと組み合わせたKeycloakコンテナを立ち上げます。

# docker-compose.yml
services:
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak_user
      POSTGRES_PASSWORD: super_secure_db_pass
    volumes:
      - pgdata:/var/lib/postgresql/data

  keycloak:
    image: quay.io/keycloak/keycloak:24.0
    command: start-dev
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
      KC_DB_USERNAME: keycloak_user
      KC_DB_PASSWORD: super_secure_db_pass
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin_password_here
    ports:
      - "8080:8080"
    depends_on:
      - postgres

volumes:
  pgdata:

次のコマンドを実行してコンテナを起動します。

docker compose up -d

注意: start-devフラグはローカル開発環境専用です。本番環境へのデプロイ時は、適切なSSL証明書を設定した上でstart --optimizedコマンドを使用してください。

ステップ2: Keycloak管理コンソールでのRealmとClientの設定

ブラウザでhttp://localhost:8080を開き、管理者アカウントでログインして以下の項目を設定します。

  • Realm: internal-corpという名前で新規Realmを作成します。社内向けアプリケーションでデフォルトのmasterレルムをそのまま利用することは避けてください。
  • Client: Client IDをnextjs-portalとして作成します。
  • Client Authentication: On(コンフィデンシャルクライアント)に設定します。Next.jsはサーバーサイド(Node.js環境)で動作するため、シークレットを安全に保持できます。
  • Valid Redirect URIs: http://localhost:3000/api/auth/callback/keycloakを入力します。
  • Web Origins: http://localhost:3000を入力します。
  • Credentials: 作成したクライアントのCredentialsタブへ移動し、Client Secretの値をコピーします。
  • テストユーザーの作成: Usersメニューからtestuserを作成し、Credentialsタブで固定パスワードを設定します(Temporaryオプションはオフにしてください)。

ステップ3: Next.js App RouterでのNextAuthのインストールと構成

認証ライブラリをインストールします。

npm install next-auth

.env.localファイルに必要な環境変数を追加します。

KEYCLOAK_ID=nextjs-portal
KEYCLOAK_SECRET=ステップ2で取得したClient_Secret文字列
KEYCLOAK_ISSUER=http://localhost:8080/realms/internal-corp
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=openssl_rand_hex_32で生成した32文字のランダム文字列

app/api/auth/[...nextauth]/route.tsにルートハンドラーを作成します。以下のコードはロールの抽出とトークンの永続化を実装したものです。

import NextAuth, { NextAuthOptions } from "next-auth";
import KeycloakProvider from "next-auth/providers/keycloak";

export const authOptions: NextAuthOptions = {
  providers: [
    KeycloakProvider({
      clientId: process.env.KEYCLOAK_ID!,
      clientSecret: process.env.KEYCLOAK_SECRET!,
      issuer: process.env.KEYCLOAK_ISSUER!,
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      // access_tokenとrefresh_tokenを保持し、有効期限(ミリ秒)を算出
      if (account) {
        token.accessToken = account.access_token;
        token.refreshToken = account.refresh_token;
        token.accessTokenExpires = (account.expires_at ?? 0) * 1000;
      }
      if (profile) {
        token.roles = (profile as any).realm_access?.roles || [];
      }
      return token;
    },
    async session({ session, token }) {
      (session as any).accessToken = token.accessToken;
      (session as any).roles = token.roles;
      (session as any).error = token.error;
      return session;
    },
  },
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

ステップ4: Middlewareによるアクセス制御

個々のページコンポーネントで開発者に認可チェックの実装を強いるべきではありません。プロジェクトルート直下にmiddleware.tsを配置し、エッジ層で不正アクセスを確実にインターセプトします。

import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";

export default withAuth(
  function middleware(req) {
    const token = req.nextauth.token;
    const pathname = req.nextUrl.pathname;

    // 管理者ルートに対して'admin'ロールを保持していない場合は認可エラー画面へ書き換え
    if (pathname.startsWith("/admin") && !token?.roles?.includes("admin")) {
      return NextResponse.rewrite(new URL("/unauthorized", req.url));
    }
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token,
    },
  }
);

export const config = {
  matcher: ["/dashboard/:path*", "/admin/:path*"],
};

ステップ5: バックグラウンドでのアクセストークン自動更新(サイレントリフレッシュ)

Keycloakのデフォルト設定では、アクセストークンの有効期限はわずか5分(300秒)です。ユーザーがフォームを入力している最中にトークンが切れると、その後のバックエンドリクエストは401 Unauthorizedエラーとなってしまいます。

この問題を回避するため、トークン再取得関数を実装し、route.ts内のjwtコールバックに組み込みます。

async function refreshAccessToken(token: any) {
  try {
    const url = `${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`;
    const response = await fetch(url, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: process.env.KEYCLOAK_ID!,
        client_secret: process.env.KEYCLOAK_SECRET!,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken,
      }),
    });

    const refreshedTokens = await response.json();
    if (!response.ok) throw refreshedTokens;

    return {
      ...token,
      accessToken: refreshedTokens.access_token,
      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
    };
  } catch (error) {
    return { ...token, error: "RefreshAccessTokenError" };
  }
}

// NextAuthOptionsのcallbacks内:
async jwt({ token, account, profile }) {
  if (account && profile) {
    return {
      accessToken: account.access_token,
      accessTokenExpires: (account.expires_at ?? 0) * 1000,
      refreshToken: account.refresh_token,
      roles: (profile as any).realm_access?.roles || [],
    };
  }

  // 有効期限内の場合は既存トークンを返却(60秒のマージンを設ける)
  if (Date.now() < (token.accessTokenExpires as number) - 60000) {
    return token;
  }

  // 期限切れ間近または切れている場合、新しいトークンを自動取得
  return refreshAccessToken(token);
}

本番運用における3つの重要ポイント

SSO基盤はシステム全体における最大のボトルネックとなり得ます。SSOがダウンすれば、連携するすべてのWebアプリケーションが停止します。本番環境へ投入する際は、以下の3点を徹底してください。

  • NTPによるサーバー時刻の厳密な同期: KeycloakサーバーとNext.jsサーバーの間でわずか2秒のクロックドリフトが生じるだけでも、token used before issuedエラーが発生します。すべてのホストでchronyやsystemd-timesyncdを必ず稼働させてください。
  • Client Secretの徹底した保護: シークレットをGitリポジトリにコミットしたり、Reactのクライアントサイドコードに露出させたりしてはなりません。トークン交換処理は必ずバックエンドのRoute Handler経由で完結させます。
  • 認証フローのE2Eテスト自動化: PlaywrightやCypressを用いて、「初回ログイン」「有効期限切れに伴う自動リフレッシュ」「管理者による権限剥奪時(セッション無効化)の即時キック」の3つのシナリオを網羅した自動テストを構築してください。
Share: