【Azure SDK】ユーザーのEntra IDでAzureサービスに接続する(デスクトップアプリ)

# 概要
AzureのストレージやAIサービスなど各サービスにはSDKが用意されており、簡単にAzureサービスを利用することができます。
ただし、サービスに対してセキュアに接続する場合は、何らかの認証を利用する必要があります。

今回は、
**①パブリッククライアントであるWindowsデスクトップアプリから**
**②ユーザー自身のEntraIDを使ってAzureサービスに接続する方法**
を解説します。

要するにこんな画面で認証させる方法です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293667/b605f412-1ab9-44f4-3032-0fba1f4572cd.png)

# ❌よくある認証方法のコード(DefaultAzureCredential)
- 以下はAzureサービスを呼び出すSDKのサンプルコードによくある形です
- クライアントSDKのTokenCredentialとして`DefaultAzureCredential`を指定する方法です
- この方法はドキュメントにある通り、主に開発環境とシークレットクライアント(サーバーサイドアプリなど)の環境で認証させる方法です。
 環境変数やマネージドIDを使って認証させる方法なので、仮想マシンやAzureサービスから呼び出すことが前提です。
- よってユーザー自身のIDで呼び出す方法ではありません。

```cs
string keyVaultName = Environment.GetEnvironmentVariable("KEY_VAULT_NAME");
var kvUri = "https://" + keyVaultName + ".vault.azure.net";

var client = new SecretClient(new Uri(kvUri), new DefaultAzureCredential());
```

ちなみにDefaultAzureCredentialは、端末の環境変数やマネージドIDなど複数の認証方法を自動で試行してくれるため、環境が変わっても同じコードで認証できる便利な方法です。

https://learn.microsoft.com/ja-jp/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet


# ユーザー自身のEntra IDを使って認証する方法

## 事前準備
- サービスを利用させるユーザーに対してIAMで必要なロールを割り当てておきます
- 今回の例のAzure AI Documentでは、利用者に対して`Cognitive Services ユーザー`の割り当てが必要でした

## ⭕ネイティブ認証ブローカーを使った認証
おすすめの方法です。
WindowsのWAM(Web Account Manager)を使って対話型画面で認証させます。
対話画面でIDを選択することでID Tokenを取得し、後はSDK側でサイレントにトークンを取得、リフレッシュしてくれます。


**以下の追加ライブラリが必要です**
```sh
Azure.Identity.Broker
```

**コードは以下**

まず、GetCredentialメソッドを作成しておきます

```cs
public static TokenCredential GetCredential(nint handle)
{
    TokenCachePersistenceOptions tokenCachePersistenceOptions = new();

    TokenCredential credential = new ChainedTokenCredential(
        // ディスクキャッシュからの読み出し
        new SharedTokenCacheCredential(
            // 必ずBroker版を指定
            new SharedTokenCacheCredentialBrokerOptions(tokenCachePersistenceOptions)
        ),
        // ネイティブ認証ブローカー
        new InteractiveBrowserCredential(
            new InteractiveBrowserCredentialBrokerOptions(handle){
                TokenCachePersistenceOptions = tokenCachePersistenceOptions
            }
        )
    );

    return credential;
}
```
- WAMによる認証を利用するには、InteractiveBrowserCredentialのオプションでInteractiveBrowserCredentialBrokerOptionsを指定します
    - handleには親ウィンドウのウィンドウハンドルを指定します
    - TokenCachePersistenceOptions を使うことでトークン情報をディスクにキャッシュ可能です
- ChainedTokenCredential を使うと、任意の認証方法を順番に試行するTokenCredentialを作成可能です
    - 上記の設定ではトークンのディスクキャッシュの読み込みをトライして、ダメなら認証画面を表示する流れです
    - なので、初回はWAMを利用してユーザーのEntra IDを選択またはログインさせて認証します



'24/12/19追記
SharedTokenCacheCredentialの書き方に誤りがありましたので修正しました。


<br>
そして、Azureサービスを呼び出すときは以下のように呼び出せます。
以下はDocumentIntelligenceの例です。
 
```cs

// TokenCredentialを作成
TokenCredential credential = GetCredential(brokerParentHandle);

// クライアント初期化
var client = new DocumentAnalysisClient(new Uri($"https://{_resource}.cognitiveservices.azure.com/"), credential);

using var stream = new FileStream(filePath, FileMode.Open);

AnalyzeDocumentOperation operation;
try
{
    // AI Document呼び出し
    operation = await _client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-read", stream);  
}
catch (AuthenticationFailedException e)
{
    // 認証に失敗したとき
    Console.WriteLine($"Authentication Failed. {e.Message}");
}

```


https://learn.microsoft.com/ja-jp/dotnet/api/overview/azure/identity.broker-readme?view=azure-dotnet

<br>



## (非推奨)DefaultAzureCredentialとInteractiveBrowserCredentialオプション
- DefaultAzureCredentialのオプションとして、ブラウザを使った対話型画面によりユーザーのEntraIDを使用した認証に対応可能です。
- ただし既定ではブラウザ認証が無効になっているので、コンストラクタの引数で有効にしてあげる必要があります。
- また、他の認証方法を利用しない場合は以下のようにカスタムして無効にできます。 

```cs
var credential = new DefaultAzureCredential(
    new DefaultAzureCredentialOptions()
    {
        // デスクトップアプリでは使わない認証方法は無効に
        ExcludeAzurePowerShellCredential = true,
        ExcludeEnvironmentCredential = true,
        ExcludeWorkloadIdentityCredential = true,
        ExcludeManagedIdentityCredential = true,
        ExcludeAzureDeveloperCliCredential = true,
        ExcludeAzureCliCredential = true,

        // キャッシュとブラウザを有効
        ExcludeSharedTokenCacheCredential = false,
        ExcludeInteractiveBrowserCredential = false,        
    }
);

```

ただし、各認証方法のoptionsが指定できないので、トークン情報のディスクキャッシュが有効になるかは不明です。
また、ブラウザ画面で、ユーザーが誤ってタブを閉じると永遠にデスクトップアプリ側に制御が戻らないため、タイムアウトを設けるなどの処理が必要で実用上の問題がありそうでした。

# 考察
## 認証はどのタイミングで実行されるか
DefaultAzureCredentialやInteractiveBrowserCredential、その基底クラスである`TokenCredential`ですが、インスタンス作成時点では何も起きません。
実際にAzureサービスを呼び出すタイミングで、TokenCredential.GetTokenAsync()が呼び出され、認証される仕組みです。
なので、認証に失敗した場合の例外もここで発生します。

Azure.IdentityライブラリはMSALを内包しており、アクセストークンの取得やトークンリフレッシュを行ってくれるようです。

<br>

### トークン取得フローについて
Azure.Identity、Azure.Core関係のソースコードを追っていった感じでは以下のように処理されていました。
1. Azureサービスを呼び出すメソッドを実行する(例：AnalyzeDocumentAsync)
1. HttpPipelineとしてHttpヘッダを生成する処理が走る
1. サービス呼び出しに必要なヘッダを生成する
1. Authorizationヘッダの生成過程で、AccessTokenがインスタンスに存在するか、有効期限が切れていないか確認する
1. AccessTokenが無ければ、サービスクライアントのコンストラクタで指定された`TokenCredential.GetTokenAsync()`を実行し認証させる
    1. 上記の例だとキャッシュ確認→ダメなら認証ブローカー
1. 認証の結果、ID Tokenが取得されるので、内包するMSALのAquireTokenSilentを使ってIDTokenとAzureサービスのスコープからAccessTokenを取得する
    1. 2回目以降はAccessTokenの有効期限を確認し、切れていれば勝手にAquireTokenSilentを実行してくれる
1. これにより、Authorizationヘッダを付加する
1. Azureサービスを呼び出すことで正しくサービスを利用できる。


上述の通り、認証ブローカーを使用した認証では、id_tokenのみ返されます。
ディスクキャッシュの内容もid tokenのみが保存されます。
※ブラウザを使った認証の場合はid tokenとaccess tokenが同時に返されキャッシュされていました。
サービス利用のための認可であるaccess tokenについては、AzureサービスのSDK内でid tokenとサービスごとのscopeをパラメータとして認可エンドポイントからaccess tokenを取得しているようです。

# おまけ

## トークンキャッシュの中身を確認する
- SDKで保存されたキャッシュファイルを復号化して表示します。MSALと同じですね。
- WAMで認証させるとAccessTokenやRefreshTokenは空でIdTokenのみ保存されています

```cs
public static readonly string CacheFilePath = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ".IdentityService", "msal.cache.cae");

public static string ReadTokenCache()
{

    var byteArray = ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
                                          null,
                                          DataProtectionScope.CurrentUser);

    string str = Encoding.UTF8.GetString(byteArray);


    return str;

}

```
トークンキャッシュの保存先は以下
`AppData\Local\.IdentityServiceのmsal.cache.cae`

tokenCachePersistenceOptions の初期化子で名前を指定することでキャッシュのファイル名を変更できそうです。 

https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/src/TokenCache.cs

https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/src/Constants.cs

## トークンリクエスト時のアプリケーションIDやスコープ
- アプリケーションIdはAzure CLIのものを利用しているよう
- スコープはAzureサービスのSDKから取得し、AIサービスでは以下が指定される
    - `public const string DefaultCognitiveScope = "https://cognitiveservices.azure.com/.default";`
- このスコープを指定してアクセストークンを取得しているようだ

<br>

# おまけ2
## 認証ブローカーで保存したトークンキャッシュからIAccountを取り出す方法
- Azure.IdentityやMicrosoft.Identity.Client.Extensions.Msalのソースコードから見つけた
- ポイントは`WithBroker()`で、認証ブローカーから取得したトークンキャッシュはこれがないと取得できない
    - なのでこのコードはAzure.Identity.Brokerに書いてある
- これができれば自分でアクセストークンも取得できる

```cs
    public static async Task<IAccount> ReadAccount()
    {
        // msalキャッシュヘルパー、今はMSALに内包されているらしい
        var strageProperties = new StorageCreationPropertiesBuilder("msal.cache.cae", Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), ".IdentityService"))
            .Build();
        var cacheHelper = await MsalCacheHelper.CreateAsync(strageProperties);
        // 書き込みが有効かのテスト
        cacheHelper.VerifyPersistence();

        // Msalクライアントを準備
        // AppIdはAzure CLIのもの、その他はソースコードを真似
        var pca = PublicClientApplicationBuilder.Create("04b07795-8ddb-461a-bbee-02f9e1bf7b46")
                    .WithAuthority(@"https://login.microsoftonline.com/", "organizations", false)
                    .WithClientCapabilities(new List<string>() { "CP1" })
                    // Windows認証ブローカーを使用した場合は以下オプションが必要
                    .WithBroker(new BrokerOptions(BrokerOptions.OperatingSystems.Windows))
                    .Build();

        // キャッシュ読み書き時のイベントを登録
        cacheHelper.RegisterCache(pca.UserTokenCache);

        // アカウント取得
        var accounts = await pca.GetAccountsAsync();

        return accounts.FirstOrDefault();
    }
```


# 参考
https://learn.microsoft.com/ja-jp/dotnet/azure/sdk/authentication/additional-methods?utm_source=pocket_shared