Azure Web Apps にデプロイした ASP.NET Core Web API からユーザー認証なしで Microsoft Graph API を叩く（Managed Identity）

# 概要

[前回](https://qiita.com/tamtamyarn/items/ddc92d72f3a274fdacd4)は Client Credentials Flow を使いましたが、今回は Azure Web Apps にデプロイした ASP.NET Core Web API から Managed Identity を使って Microsoft Graph API を叩いてみたいと思います。前回同様、User Principal Name を指定してユーザー情報を取得するシンプルな Web API を開発していきます。

# 環境

- Windows 10 Pro Version 20H2
- Visual Studio Community 2019 Version 16.10.2
- Azure Active Directory
- Azure サブスクリプション
- Azure Az PowerShell Module
- Azure AD PowerShell Module
- .NET と各パッケージのバージョンは以下の通り

```
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Azure.Identity" Version="1.4.0" />
    <PackageReference Include="Microsoft.Graph" Version="3.35.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
  </ItemGroup>

</Project>

```

# プロジェクト作成

Visual Studio を開いて新しいプロジェクトを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/627214c7-9792-d118-f641-70e7607420a5.png)

**ASP.NET Core Web API** を選択して**次へ**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/931aa5e8-5f59-98ee-b4d1-395cf97ae3e2.png)

プロジェクト名と場所を入力して**次へ**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/9a4d45e3-8c09-9b6e-6e44-584f4abca5a8.png)

設定を確認して**作成**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/a00c47ca-7a3f-2c28-7791-50449917b5d6.png)

# パッケージ追加

```posh
Install-Package Azure.Identity
Install-Package Microsoft.Graph
```

# Authentication

[Microsoft Graph Client](https://docs.microsoft.com/ja-jp/graph/sdks/create-client?tabs=CS) のインスタンスを作成するために IAuthenticationProvider の実装を用意します。Managed Identity を利用するため DefautlAzureCredential クラスのインスタンスを生成します。

```cs:Authentication/ManagedIdentityAuthenticationProvider.cs
using Azure.Identity;
using Microsoft.Graph;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace GraphTutorial.Authentication
{
    public class ManagedIdentityAuthenticationProvider : IAuthenticationProvider
    {
        private readonly string[] scopes;
        private readonly DefaultAzureCredential credential;

        public ManagedIdentityAuthenticationProvider(DefaultAzureCredential credential)
        {
            this.scopes = new[] { "https://graph.microsoft.com/.default" };
            this.credential = credential;
        }

        public async Task<string> GetAccessToken()
        {
            var token = await credential.GetTokenAsync(new Azure.Core.TokenRequestContext(scopes));
            return token.Token;
        }

        public async Task AuthenticateRequestAsync(HttpRequestMessage requestMessage)
        {
            requestMessage.Headers.Authorization = new AuthenticationHeaderValue("bearer", await GetAccessToken());
        }
    }
}

```

# ApiModel

ApiModel は前回と同じです。

```cs:ApiModels/UserModel.cs
namespace GraphTutorial.ApiModels
{
    public class UserModel
    {
        public string UserPrincipalName { get; set; }
        public string DisplayName { get; set; }
        public string givenName { get; set; }
        public string surName { get; set; }
        public string Mail { get; set; }
    }
}

```

# Service

Service インターフェースとクラスについても前回と同じです。

```cs:Services/IGraphApiService.cs
using GraphTutorial.ApiModels;
using System.Threading.Tasks;

namespace GraphTutorial.Services
{
    public interface IGraphApiService
    {
        public Task<UserModel> GetUserAsync(string upn);
    }
}

```

```cs:Services/GraphApiServices.cs
using GraphTutorial.ApiModels;
using Microsoft.Graph;
using System.Threading.Tasks;

namespace GraphTutorial.Services
{
    public class GraphApiService : IGraphApiService
    {
        private readonly IGraphServiceClient graphServiceClient;

        public GraphApiService(IAuthenticationProvider authProvider)
        {
            this.graphServiceClient = new GraphServiceClient(authProvider);
        }

        public async Task<UserModel> GetUserAsync(string upn)
        {
            var user = await graphServiceClient.Users[upn].Request().GetAsync();

            var userModel = new UserModel
            {
                UserPrincipalName = user.UserPrincipalName,
                DisplayName = user.DisplayName,
                givenName = user.GivenName,
                surName = user.Surname,
                Mail = user.Mail
            };

            return userModel;
        }
    }
}

```

# Controller

コントローラーも前回と同じです。

```cs:Controllers/UserController.cs
using GraphTutorial.Services;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;

namespace GraphTutorial.Controllers
{
    [Route("api/users")]
    [ApiController]
    public class UserController : ControllerBase
    {
        private readonly IGraphApiService graphApiService;

        public UserController(IGraphApiService graphApiService)
        {
            this.graphApiService = graphApiService;
        }

        [HttpGet("{upn}")]
        public async Task<IActionResult> GetUserAsync(string upn)
        {
            var user = await graphApiService.GetUserAsync(upn);
            return Ok(user);
        }
    }
}

```

# DI

DI のために AddSingleton を追加します。シングルトンにしておくとアクセストークンの期限内は既存のアクセストークンを利用し、期限が切れると自動的に新規のアクセストークンを取得してくれます。（AddScoped AddTransient にするとリクエストのたびにアクセストークンを新しく取得します）DefaultAzureCredential を呼び出していることと、呼び出す AuthenticationProvider が変わっているだけで、基本的には前回と同じです。

```cs:Startup.cs
using Azure.Identity;
using GraphTutorial.Authentication;
using GraphTutorial.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Graph;
using Microsoft.OpenApi.Models;

namespace GraphTutorial
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton<IAuthenticationProvider, ManagedIdentityAuthenticationProvider>(); // 追加
            services.AddSingleton<IGraphApiService, GraphApiService>(); // 追加
            services.AddSingleton<DefaultAzureCredential>();  // 追加
            services.AddControllers();
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new OpenApiInfo { Title = "GraphTutorial", Version = "v1" });
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseSwagger();
                app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "GraphTutorial v1"));
            }

            app.UseHttpsRedirection();

            app.UseRouting();

            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
        }
    }
}

```

コーディングはここまでです。

# Azure へのデプロイ

Visual Studio のソリューションエクスプローラー上でプロジェクト名を右クリックして**発行**を選択します。
**Azure** を選択して**次へ**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/e6398e65-92f8-13dc-2fa9-feda5cf8aad3.png)

**Azure App Service (Windows)** を選択して**次へ**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/ae812da2-3ea2-c825-30fe-291b2da542bb.png)

適当な Web App を選択して（無ければ作成して）**次へ**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/ad36aba7-62f1-4873-1334-aeb33ad4a4af.png)

API Management の作成はスキップするので**この手順をスキップする**を有効にして**完了**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/76624b19-b348-22a7-f4d7-626efe8a09c3.png)

公開準備が完了したら**発行**をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/827b6425-88d7-7091-e083-510e50cecc2c.png)

発行が完了したらブラウザが起動して自動的に発行した Web App にアクセスされますが、まだ正常に呼び出すことはできません。

# Managed Identity の有効化

Azure Portal から作成した Web App を開き ID を選択してシステム割り当てマネージド ID が有効化されていることを確認します。（無効の場合は有効化します）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/2b7c6e4e-8f68-c322-7df8-fdadbf192777.png)

# アクセス許可の付与

現時点では Azure Portal からアクセス許可が付与できません。Windows PowerShell から [Microsoft Graph へのアクセス許可を付与](https://docs.microsoft.com/ja-jp/azure/app-service/scenario-secure-app-access-microsoft-graph-as-app?tabs=azure-powershell%2Ccommand-line#grant-access-to-microsoft-graph)していきます。

```posh
$TenantID="<tenant id>"
$ResourceGroup = "<resource group name>"
$WebAppName="<web app name>"

Connect-AzAccount

# Managed Identity の Obuject ID を取得
$PrincipalId = (Get-AzWebApp -ResourceGroupName $resourceGroup -Name $webAppName).Identity.PrincipalId

$PermissionName = "User.Read.All"

Connect-AzureAD -TenantId $TenantID

# Microsoft Graph 用のサービスプリンシパルを取得
$GraphServicePrincipal = Get-AzureADServicePrincipal -SearchString "Microsoft Graph" | Select-Object -first 1

# Microsoft Graph の User.Read.All アクセス許可を取得
$AppRole = $GraphServicePrincipal.AppRoles | Where-Object {$_.Value -eq $PermissionName -and $_.AllowedMemberTypes -contains "Application"}

# アクセス許可を付与
New-AzureAdServiceAppRoleAssignment -ObjectId $PrincipalId -PrincipalId $PrincipalId -ResourceId $GraphServicePrincipal.ObjectId -Id $AppRole.Id
```

Azure Portal でアクセス許可が付与されたことを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/caa86900-fb71-b518-d363-a11b971fe0b1.png)

# 動作確認

アクセス許可の付与後、以下の URL にアクセスしてユーザー情報が返ってくることを確認します。（user@example.com の部分は実在する User Principal Name に置き換えてください）

```
https://<webappname>.azurewebsites.net/api/users/user@example.com
```

# Managed Identity を有効にして Microsoft Graph のアクセス許可を付与する

Azure Portal からシステム割り当てマネージド ID をオンにして、オブジェクト（プリンシパル）ID を確認しておく。

マネージド ID に Microsoft Graph のアクセス許可を付与する。

```powershell
az login
$objectId = "{Object ID}"
$graphResourceId = az ad sp list --display-name "Microsoft Graph" --query [0].objectId --output tsv
$appRoleId = az ad sp list --display-name "Microsoft Graph" --query "[0].appRoles[?value=='User.Read.All' && contains(allowedMemberTypes, 'Application')].id" --output tsv
$uri = "https://graph.microsoft.com/v1.0/servicePrincipals/$objectId/appRoleAssignments
$body = "{'principalId':'$objectId','resourceId':'$graphResourceId','appRoleId':'$appRoleId'}"
az rest --method post --uri $uri --body $body --headers "Content-Type=application/json"
```

エンタープライズアプリケーションからアクセス許可の付与を確認。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383318/22f266b5-0791-3d84-b864-9415fc19ccae.png)

マネージド ID でアクセストークンを取得する。

https://docs.microsoft.com/ja-jp/azure/app-service/overview-managed-identity?tabs=powershell#code-examples

https://regarding365.com/putting-some-more-fun-into-azure-functions-managed-identity-microsoft-graph-f9a51319f4e5

Function App のコードは↓な感じ。無事シークレット情報無しで Microsoft Graph API を呼び出すことが出来た。

```powershell
using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

# Write to the Azure Functions log stream.
Write-Host "PowerShell HTTP trigger function processed a request."

# Interact with query parameters or the body of the request.
$upn = $Request.Query.Upn
if (-not $upn) {
    $upn = $Request.Body.Upn
}

$resourceURI = "https://graph.microsoft.com/"
$tokenAuthURI = $env:IDENTITY_ENDPOINT + "?resource=$resourceURI&api-version=2019-08-01"
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{"X-IDENTITY-HEADER"="$env:IDENTITY_HEADER"} -Uri $tokenAuthURI
$accessToken = $tokenResponse.access_token

$headers = @{"Authorization" = "Bearer " + $accessToken}
$body = Invoke-RestMethod -Uri https://graph.microsoft.com/v1.0/users/$upn -Headers $headers

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = [HttpStatusCode]::OK
    Body = $body
})

```