🔐

Microsoft Entra ID × Next.jsで実装する認証機能(Auth.js + @azure/msal-node)

2025/04/13に公開

はじめに

Microsoft 社が提供する認証・認可サービスである Microsoft Entra ID(旧称:Azure Active Directory)と、Next.js App Router(RSC) を組み合わせた認証機能の実装について解説します。

最近、Microsoft Entra ID を利用したシングルサインオン認証(SSO)を実装する機会がありました。その際、CSRベースの SPA 構成での参考記事は多く見つかりましたが、Next.js App Router(RSC)や Auth.js、RouteHandler、Middleware を活用した実装例はあまり多くなかったため、本記事でまとめて紹介します。

対象読者

  • Next.js App Router(RSC)構成で、Microsoft Entra IDを用いた認証の導入を検討している方
  • 認可コードフロー(Authorization Code Flow)の認証処理、アクセス制御、セッション管理、トークン更新処理など、Next.js 側の認証機構の実装のサンプルが欲しい方

技術スタック

サンプルリポジトリ

今回の記事の内容を実装したサンプルリポジトリを用意しました。記事と合わせてご覧ください。

https://github.com/kokoro-hart/microsoft-entra-id-nextauth

実装する認証フロー

本記事で紹介するサンプル実装では、以下の3つの処理を実装しています。

  1. ログイン処理(Auth.js + Microsoft Entra ID)
  2. トークンリフレッシュ処理(@azure/msal-nodeを用いた再認証)
  3. アクセス制御(Next.js Middleware/ServerComponent)

以下は、簡単に図にしたものです。
※Auth.jsが隠蔽してくれている部分も多く、OAuth2/OIDCの認可コード受け取りやセッションの暗号化保存などの処理は抽象化されており、実装コードは比較的シンプルです。

mermaid
sequenceDiagram
  participant User
  participant Browser
  participant Middleware
  participant Server as Server
  participant EntraID

  Note over User,Browser: 初回ログイン
  User->>Browser: ページにアクセス
  Browser->>Middleware: リクエスト送信
  Middleware->>Browser: /signin にリダイレクト
  Browser->>Server: /signin → 認可コード取得開始
  Server->>EntraID: 認可コードフロー実行
  EntraID->>Server: IDトークン + アクセストークン + リフレッシュトークン
  Server->>Browser: セッションJWTをCookieに保存

  Note over Browser, Server: ページ閲覧中のセッション確認
  Browser->>Server: `auth()` を用いてセッション取得
  alt accessToken が有効
    Server->>Browser: セッション情報を返却
  else accessToken が期限切れ
    Server->>EntraID: refreshToken を使ってトークン再取得
    EntraID->>Server: 新しい accessToken / idToken / expiresAt
    Server->>Browser: JWT を更新して保存
  end

  Note over Browser, Middleware: 有効なセッションで保護ページに再アクセス
  Browser->>Middleware: ページリクエスト
  Middleware->>Browser: アクセス許可

事前準備:Entra IDの設定

Microsoft Entra ID アプリ登録

Microsoft Entra ID でアプリを認証するためには、Azure Portal でアプリを登録する必要があります。
Azure Portal にログインし、Microsoft Entra ID の画面に移動します。
その後、「アプリの登録」>「新規登録」を選択します。

任意の名前、サポートされているアカウントの種類(この組織ディレクトリのみに含まれるアカウント)を選択し、リダイレクト URI を web http://localhost:3000/auth/callback/microsoft-entra-id に設定します。

クライアントシークレットの作成

続いて、クライアントシークレットを作成します。
Microsoft Entra ID に登録したアプリのページに移動し、「証明書とシークレット」を選択します。
「新しいクライアントシークレット」を選択し、任意の名前を入力し、シークレットを作成します。
このシークレットは、アプリケーションの実装時に使用するので、コピーしておきます。

認証機能の実装

ディレクトリ構成

今回はサンプルなので以下のようなシンプルな構成です。

src/
├── app/
│   ├── (protected)/           # 認証が必要なルート
│   │    └── page.tsx          # 認証が必要なページ
│   ├── signin/                # サインインページ
│   └── api/auth/[...nextauth]/route.ts # Auth.jsエントリーポイントを呼び出すRouteHandler
├── auth.ts                   # Auth.jsエントリーポイント
├── auth.config.ts            # Auth.jsの設定
├── middleware.ts             # ミドルウェア(ルート制御)

Next.jsプロジェクトを作成

以下のコマンドで Next.js のプロジェクトを作成します。

npx create-next-app@latest

必要なライブラリのインストール

以下のコマンドで Auth.js と @azure/msal-node、jsonwebtoken をインストールします。

npm i next-auth@beta @azure/msal-node jsonwebtoken

環境変数の設定

.env ファイルに、Auth.js および Microsoft Entra ID の認証に必要な環境変数を設定します。

.env
# Auth.js
AUTH_TRUST_HOST=true
AUTH_URL=http://localhost:3000
AUTH_SECRET=your_random_secret

# Entra ID
AUTH_MICROSOFT_ENTRA_ID_TENANT_ID=your_tenant_id
AUTH_MICROSOFT_ENTRA_ID_CLIENT_ID=your_client_id
AUTH_MICROSOFT_ENTRA_ID_SECRET=your_client_secret
  • AUTH_TRUST_HOST: リクエストの Host ヘッダーや URL 情報を Auth.js が信頼して使用できるかどうかを示す設定です
  • AUTH_URL: 認証フロー内で使用するアプリケーションの URL。本番環境では必ず https:// を指定してください(セッションクッキーの Secure フラグに影響します)
  • AUTH_SECRET: セッショントークン(JWT)の暗号化・署名に使われるシークレット文字列。ランダムで十分に強度のある値を設定する必要があります。
    • openssl rand -base64 32 などで生成するのが推奨されています。
  • AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: Entra ID のテナント ID(Azure Portalで確認可能)
  • AUTH_MICROSOFT_ENTRA_ID_CLIENT_ID: アプリ登録時に発行されるクライアント ID
  • AUTH_MICROSOFT_ENTRA_ID_SECRET: アプリ登録後に作成したクライアントシークレット

Auth.js(MicrosoftEntraIDプロバイダ)の設定とトークンリフレッシュの実装

Auth.jsのMicrosoftEntraIDプロバイダを利用した設定と、Microsoft Entra ID から取得したリフレッシュトークンを使ってアクセストークンを更新する仕組みを実装します。

  • Auth.jsのMicrosoftEntraIDプロバイダを利用して認証処理
  • @azure/msal-nodeのacquireTokenByRefreshTokenを利用してトークンリフレッシュ処理
  • 認証時に取得した受け取ったユーザー情報などをセッションに転送

を行います。

https://authjs.dev/reference/core/providers/microsoft-entra-id

src/auth.config.ts
import jwt, { JwtPayload } from "jsonwebtoken";

import type { NextAuthConfig } from "next-auth";
import { ConfidentialClientApplication } from "@azure/msal-node";
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";

const LoggerMessages = {
  emptyAccessToken: "アクセストークンが取得できませんでした",
  emptySub: "ユーザー識別子 (sub) が取得できませんでした",
  expiredAccessToken: "アクセストークンの有効期限が切れています",
  startedToRefreshAccessToken: "アクセストークンの更新を開始します",
  failedToRefreshAccessToken: "アクセストークンの更新に失敗しました",
  successToRefreshAccessToken: "アクセストークンの更新に成功しました",
} as const;

export const ErrorCodes = {
  emptyAccessToken: "EMPTY_ACCESS_TOKEN",
  emptySub: "EMPTY_SUB",
  failedToRefreshAccessToken: "FAILED_TO_REFRESH_ACCESS_TOKEN",
} as const;

const msalInstance = new ConfidentialClientApplication({
  auth: {
    clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_CLIENT_ID!,
    authority: `https://login.microsoftonline.com/${process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID}/v2.0`,
    clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
  },
});

/**
 * refreshAccessToken
 * リフレッシュトークンを使用してアクセストークンを更新
 *
 * @param refreshToken - リフレッシュトークン
 */
async function refreshAccessToken(refreshToken: string) {
  console.log("refreshAccessToken", LoggerMessages.startedToRefreshAccessToken);
  try {
    const response = await msalInstance.acquireTokenByRefreshToken({
      refreshToken,
      scopes: ["openid", "profile", "email"],
    });

    if (!response?.accessToken) {
      throw new Error(LoggerMessages.failedToRefreshAccessToken);
    }
    console.log("refreshAccessToken", LoggerMessages.successToRefreshAccessToken);

    return {
      idToken: response.idToken,
      accessToken: response.accessToken,
      expiresAt: response.expiresOn?.getTime() ?? Date.now() + 3600 * 1000,
    };
  } catch (error) {
    console.error("refreshAccessToken", LoggerMessages.failedToRefreshAccessToken, error);
    return null;
  }
}

/**
 * NextAuth(Auth.js)の設定
 */
export const authConfig: NextAuthConfig = {
  secret: process.env.AUTH_SECRET,
  trustHost: true,
  providers: [
    MicrosoftEntraID({
      clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_CLIENT_ID,
      clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
      issuer: `https://login.microsoftonline.com/${process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID}/v2.0`,
      authorization: {
        params: {
          scope: "openid profile email offline_access",
        },
      },
    }),
  ],
  callbacks: {
    async jwt({ token, account, profile }) {
      if (account) {
        const { id_token, access_token, refresh_token, expires_in } = account;
        if (id_token) {
          const decoded = jwt.decode(id_token) as JwtPayload;
          token.emailVerified = decoded?.email_verified ?? null;
        }
        token.accessToken = access_token ?? token.accessToken;
        token.refreshToken = refresh_token ?? token.refreshToken;
        token.expiresAt = expires_in ? Date.now() + expires_in * 1000 : token.expiresAt;
        token.error = undefined;
      }

      if (profile) {
        token.sub = profile.sub ?? token.sub;
        token.email = profile.email ?? token.email;
        token.name = profile.name ?? token.name;
      }

      if (!token.accessToken) {
        console.error("authConfig.callback.jwt", LoggerMessages.emptyAccessToken);
        token.error = ErrorCodes.emptyAccessToken;
      }

      if (!token.sub) {
        console.error("authConfig.callback.jwt", LoggerMessages.emptySub);
        token.error = ErrorCodes.emptySub;
      }

      // アクセストークンの有効期限が切れている場合
      // リフレッシュトークンを使用してトークンを更新
      const isExpiredToken = token.expiresAt && Date.now() >= token.expiresAt;
      if (isExpiredToken && token.refreshToken) {
        const refreshedTokens = await refreshAccessToken(token.refreshToken);
        if (refreshedTokens && refreshedTokens.accessToken) {
          token.idToken = refreshedTokens.idToken;
          token.accessToken = refreshedTokens.accessToken;
          token.expiresAt = refreshedTokens.expiresAt;
          token.error = undefined;
        } else {
          token.error = ErrorCodes.failedToRefreshAccessToken;
        }
      }

      return token;
    },
    async session({ session, token }) {
      session.user = {
        id: token.sub!,
        name: token.name!,
        email: token.email!,
        emailVerified: token.emailVerified ?? null,
      };
      session.error = token.error ?? null;
      return session;
    },
  },
};

JWT callbackの処理

callbacks.jwt() は、JWT が作成されるまたは更新される(サインイン時/クライアントでセッションにアクセス)たびに呼び出されます。返される値はAUTH_SECRETを元に暗号化され、HttpOnlyクッキーに保存されます。ここでは最初のログイン時に Microsoft Entra ID から返される accessToken や refreshToken、idToken を JWT トークンとして保存します。以降はこのトークンが Cookie を通して管理され、セッション取得時に活用されます。

Session callbackの処理

callbacks.session() は、セッションがチェックされるたびに呼び出されます。デフォルトでは、セキュリティを高めるためにトークンの一部のみが返されます。callbacks.jwt() でトークンに追加したものをクライアントに利用可能にしたい場合は、ここで明示的にそれを転送する必要があります。
上記では、基本的なユーザー情報を転送しています。

また、アクセストークンの有効期限が切れた際には、refreshToken を使って accessToken を更新する処理を追加することで、ユーザーにとってシームレスな認証体験を実現できます。MSAL(@azure/msal-node)を活用して、Entra ID との連携が簡単に行えます。

Auth.jsをRouteHandlerで呼び出す

Entra ID(Microsoft OAuth)からのコールバック時に受け取るエンドポイントをRouteHandlerに定義します。

src/auth.ts
import NextAuth from "next-auth";

import { authConfig } from "./auth.config";

const {
  auth,
  signIn,
  signOut,
  handlers: { GET, POST },
} = NextAuth(authConfig);

export { GET, POST, auth, signIn, signOut };
app/api/auth/[...nextauth]/route.ts
export { GET, POST } from "@/auth";

Entra ID 側には「リダイレクトURI」として、http://localhost:3000/auth/callback/microsoft-entra-idを設定しているので、コールバック時にRouteHandlerが受け取り、Auth.jsが内部処理を実行してくれます。

ログイン処理の実装

サインインページで signIn() 呼び出します。
この signIn() は、Auth.js のクライアント側 API を利用して Entra ID の認可コードフローを開始するトリガーです。
第1引数にプロバイダ名(この場合は "microsoft-entra-id")を渡すことで、内部的に /api/auth/signin/microsoft-entra-id にリダイレクトが発生し、Microsoft のログイン画面へ誘導されます。

第3引数には認可リクエストのパラメータを追加できます(例:prompt, login_hint, domain_hint など)。prompt=select_account を指定することで、EntraID側にセッションがあればアカウント選択を促すことができます。

app/signin/page.tsx
"use client";

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

export default function SignInPage() {
  const handleSignIn = () => {
    signIn(
      "microsoft-entra-id",
      {
        redirect: true,
      },
      {
        prompt: "select_account",
      }
    );
  };

  return (
    <button type="button" onClick={handleSignIn}>
      Sign in with Microsoft
    </button>
  );
}

要認証ページでのセッションの取得

サーバーコンポーネントでは、src/auth.tsで定義したauth関数を使用してセッションを取得できます。

app/(protected)/page.tsx
import { auth } from '@/auth';
import React from 'react';

export default async function Page() {
  const session = await auth();

  console.table(session);

  return <div>MyAccountPage</div>;
}

ログイン状態に応じたアクセス制御の実装(Middleware/ServerComponent)

Next.js は、Middlewareを用いて、リクエストが完了する前に、リクエストに対して何らかの処理を行うことができます。
以下Middlewareでは、auth() 関数を用いてリクエスト時点でのセッションを取得し、保護されたページにアクセスしようとしたユーザーが未認証の場合は /signin にリダイレクトします。

src/middleware.ts
import { auth } from "./auth";

const ROUTES = {
  PUBLIC: [],
  AUTH: ["/signin"],
  PROTECTED: ["/"],
};

function isProtectedRoute(path: string) {
  return ROUTES.PROTECTED.includes(path);
}

function isAuthRoute(path: string) {
  return ROUTES.AUTH.includes(path);
}

export default auth((req) => {
  const { nextUrl, auth: session } = req;
  const pathname = nextUrl.pathname;

  const isLoggedIn = !!session;

  // 認証が必要なページにアクセスした場合
  // ログインしている場合はアクセスを許可し、未ログインの場合はログインページにリダイレクト
  if (isProtectedRoute(pathname) && (!isLoggedIn || session?.error)) {
    return Response.redirect(new URL("/signIn", nextUrl));
  }

  // 認証済みで認証ページにアクセスした場合は、TOP にリダイレクト
  if (isAuthRoute(pathname) && isLoggedIn) {
    return Response.redirect(new URL("/", nextUrl));
  }
});

export const config = {
  // middleware対象を指定(apiや静的ファイル等は除外)
  matcher: ["/((?!api|_next/static|_next/image|.png).*)"],
};

Azure App Service などEdge Runtime がサポートされていないホスティング環境では、middleware.ts の利用が制限されることがあります。
このような環境では、上記のruntime: 'nodejs'フラグを追加するか、以下のように Server Component レベルでの認可制御に処理を寄せる構成が推奨されます。(※AuthGuardProvider的なコンポーネントで共通化するのが良さそうです)

app/(protected)/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function Page() {
  const session = await auth();
  if (!session || session.error) {
    redirect("/signin");
  }
  return <div>ようこそ!</div>;
}

まとめ

本記事では、Next.js App Router(RSC構成)と Microsoft Entra ID を組み合わせたシングルサインオン認証の実装方法を紹介しました。

  • Auth.js を用いた認可コードフローのログイン処理
  • @azure/msal-node を用いたアクセストークンのリフレッシュ処理
  • ミドルウェア・サーバーコンポーネントそれぞれの認可制御パターン

参考文献

https://www.microsoft.com/ja-jp/security/business/identity-access/microsoft-entra-id
https://learn.microsoft.com/ja-jp/entra/identity-platform/msal-overview
https://authjs.dev/reference/core/providers/microsoft-entra-id
https://zenn.dev/tsuboi/books/3f7a3056014458

株式会社FLAT テックブログ

Discussion