複数の Azure AD マルチテナントユーザー認証を .NET Web アプリで検証してみた

## 背景と目的

複数の Azure AD テナントのユーザーを一つの Web アプリでサインインを行いたい場合、一つの Azure AD テナントに B2B で招待しゲストユーザー登録してしまうのが Web アプリとしては最も簡単で単純です。しかしながら、この方法だと招待したゲストユーザーが居なくなった（認証は通らない）としてもゴミとして残り続けてしまう、という問題があります。何にでもメリットデメリットは存在するものですが、複数の Azure AD テナントのユーザーはそのままで Web アプリ側に少々複雑にして対応する一例を今回は検証してみたいと思います。

## 前提条件

いつも使用している Azure AD 以外にもう一つ別の Azure AD テナントを用意しユーザーを作成しておいてください。

コマンドの実施環境は、Mac + Azure CLI + .NET 6 です。

```bash:bash
$ sw_vers
ProductName:    macOS
ProductVersion: 12.4
BuildVersion:   21F79

# 2.37.0 で az ad app が破壊的変更となりました
$ az version
{
  "azure-cli": "2.37.0",
  "azure-cli-core": "2.37.0",
  "azure-cli-telemetry": "1.0.6",
  "extensions": {}
}

$ dotnet --version
6.0.300
```

## Azure AD にアプリ登録

```bash:bash
# 環境変数をセットします
prefix=sampleapp

# Azure AD にアプリ登録を行います（アクセス許可として Microsoft Graph の User.Read を付与します）
appid=$(az ad app create \
  --display-name $prefix \
  --web-redirect-uris https://localhost/signin-oidc \
  --enable-id-token-issuance true \
  --required-resource-accesse '[
    {
      "resourceAccess": [
        {
          "id": "e1fe6dd8-ba31-4d61-89e7-88639da4683d",
          "type": "Scope"
        }
      ],
      "resourceAppId": "00000003-0000-0000-c000-000000000000"
    }
  ]' \
  --query appId \
  --output tsv)

# Azure AD アプリにパスワードを登録します
apppw=$(az ad app credential reset \
  --id $appid \
  --append \
  --display-name $prefix \
  --years 100 \
  --query password \
  --output tsv)
```

## .NET 6 Web アプリを作成

```bash:bash
dotnet new mvc \
  --name $prefix \
  --auth MultiOrg \
  --client-id $appid

cd $prefix

dotnet run
```

新しい InPrivate ウィンドウで `https://localhost:port` にアクセスしてサインインします。こちらのスクショの通り、複数の Azure AD テナントユーザーがサインインできます。

![aad-multitenat-auth-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/d9eaaf23-4227-31b3-fc2b-86efdd3eef16.png)

## Azure AD ユーザートークンを取得して検証

ここまでの方法だと、世の中にある全ての Azure AD テナントユーザーが同意すればこの Web アプリを使うことができてしまいます。特定のテナントだけに制限したい場合は、以下のユーザートークンを取得する処理を追加して、テナントを検証する必要があります。

```bash:bash
# 必要なパッケージを .NET に追加します
dotnet add package Newtonsoft.Json
dotnet add package Microsoft.IdentityModel.Clients.ActiveDirectory

# appsettings.json に ClientSecret を追加します
gsed -i "s/\"CallbackPath\"/\"ClientSecret\": \"$apppw\",\n    \"CallbackPath\"/" appsettings.json

# Index.cshtml にユーザートークンとユーザー情報を表示するようにします
gsed -i "s#</div>#</div>\n\n<div><h2>token</h2><p>@ViewData[\"TokenResult\"]</p></div>\n\n<div><h2>user</h2><p>@ViewData[\"UserResult\"]</p></div>#" Views/Home/Index.cshtml

# HomeController.cs に必要な using を追加します
gsed -i "s/Models;/Models;\nusing Microsoft.Identity.Web;\nusing System.Net.Http.Headers;\nusing System.Net;\nusing Newtonsoft.Json;\nusing Microsoft.IdentityModel.Clients.ActiveDirectory;\nusing System.IdentityModel.Tokens.Jwt;/" Controllers/HomeController.cs

# HomeController.cs に ITokenAcquisition を追加します
gsed -i "s/_logger;/_logger;\n    private readonly ITokenAcquisition _tokenAcquisition;/" Controllers/HomeController.cs

# HomeController.cs の初期化処理に ITokenAcquisition を追加します
gsed -i "s/public HomeController(ILogger<HomeController> logger)/public HomeController(ILogger<HomeController> logger, ITokenAcquisition tokenAcquisition)/" Controllers/HomeController.cs
gsed -i "s/= logger;/= logger;\n        _tokenAcquisition = tokenAcquisition;/" Controllers/HomeController.cs

# HomeController.cs の Index ページの処理を変更します
gsed -i "s/public IActionResult Index()/public async Task<IActionResult> Index()/" Controllers/HomeController.cs

# Program.cs の認証処理にユーザートークン取得処理を追加します
gsed -i "s/(\"AzureAd\"));/(\"AzureAd\"))\n    .EnableTokenAcquisitionToCallDownstreamApi(new string[]{\"user.read\"})\n    .AddInMemoryTokenCaches();/" Program.cs
```

最後に HomeController.cs の Index() を下記のようにします。

```csharp:HomeController.cs
    public async Task<IActionResult> Index()
    {
        string[] scopes = new string[]{"user.read"};
        string accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);

        var handler = new JwtSecurityTokenHandler();
        var jwtSecurityToken = handler.ReadJwtToken(accessToken);
        ViewData["TokenResult"] = jwtSecurityToken;

        HttpClient httpClient = new HttpClient();
        httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

        var response = await httpClient.GetAsync("https://graph.microsoft.com/beta/me");
        if (response.StatusCode == HttpStatusCode.OK)
        {
            var content = await response.Content.ReadAsStringAsync();
            dynamic me = JsonConvert.DeserializeObject(content);
            ViewData["UserResult"] = me;
        }

        return View();
    }
```

## サンプルアプリを起動して情報を確認

```bash:bash
dotnet run
```

新しい InPrivate ウィンドウで `https://localhost:port` にアクセスしてサインインします。こちらのスクショは画面の一部です。例えば `iss` などの情報からこの Web アプリにアクセスしても良い Azure AD テナントかを判別する事も可能です。

![aad-multitenat-auth-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/5f5b0cb4-74b6-d70d-7b28-5a664622ee99.png)

![aad-multitenat-auth-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/6a4e3331-7ac6-83b8-90ce-e883504db661.png)

## 参考

```bash:bash
# サンプルアプリを削除します
cd ..
rm -rf $prefix

# Azure AD アプリを削除します
az ad app delete --id $appid
```

https://docs.microsoft.com/ja-jp/cli/azure/microsoft-graph-migration?view=azure-cli-latest

https://docs.microsoft.com/ja-jp/azure/active-directory/develop/howto-convert-app-to-be-multi-tenant

https://techcommunity.microsoft.com/t5/azure-active-directory-identity/publisher-verification-and-app-consent-policies-are-now/ba-p/1257374