Azure App Service上にデプロイしたアプリケーションに対してMicrosoft EntraIDでSSOを設定し、認証後のユーザー情報を取得してみた
はじめに
こんにちは、コンサルティング部の神野です。
好きなAzureサービスはAzure App Service(以下App Service)で、App Service 認証機能が便利だなと感じています。
今回は、大好きなWebフレームワーク Hono を使ったアプリケーションをApp Serviceにデプロイし、Microsoft Entra ID(以下Entra ID)と連携したシングルサインオン(SSO)を実装する方法を紹介します。また、どういったユーザー情報が取れるかで認証後に、アプリケーション独自の情報をもつDBとユーザー情報をどう連携するかも重要かと思うので、認証後のユーザー情報をどのように取得できるか確認したいと思います。
App ServiceにはApp Service 認証機能(Easy Auth)という便利な機能があり、アプリケーションコードを変更することなく、Entra IDとの連携が可能です。今回は Hono を使ったアプリケーションをデプロイして、この機能を活用してみたいと思います。
先に取得方法
結論から言うと、下記2種類からログインしたユーザー情報を取得可能です。
- 認証後のリクエストヘッダーからメールアドレスを取得可能
- App Service側で自動的に認証情報などのヘッダーが付与され、具体的にはヘッダーパラメータ
X-MS-CLIENT-PRINCIPAL-NAME
でユーザーアカウント名(メールアドレス)が取得可能 - 公式ドキュメントからどういったヘッダーが付与されるか確認できます!
- App Service側で自動的に認証情報などのヘッダーが付与され、具体的にはヘッダーパラメータ
/.auth/me
エンドポイントを実行してより詳細なユーザー情報を取得- Azure ADに登録されている名前などの別途属性も取得可能です。
- ただリクエストするにはCookieを使用する必要があります。もともとSPA用に用意されたと思われる機能で、バックエンド側からこのエンドポイントにリクエストを送信する場合は転送する必要があります。
1つ目のやり方はシンプルそうですね。SSOのユーザー情報とアプリケーション独自のDBなどとの紐付けはメールアドレスをキーにすればユーザーは特定できそうです。ただメールアドレスしか取得できないため、アプリケーション側のユーザー名などのその他情報は入力が必要になるかと思います。
2つ目のやり方はより詳細な情報を取得したい場合はよさそうですね。
案1と比べてどれだけ追加の情報が取れるか気になるところです。
実際にシステムを構築してどういったやり方ができるのか確認していきましょう!
システム構成
今回構築するシステムの構成は次のようになります。
主な構成要素
- ホスティング:App Service
- アプリケーション:Hono
- 認証:Entra AD(エンタープライズアプリケーション)
前提条件
本記事の手順を実施するためには、以下の環境が必要です。
- Azureサブスクリプション
- Azure ADの管理者権限
- Node.js(v20以上)
使用したツールとバージョンは下記です。
- Node.js: v20.19.0
- Hono: v4.7.9
- Azure CLI: 2.53.0
App Serviceのデプロイと認証設定
App Serviceの作成
まずはアプリケーションをホストするApp Serviceを作成します。
今回は検証のため無料のApp Serviceプランを使用します!
-
Azureポータルの左上のメニューから「リソースの作成」をクリックします
-
「Web App」を検索して選択します
-
基本セクションでは以下の情報を入力します。
- サブスクリプション:利用するサブスクリプション
- リソースグループ:新規または既存のリソースグループ
- 名前:任意の名前(例:sample-app-hono-blog)
- 安全な位置の規定のホスト名がオン
- 公開:コード
- ランタイムスタック:Node 20 LTS
- オペレーティングシステム:Linux
- リージョン:Japan East(お好みの地域)
- App Serviceプラン:新規または既存のプランを選択
- F1(無料)
- F1(無料)
-
データベースセクション、デプロイセクション、ネットワークセクション、監視とセキュリティ保護セクション、タグセクションはスキップして次へを押下
-
「確認および作成」→「作成」をクリックします
認証の設定
App Serviceが作成できたら、今度はApp Service認証機能を設定します。
- App Serviceの画面をポータル上から開き、
認証
メニューを開き、IDプロバイダーを追加
ボタンを押下
- IDプロバイダーは
Microsoft
を選択して、下記を入力していきます。- アプリケーションとそのユーザーのテナントを選択
- 従業員の構成
- アプリの登録の種類
- アプリの登録を新規作成する
- 名前
- 任意の名前
- クライアント シークレットの有効期限
- 推奨:180日
- サポートされているアカウントの種類
- 現在のテナント - 単一テナント
- クライアント アプリケーションの要件
- このアプリケーション自体からの要求のみを許可する
- 任意の ID からの要求を許可する
- 発行者テナントの要求のみを許可する
- 発行者テナントの要求のみを許可する
- アプリケーションとそのユーザーのテナントを選択
- アクセス許可はデフォルトのまま
User.Read
のみ許可し、追加ボタンを押下
認証の設定、IDプロバイダーが画面上に表示されていれば問題ありません!
Honoアプリケーションの準備 & デプロイ
まずはHonoアプリケーションを準備します。
プロジェクトの作成
npm create hono@latest
でアプリケーションのテンプレートを作成します。
npm create hono@latest honoapp
Need to install the following packages:
create-hono@0.19.0
# yを入力
Ok to proceed? (y) y
> npx
> create-hono honoapp
create-hono version 0.19.0
✔ Using target directory … honoapp
# nodejsを選択
✔ Which template do you want to use? nodejs
# yes
✔ Do you want to install project dependencies? Yes
# npm
✔ Which package manager do you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd honoapp
基本的なHonoアプリケーションの作成
src
ディレクトリを配下にindex.ts
ファイルが存在するため少しだけ修正します。
App Serviceではデプロイ時に起動するポートが割り当てられ、そのポート番号は自動で環境変数で持っているため動的に取得するよう変更します。
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
serve({
fetch: app.fetch,
port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000
}, (info) => {
console.log(`Server is running on http://localhost:${info.port}`)
})
package.jsonにスクリプトを追加
package.json
にビルドと起動用のスクリプトを追加します。
何をやっているかというと、build
コマンドでTypeScript→ JavaScriptへトランスパイルして、deploy:zip
でデプロイ用のパッケージをZip化します。
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"deploy:zip": "zip -r dist.zip dist package.json package-lock.json node_modules"
}
}
ビルド & デプロイ
下記コマンドをそれぞれ実行して、ビルドとデプロイを行います。
# ビルド実行
npm run dev
# Zip化
npm run deploy:zip
Zip化まで完了したら下記のAzure CLIコマンドを実行してApp Serviceにデプロイを行います。
リソースグループとApp Serviceの名前を引数で指定します。
# プロジェクトのディレクトリまで移動
cd xxx/honoapp
az webapp deployment source config-zip --resource-group <リソースグループ名> --name <Web App名> --src ./dist.zip
実行し数分待つとデプロイが完了するので、早速アクセスしてみましょう。
アクセスする際のURLはApp Serviceの概要に記載されている規定のドメイン
となります。
アクセスするとさっそく認証画面に遷移します。
ここでは承諾を押下して次に進みます。
すると再度アプリケーションにリダイレクトされて、メッセージが返却されたのを確認できました!!
数クリックの設定で認証も組み込めてすごいお手軽ですね・・・!!!
また無料でサクッと作れたのも嬉しいポイントだなと思います!
ちなみに自分のテナントであるEntra IDに存在しないユーザーでアクセスするときちんと弾かれます。
ユーザー情報の取得方法
アプリケーションの土台は作成できたので、Honoアプリケーションで認証後のユーザー情報を取得する方法を紹介していきます!
リクエストヘッダーから情報を取得する方法
App Service 認証機能は、認証済みユーザーの情報をHTTPヘッダーや環境変数として提供します。これをアプリケーションで取得してみましょう。
src/index.ts
のauthMiddlewareでユーザー情報をコンテクストに設定して、/profile
エンドポイントで簡易的なユーザー情報が見れるHTMLを返却してみます。
ユーザーのメールアドレスはヘッダーX-MS-CLIENT-PRINCIPAL-NAME
に格納されているのでこちらを参照するイメージです。
IDはEntra IDに登録されているIDとなります。
import { serve } from "@hono/node-server";
import { Hono } from "hono";
// ユーザー情報
type User = {
name: string;
id: string;
identityProvider: string;
};
const app = new Hono<{ Variables: { user: User } }>();
// 認証情報のミドルウェア
app.use(async (c, next) => {
// App Serviceの組み込み認証から認証済みかどうかを確認
const principal = c.req.header("X-MS-CLIENT-PRINCIPAL-NAME");
if (!principal) {
// 未認証の場合はログインページにリダイレクト
return c.redirect("/.auth/login/aad");
}
// 認証情報をコンテキストに追加
c.set("user", {
name: principal,
id: c.req.header("X-MS-CLIENT-PRINCIPAL-ID") || "",
identityProvider: c.req.header("X-MS-CLIENT-PRINCIPAL-IDP") || "",
});
await next();
});
app.get("/", (c) => {
return c.text("Hello Hono!");
});
app.get("/profile", async (c) => {
const user = c.get("user");
return c.html(`
<html>
<head>
<title>ユーザープロフィール</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-top: 20px; }
.button { display: inline-block; padding: 10px 20px; background-color: #0078d4; color: white; text-decoration: none; border-radius: 4px; }
</style>
</head>
<body>
<h1>ユーザープロフィール</h1>
<div class="card">
<h2>基本情報</h2>
<p><strong>名前:</strong> ${user.name}</p>
<p><strong>ID:</strong> ${user.id}</p>
<p><strong>認証プロバイダー:</strong> ${user.identityProvider}</p>
</div>
<div style="margin-top: 20px;">
<a href="/.auth/logout" class="button">ログアウト</a>
</div>
</body>
</html>
`);
});
上記修正を加えて再度デプロイして、/profile
にアクセスすると下記のように確認できました!
メールアドレスもしくはIDをキーにして、アプリケーションの情報を保持するDBと連携する形になりそうですね。
/.auth/me
エンドポイントから情報を取得する方法
SPAなどからユーザー情報を取得するための/.auth/me
エンドポイントが提供されています。ここから情報を取得することも可能です。このエンドポイントにアクセスすることで、ユーザー情報やアクセストークンなども確認可能です。
この仕様については下記ドキュメントをご参照ください。
/.auth/me
エンドポイントはブラウザから見るには、AppServiceAuthSession
のCookieがあれば認証できて確認できるのですが、逆にApp Serviceからアクセスするにはそのままではアクセスできません。
そのため、リクエストのCookieAppServiceAuthSession
を転送して、アクセスするようにしてみます。
// 認証クレームの型定義
type AuthClaim = {
typ: string;
val: string;
};
// 認証レスポンスの型定義
type AuthResponse = {
user_id: string;
user_claims: AuthClaim[];
[key: string]: any;
};
app.get("/auth/me", async (c) => {
try {
// サーバーサイドから/.auth/meにリクエスト
// URLを正しく構築 - ホスト部分を動的に取得
const host = c.req.header("host") || "localhost:3000";
const protocol = host.includes("localhost") ? "http" : "https";
const url = new URL("/.auth/me", `${protocol}://${host}`);
// ヘッダー初期化
const headers: HeadersInit = {};
// クッキーも転送
const cookieHeader = c.req.header("cookie");
if (cookieHeader) {
const cookies = cookieHeader.split(";");
const authCookie = cookies.find((cookie) =>
cookie.trim().startsWith("AppServiceAuthSession=")
);
if (authCookie) {
headers["Cookie"] = authCookie.trim();
} else {
// 認証Cookieが見つからない場合のハンドリング
console.warn("AppServiceAuthSession Cookieが見つかりません");
return c.redirect("/.auth/login/aad"); // 再認証へリダイレクト
}
}
// リクエスト実行
const response = await fetch(url.toString(), {
headers,
});
// レスポンスのステータスコードをチェック
if (!response.ok) {
throw new Error(`APIエラー: ${response.status} ${response.statusText}`);
}
// レスポンスのテキストを取得してデバッグ用に保存
const responseText = await response.text();
let authData: AuthResponse[] = [];
try {
// JSON解析を試みる
authData = JSON.parse(responseText) as AuthResponse[];
} catch (parseError) {
console.error("JSON解析エラー:", parseError);
return c.html(`
<html>
<head>
<title>JSONエラー</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.error { color: red; }
pre { background-color: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<h1>JSONデータの解析に失敗しました</h1>
<p class="error">サーバーから受け取ったデータの形式が正しくありません。</p>
<h2>受信したデータ</h2>
<pre>${responseText.substring(0, 1000)}${
responseText.length > 1000 ? "...(省略)" : ""
}</pre>
<a href="/profile">プロフィールに戻る</a>
</body>
</html>
`);
}
// 認証情報から必要なデータを抽出
const userInfo =
authData && authData.length > 0 ? authData[0] : ({} as AuthResponse);
const claims: AuthClaim[] = userInfo.user_claims || [];
// クレームから特定の情報を検索する関数
const findClaim = (type: string): string => {
const claim = claims.find((c: AuthClaim) => c.typ === type);
return claim ? claim.val : "不明";
};
// 表示するユーザー情報
const userData = {
name: findClaim("name"),
email: findClaim("preferred_username") || findClaim("email"),
objectId: findClaim(
"http://schemas.microsoft.com/identity/claims/objectidentifier"
),
tenantId: findClaim(
"http://schemas.microsoft.com/identity/claims/tenantid"
),
roles: claims
.filter((c: AuthClaim) => c.typ === "roles")
.map((c: AuthClaim) => c.val),
};
// ユーザー情報を表示するHTMLを返す
return c.html(`
<html>
<head>
<title>認証情報詳細</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-top: 20px; }
.button { display: inline-block; padding: 10px 20px; background-color: #0078d4; color: white; text-decoration: none; border-radius: 4px; }
.roles-list { list-style-type: none; padding: 0; }
.roles-list li { background-color: #f0f0f0; margin: 5px 0; padding: 8px; border-radius: 4px; }
.json-data { background-color: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<h1>認証情報詳細</h1>
<div class="card">
<h2>ユーザー情報</h2>
<p><strong>名前:</strong> ${userData.name}</p>
<p><strong>メールアドレス:</strong> ${userData.email}</p>
<p><strong>オブジェクトID:</strong> ${userData.objectId}</p>
<p><strong>テナントID:</strong> ${userData.tenantId}</p>
<h3>ロール</h3>
${
userData.roles && userData.roles.length > 0
? `<ul class="roles-list">
${userData.roles.map((role) => `<li>${role}</li>`).join("")}
</ul>`
: "<p>割り当てられたロールはありません</p>"
}
</div>
<div class="card">
<h2>全ての認証クレーム</h2>
<div class="json-data">
<pre>${JSON.stringify(claims, null, 2)}</pre>
</div>
</div>
<div style="margin-top: 20px;">
<a href="/profile" class="button">プロフィールに戻る</a>
<a href="/.auth/logout" class="button">ログアウト</a>
</div>
</body>
</html>
`);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "不明なエラー";
console.error("認証情報取得エラー:", errorMessage);
return c.html(`
<html>
<head>
<title>エラー</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.error { color: red; }
</style>
</head>
<body>
<h1>エラーが発生しました</h1>
<p class="error">${errorMessage}</p>
<a href="/profile">プロフィールに戻る</a>
</body>
</html>
`);
}
});
こちらも再度ビルド&デプロイして、/auth/me
にアクセスして確認してみましょう。
こちらは名前も確認できていますね!
あくまでSPA用に用意されたエンドポイントで今回はバックエンドサーバーとして動かしているのであまり目的に合っていないかもしれません。と言うのも追加で取れたのは名前であったり、アクセストークンなのでトークンを使ってアクセスするようなリソースがなければ特に使う必要はないかもしれませんね。
まとめ
どちらもユーザー情報を取得できましたが、サーバーサイドでユーザー情報を取得する場合はヘッダーの値を使用する方がオーバーヘッドなしに取得できる所感です。
ex. リクエストヘッダーの値からDBへ問い合わせする認証用のMiddlewareを作ってチェックしたり、ユーザー情報を取得したり。
.auth/me
エンドポイントを使ってデフォルトの設定のまま追加で取得できそうなものは名前ぐらいなので、それだったらエンドポイントは活用せずヘッダーから情報を取得して、DB側で別途ユーザーのマスタなどアプリケーションに必要な情報を管理するでも、いい気がしますね。
SPAであったら、クライアント側で./auth/me
にリクエストを送信して使ってユーザー情報を取得してバックエンドにリクエストを送るためのトークンを取得したりなどで活用できるケースがあるかと思いました!逆に言うとSPAの場合だと案1のようなヘッダーから取得するのが難しいかと思います。
おわりに
今回はHonoを使ったアプリケーションをAzure App Serviceにデプロイし、Entra IDによるシングルサインオン認証を実装しました。特に認証後のユーザー情報取得方法として、2つのアプローチを紹介しました!
- HTTPヘッダーからの情報取得
/.auth/me
エンドポイント利用
App Service 認証機能は強力で、アプリケーションコードを変更することなく、Entra IDとの連携が可能なため、認証機能を持つWebアプリケーションを素早く開発できます。
今回のサンプルコードは基本的な実装に留めていますが、実際のプロジェクトでは、セキュリティ要件やパフォーマンス要件に応じて、より適切な実装を検討することをお勧めします。特に本番環境では、トークンの検証や権限管理など、より厳密な実装が必要になるかと思います!
下記記事などが参考になるのかなと思いました!
サクッと認証付きのアプリケーションを実装する分にはお手軽でいいですね!
最後までご覧いただきありがとうございました!少しでも本記事が参考になりましたら幸いです。