顧客向け Microsoft Entra ID の認証情報を使って独自 WebAPI で認証する

## 前提

### 顧客テナント

こちらの手順で用意したテナントを利用します。
https://qiita.com/tsubasa02/items/08d44a44af00d9958eb2

### WebAPI

なんでもかまいませんが MSAL が利用できる言語、フレームワークが良いです。今回は Flask + Docker で作成しました。

https://flask.palletsprojects.com/en/3.0.x/

### クライアントアプリ

今回は使いませんがネイティブアプリやシングルページアプリなどから Entra ID で取得した認証情報を独自APIで利用するといった使い方もできるかと思います。

## WebAPIの実装

サンプルはこちらにあります。今回はコードの解説はしません。そんなに複雑ではないので追っていけばわかるかと思います。

https://github.com/neo-tsubasa/entra-id-webapp-sample

### 認証情報の追加

認証情報を設定するために `api/config/auth_config.py` を追加します。

```py:api/config/auth_config.py
AUTHORITY = "https://<テナントID>.ciamlogin.com/"
SCOPE = ["User.ReadBasic.All", "Group.Read.All"]
SESSION_TYPE = "filesystem"
REDIRECT_PATH = "http://localhost:8000/get_auth_token"
CLIENT_SECRET = "<シークレットID>"
CLIENT_ID = "<クライアントID>"
TENANT_ID = "<テナントID>"
ISSUER = "https://login.microsoftonline.com/"+TENANT_ID+"/v2.0"
```

次の値を置き換えます。

- <テナントID>: 作成済みのテナントのテナント ID。[Microsoft Entra 管理センター](https://entra.microsoft.com/)から、ID > 概要 > 概要 で確認できます。
- <シークレットID>: 登録済みアプリのクライアントシークレットの値。未発行の場合は後述します。
- <クライアントID>: 登録済みアプリのアプリケーション (クライアント) ID。[Microsoft Entra 管理センター](https://entra.microsoft.com/)から、ID > アプリの登録 > <登録済みのアプリ> > 概要 で確認できます。

### シークレットID発行

次の手順でシークレット ID を発行します。

- [Microsoft Entra 管理センター](https://entra.microsoft.com/)にアクセス
- ID > アプリの登録 > <登録済みのアプリ> > 証明書とシークレット
- [新しいクライアントシークレット] をクリックし、説明と有効期限を入力後、追加
- 発行された値をコピーし控えておく

画面遷移すると二度と値が表示できなくなるので注意してください。

### 動作確認

サーバを起動します。

```bash
docker compose -f docker-compose.yml -f dev.yml build 
docker compose -f docker-compose.yml -f dev.yml up
```

`localhost:8000/login`にアクセスするとリンクが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457543/97840f98-def3-a7cb-82b1-3c7bd4188a6f.png)

リンクをクリックしてサインインします。サインインに成功するとサーバのログから認証情報を確認できます。
※見やすいよう整形しています。

```json
{
    "token_type": "Bearer",
    "scope": "Group.Read.All openid profile User.ReadBasic.All email",
    "expires_in": 4096,
    "ext_expires_in": 4096,
    "access_token": "eyJ0 ... fpYA",
    "refresh_token": "0.Ab ... NYH4",
    "id_token": "eyJ0e ... s0QA",
    "client_info": "eyJ1 ... 5In0",
    "id_token_claims": {
        "aud": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "iss": "https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/v2.0",
        "iat": 1702198536,
        "nbf": 1702198536,
        "exp": 1702202436,
        "aio": "AYQA .... XeA=",
        "groups": [
            "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            ...
        ],
        "idp": "https://sts.windows.net/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/",
        "name": "翼 小林",
        "oid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "preferred_username": "xxxxx@yyyyyy.onmicrosoft.com",
        "rh": "0.Ab ... i4AP8.",
        "roles": [
            "Sample.Manager",
            "Sample.User"
        ],
        "sub": "2V-x ... hGBQ",
        "tid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "uti": "j5 ... AA",
        "ver": "2.0"
    },
    "token_source": "identity_provider"
}
```

これで Microsoft Entra から認証情報を取得できました。

## 独自 WebAPI のプライベートエンドポイントにアクセスする

cURLで WebAPIのプライベートエンドポイントにアクセスします。このコマンドでは認証情報を何もセットしていないので 401 が返却されます。

```bash
$ curl -v  localhost:8000/private
*   Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /private HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.81.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 401 UNAUTHORIZED
< Server: Werkzeug/3.0.1 Python/3.11.6
< Date: Sun, 10 Dec 2023 11:25:37 GMT
< Content-Type: application/json
< Content-Length: 89
< Set-Cookie: session=0c824221-3001-4799-9e76-b9a6cd62c670; Expires=Wed, 10 Jan 2024 11:25:37 GMT; HttpOnly; Path=/
< Connection: close
< 
{"code":"authorization_header_missing","description":"Authorization header is expected"}
```

前項で取得した認証情報の中から`id_token`の値をコピーして次のコマンドでプライベートエンドポイントにアクセスします。

```bash
$ curl -v http://localhost:8000/private -H 'Authorization: Bearer eyJ0 ... TePg'
*   Trying 127.0.0.1:8000...
* Connected to localhost (127.0.0.1) port 8000 (#0)
> GET /private HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.81.0
> Accept: */*
> Authorization: Bearer eyJ0 ... TePg
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Server: Werkzeug/3.0.1 Python/3.11.6
< Date: Sun, 10 Dec 2023 11:47:32 GMT
< Content-Type: application/json
< Content-Length: 88
< Set-Cookie: session=2c309adc-7e79-422f-838b-803320c3e0a4; Expires=Wed, 10 Jan 2024 11:47:32 GMT; HttpOnly; Path=/
< Connection: close
< 
{"message":"Hello from a private endpoint! You need to be authenticated to see this.."}
* Closing connection 0
```

ステータスコード 200 が返却され、プライベートエンドポイント内のコンテンツを取得できました。

今回はcURLで簡単に確認するだけでしたが、アプリで取得した ID トークンをキャッシュしAPI呼び出し時にヘッダーにつけてあげればよろしいかと思います。

### 注意点

Open ID Connectでは`access_token`を検証するのが普通では？と思うかもしれません。Entra IDではアクセストークンは Microsoft Graph API を利用するためのトークンとなっており独自Web APIでは検証ができなくなっています。

> Azure AD から発行されるアクセス トークンは、発行された対象 (audience) で消費されるべきであり、Microsoft Graph API に対するトークンは Microsoft Graph API だけが検証してよい。
特に Microsoft の First Party に対して発行されるアクセス トークンについては、公開情報でも jwt 形式とは限らないと注意書きがある。

https://blog.haniyama.com/2023/04/26/do-not-validate-ms-graph-token/

## おまけ

ネイティブアプリでは次のような流れでID トークンを取得し独自Web APIの認証に使用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/457543/f54950c3-b2dd-c913-31f1-1a457e7b8c26.png)