0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

App Service上のEasy Auth使ったREST API呼出(Client Credential)

Last updated at Posted at 2025-12-29

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 を追加。
後でいろいろと変更したので結果的にこうなったというスクリーンショット。
image.png
image.png
「許可されたクライアント アプリケーション」は自身のアプリケーション(クライアント)IDと、次ステップで登録する呼び出すClient側のアプリケーション(クライアント)ID。
ステップ5で設定します。
image.png

2. アプリロールの作成

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

3. アプリの登録

3.1. 登録

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

3.2. APIのアクセス許可

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

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

「<テナント名>に管理者の同意を与えます。」をクリック
image.png

3.3. シークレット追加

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

4. クライアントアプリケーション承認

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

5. 許可されたクライアント アプリケーション追加

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

6. App Serviceの変更とデプロイ。

以前、以下で作ったApp Service を変更してデプロイ(デプロイ手順省略)。
Roleの検証を追加しています。

main.py
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"]}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?