azure/msal-node を使って Entra ID (Azure AD) で認証、JWT トークンを取得する

表題の件、やりたくてCLINEに依頼したら一発でやってしまったので私は何も理解していません…。

ということで、このエントリで内容を検証することで理解を深めたいと思います。なお、このエントリの内容もCLINEがdesign.mdに出力してくれているので、それを写経している感じです。これが令和のプログラミング学習。

- 参考
    - https://zenn.dev/microsoft/articles/20231107_node_msal

### ディレクトリ構成

```
my-azure-app/
├─ app.js
├─ package.json
├─ .env
└─ views/
   └─ index.ejs
```

### 各ファイルの説明

- app.js
- views/index.ejs
- package.json
    - nodejsのパッケージ定義ファイル
- .env
    - 環境変数ファイル。jsの中にAzureのアプリIDやシークレットを書くわけにはいかないので、ここに書いている。

### 前提条件

AzurePortalでアプリ登録済

### 1.ブラウザからのアクセスの流れ

```mermaid
sequenceDiagram
participant B as Browser
participant P as package.json
participant S as Node.js (app.js)
participant V as views/index.ejs

Note over P:「main」や「start」スクリプトに<br>app.jsを指定している
B->>P: 実行 (npm start)
P-->>S: "start": "node app.js"
activate S
S->>S: ルート処理 (Express)
S->>V: レンダリング要求
activate V
V-->>S: レンダリングしたHTML
deactivate V
S-->>B: レスポンス(HTML)
deactivate S
```

ブラウザからアクセスし、

1. app.js
2. views/index.ejs

と読み込んでいる。なぜapp.jsが読み込まれるかというと、package.json中で指定しているから。

```json
{
  "name": "azure-app",
  "version": "1.0.0",
  "description": "A Node.js + Express sample for Microsoft Graph integration with Azure AD",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "@azure/msal-node": "^2.0.0",
    "axios": "^1.8.4",
    "dotenv": "^16.0.0",
    "ejs": "^3.1.8",
    "express": "^4.18.2",
    "express-session": "^1.17.3"
  }
}

```

「scripts」セクションの"name": "～" で設定したキーが「start」なので npm start で「node app.js」が実行される。それ以外のスクリプト名（例：「start2」）なら npm run start2 みたいに npm run <スクリプト名> と書いて呼び出す必要がある。

npm start の時点で、app.jsがサーバーとして待ち受けているイメージでOK。

### 2.app.js中の流れ

順番に見ていく

```js
// app.js: Azure AD と接続して Microsoft Graph API の /me を使うサンプル
// .env ファイルに下記環境変数を設定しておく想定:
// CLIENT_ID=************************
// CLIENT_SECRET=************************
// TENANT_ID=************************
// REDIRECT_URI=http://localhost:3000/redirect

"use strict";

require("dotenv").config();
const express = require("express");
const session = require("express-session");
const axios = require("axios");
const { ConfidentialClientApplication } = require("@azure/msal-node");

const app = express();
const PORT = 3000;

// EJS をテンプレートエンジンとして設定
app.set("view engine", "ejs");
app.set("views", "./views");
```
const で始まる変数定義の後に、EJSをテンプレートエンジンとして設定している。ここは本当に定義だけでview engine, ejsでEJSをエンジンとして設定、そのエンジンはviews, /viewsで/views配下を探してね、という指定になっている。

では、どうやってindex.ejsを読んでいるのかというと、後述のコードでres.render("index"～という部分があり、ここで指定している。仮にファイル名がindex2.ejsの場合、res.render("index2"～で指定できる。

```js
// セッション設定 (今回は簡易的にメモリストアを使用)
app.use(
  session({
    secret: "sample-session-secret-string",
    resave: false,
    saveUninitialized: false,
  })
);

```

Express.js のセッション機能を設定。

- secret: "sample-session-secret-string"

セッションIDを暗号化するためのキー。実際の運用では推測されにくい文字列を使う。

- resave: false

セッション内容に変更がなくても毎回セッションを再保存するかどうかの設定。falseにすると、何も変わらない場合は再保存しないので効率が良い。

- saveUninitialized: false

新規セッションだけど、何も書き込まれていない状態（未初期化のセッション）は保存しないという設定。これも無駄なメモリ消費を抑えられる利点がある。

なお、ここでは"メモリストアを指定"とあるが、特に明示的にメモリストアは指定していない。store: new MemoryStore()とすれば指定できるが、何も指定しない場合は自動的にメモリストアが利用される。なお、大規模なシステムの場合はデータベースやRedisに保管する。

```js
// MSAL (Microsoft Authentication Library) の設定
// テナントIDは ".env" で指定する想定
// authority = https://login.microsoftonline.com/<tenantID>
const msalConfig = {
  auth: {
    clientId: process.env.CLIENT_ID,
    authority: `https://login.microsoftonline.com/${process.env.TENANT_ID}`,
    clientSecret: process.env.CLIENT_SECRET,
  },
};

const msalClient = new ConfidentialClientApplication(msalConfig);

```
ここは@azure/msal-nodeを使うための設定。オブジェクトは

const { ConfidentialClientApplication } = require("@azure/msal-node");

で呼び出しており、clientIDなどを指定している。

```js

// /login ルート
// Microsoft のログインページにリダイレクトするための URL を生成
app.get("/login", (req, res) => {
  // ここでは code フローを想定
  const authCodeUrlParameters = {
    scopes: ["User.Read"], // Graph API でユーザー情報を取得する場合に必要
    redirectUri: process.env.REDIRECT_URI,
  };

  msalClient
    .getAuthCodeUrl(authCodeUrlParameters)
    .then((authUrl) => {
      res.redirect(authUrl);
    })
    .catch((error) => {
      console.error("/login Error: ", error);
      res.status(500).send("Failed to redirect to Microsoft login");
    });
});

// /redirect ルート
// Microsoft から認可コードが返ってくる場所
app.get("/redirect", async (req, res) => {
  const tokenRequest = {
    code: req.query.code,
    scopes: ["User.Read"],
    redirectUri: process.env.REDIRECT_URI,
  };

  try {
    // アクセストークンを取得
    const tokenResponse = await msalClient.acquireTokenByCode(tokenRequest);

    const accessToken = tokenResponse.accessToken;
    // アクセストークンを使って /me からユーザー情報を取得
    const graphResponse = await axios.get("https://graph.microsoft.com/v1.0/me", {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    // プロフィール情報をセッションに保存
    req.session.userProfile = graphResponse.data;

    // ホーム画面にリダイレクト
    res.redirect("/");
  } catch (error) {
    console.error("/redirect Error: ", error);
    res.status(500).send("Token acquisition or Graph request failed");
  }
});

// ホーム画面 (/)
app.get("/", (req, res) => {
  if (req.session.userProfile) {
    // 既にログイン済みの場合、プロフィール情報を表示
    return res.render("index", { profile: req.session.userProfile });
  } else {
    // ログインしていない場合
    return res.render("index", { profile: null });
  }
});

// /logout
// セッションを破棄し、トップに戻る
app.get("/logout", (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      console.error("/logout session destroy Error: ", err);
    }
    // ログアウト後はルート画面へ
    res.redirect("/");
  });
});

```

ここがメイン処理となるので詳しく解説。

#### 1. 初回アクセス時

初回アクセスは / に対して実施するので、

```js
app.get("/", (req, res) => {
```
が呼ばれ、分岐で以下となる。

```js
  } else {
    // ログインしていない場合
    return res.render("index", { profile: null });
```
res.render で ()内の内容を応答する。()内の内容はindex.ejsであり、profile: nullが渡された状態となる。index.ejsは以下。(profile)が空の場合、ログインしていないよ！が表示され、/loginへのリンクが表示される。

```html:index.js
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <title>Microsoft Graph アプリ</title>
</head>
<body>
  <% if (profile) { %>
    <h1>ログイン完了！</h1>
    <p><strong>名前:</strong> <%= profile.displayName %></p>
    <p><strong>メールアドレス:</strong> <%= profile.mail || profile.userPrincipalName %></p>
    <p><a href="/logout">ログアウト</a></p>
  <% } else { %>
    <h1>ログインしていないよ！</h1>
    <p><a href="/login">Microsoftログイン</a></p>
  <% } %>
</body>
</html>
```

#### 2. /loginへのアクセス

/loginにアクセスすると以下となる

```js
// /login ルート
// Microsoft のログインページにリダイレクトするための URL を生成
app.get("/login", (req, res) => {
```

constはおいといて、メイン処理は以下

```js
  msalClient
    .getAuthCodeUrl(authCodeUrlParameters)
    .then((authUrl) => {
      res.redirect(authUrl);
    })

```

この意味だが、msalClient.getAuthCodeUrl(authCodeUrlParameters) で認可コードの取得用URL（MicrosoftログインページのURL）を生成して、そのURLを「authUrl」という変数で受け取ってる。次に「res.redirect(authUrl)」で実際にログイン画面へリダイレクトさせている。

これはjavascriptのPromiseを使っている

- msalClient.getAuthCodeUrl(authCodeUrlParameters) を呼び出すと、このメソッドは「将来URLを返すよ」という約束（Promise）を返す
- 約束が「成功した（resolve）」場合、「.then((authUrl) => { ... })」のコールバックが呼ばれて、 authUrl という引数を受け取れる。これが実際のログインページのURL
- 「.then((authUrl) => { res.redirect(authUrl) })」によって、ログインページにユーザーをリダイレクトしている
- ここでのauthUrlはMicrosoftのサーバーURLとなる。ここでMicrosoft 365のID/Passwordを入力し、ログインに成功した場合に code が取得でき、後続処理に進む。

#### 3. /redirectへのアクセス

前述の通り、処理が成功したら /redirect にアクセスする(ここで /redirectにアクセスするのは、Entra ID アプリのリダイレクトURLの設定)。この時、前述で取得した code もセットで保持されている。

```js
app.get("/redirect", async (req, res) => {
  const tokenRequest = {
    code: req.query.code,
    scopes: ["User.Read"],
    redirectUri: process.env.REDIRECT_URI,
  };
```
req.query.code は Microsoft ログイン成功時に Microsoft 側から返る「認可コード」。それを使って msalClient にアクセストークン発行をリクエストする流れになってる。

ここで作成したtokenRequestを引っ提げて、最後の処理を実行する

```js
    // アクセストークンを取得
    const tokenResponse = await msalClient.acquireTokenByCode(tokenRequest);

    const accessToken = tokenResponse.accessToken;
    // アクセストークンを使って /me からユーザー情報を取得
    const graphResponse = await axios.get("https://graph.microsoft.com/v1.0/me", {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },

```

最後はサーバー起動処理。

```js
// Express サーバー起動
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
```

### まとめ

コードを作成してもらうのは一瞬だったが、理解するのには時間がかかった。もしこれを一人で実装白と言われたら何日かかったか分からない。CLINEおそるべし