Azure App Service上に作ったREST APIにEasy Authを追加。
Python クライアントを作り、 OAuth 2.0 Client Credintial Flow で認証しています。
OAuth 2.0 Authorization Code Flow with PKC を使ったのは以下の記事。
Steps
前提としてApp Serviceは以前作ったこれを流用。
1. Easy Auth設定
Portalのメニュー 設定 -> 認証で IDプロバイダーとして Microsoft を追加。
後でいろいろと変更したので結果的にこうなったというスクリーンショット。


「許可されたクライアント アプリケーション」は自身のアプリケーション(クライアント)IDと、次ステップで登録する呼び出すClient側のアプリケーション(クライアント)ID。
ステップ5で設定します。

2. アプリロールの作成
App Serviceのアプリで、Entra のメニュー 管理 -> アプリロール で 「+アプリロールの作成」をクリックし、必要な項目を入力して保存。

3. アプリの登録
3.1. 登録
Entra のメニュー 管理 -> アプリの登録 で 「+新規登録」をクリック

3.2. APIのアクセス許可
Entra IDのメニュー 管理 -> APIのアクセス許可 で「+アクセス許可の追加」をクリック

すべてのAPIからApp Serviceを選択し、「apurike-syon許可の許可」を選び、「アクセス許可の追加」

3.3. シークレット追加
Entra IDのメニュー 管理 -> 証明書とシークレット で、クライアントシークレットタブで「+新しいクライアントシークレット」をクリックし、クライアントシークレット追加。

追加後に、シークレットの値をメモしておく。
4. クライアントアプリケーション承認
App Serviceのアプリに対して、Entra IDのメニュー 管理 -> APIの公開で、「+クライアント アプリケーションの追加」で「3. アプリの登録」で登録したアプリケーションのクライアントIDを設定。「承認済みのスコープ」はONにして「アプリケーションの追加」ボタンをクリック。

5. 許可されたクライアント アプリケーション追加
Portalの App Service 画面の メニュー 設定 -> 認証 で、「クライアント アプリケーションの要件」を「特定のクライアント アプリケーションからの要求を許可する」にして、自身とCientのアプリケーション(クライアント)IDを追加

6. App Serviceの変更とデプロイ。
以前、以下で作ったApp Service を変更してデプロイ(デプロイ手順省略)。
Roleの検証を追加しています。
import base64
import json
from typing import List, Optional, Set
from fastapi import FastAPI, HTTPException, Request, Depends
from pydantic import BaseModel
app = FastAPI(title="FastAPI on Azure App Service (uv)")
# ここに「許可する App role」を列挙(API側アプリ登録で作った App role の Value)
REQUIRED_ROLES: Set[str] = {"A2A.Access"} # 例: {"A2A.Access"} / {"Api.Access"} など
class Item(BaseModel):
name: str
price: float
def _get_client_principal(request: Request) -> Optional[dict]:
"""
Easy Auth が付与する X-MS-CLIENT-PRINCIPAL をデコードして dict を返す。
ローカル実行や Easy Auth 無効時は None。
"""
b64 = request.headers.get("x-ms-client-principal")
if not b64:
return None
# base64url ではなく通常 base64 で来ることが多いが、パディング不足に備える
padding = "=" * (-len(b64) % 4)
try:
raw = base64.b64decode(b64 + padding)
return json.loads(raw.decode("utf-8"))
except Exception:
return None
def _extract_roles(client_principal: dict) -> List[str]:
"""
client_principal["claims"] から roles/role を抽出して返す。
"""
claims = client_principal.get("claims") or []
roles: List[str] = []
for c in claims:
typ = (c.get("typ") or "").lower()
val = c.get("val")
if not val:
continue
# Entra の app roles は "roles" で入ることが多い。環境により "role" の場合もあるので両対応。
if typ in ("roles", "role"):
roles.append(val)
return roles
def require_roles(required: Set[str]):
"""
required に含まれる role を1つでも持っていればOK(OR条件)。
全て必要にしたいなら AND に変更可能。
"""
def _dep(request: Request):
cp = _get_client_principal(request)
if cp is None:
# Easy Auth を有効にしていれば通常ここには来ない(401で止まる)
raise HTTPException(status_code=401, detail="Unauthorized (no client principal)")
roles = set(_extract_roles(cp))
if not roles.intersection(required):
raise HTTPException(
status_code=403,
detail={
"error": "Forbidden (missing required role)",
"required_any_of": sorted(required),
"token_roles": sorted(roles),
},
)
return {"client_principal": cp, "roles": sorted(roles)}
return _dep
@app.get("/health")
def health():
return {"status": "ok"}
# 例:/hello は role 必須にする
@app.get("/hello")
def hello(name: str = "world", auth=Depends(require_roles(REQUIRED_ROLES))):
return {"message": f"hello, {name}", "roles": auth["roles"]}
# 例:/items も role 必須にする
@app.post("/items")
def create_item(item: Item, auth=Depends(require_roles(REQUIRED_ROLES))):
return {"saved": item.model_dump(), "roles": auth["roles"]}
# デバッグ用:Easy Auth が渡している claims を見たいとき(本番では削除推奨)
@app.get("/_debug/claims")
def debug_claims(request: Request):
cp = _get_client_principal(request)
if cp is None:
raise HTTPException(status_code=401, detail="No x-ms-client-principal header")
return cp
ロールはアプリ内で検証が必要
ターゲットの App Service または Azure Functions アプリ コード内で、トークンに期待されるロールがあることを検証できるようになりました。 App Service 認証では、この検証は実行されません。
7. 呼出プログラム
APIを呼び出すPython Script
WSL の Ubuntu24.04でPython 3.13.11で実装。
import requests
TENANT_ID = "<tenant id>"
CLIENT_ID = "<client id>" # 呼び出し側アプリの Client ID
CLIENT_SECRET = "<client secret>" # 呼び出し側アプリの Secret
API_APP_ID = "<API app id>" # API側アプリの Client ID(GUID)
API_URL = "https://<app service host>/hello"
TOKEN_URL = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token"
SCOPE = f"api://{API_APP_ID}/.default" # Client Credentials は .default
def get_access_token() -> str:
data = {
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"grant_type": "client_credentials",
"scope": SCOPE,
}
resp = requests.post(TOKEN_URL, data=data, timeout=30)
resp.raise_for_status()
return resp.json()["access_token"]
def call_api(access_token: str) -> requests.Response:
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
}
return requests.get(API_URL, headers=headers, timeout=30)
def main():
token = get_access_token()
print("access_token:\n"+token)
resp = call_api(token)
print("status:", resp.status_code)
print(resp.text)
return resp
resp = main()
access_token:
<token>
status: 200
{"message":"hello, world","roles":["A2A.Access"]}
