ASP.NET Core Web API からユーザー認証なしで Microsoft Graph API を叩く（Client Credentials Flow）

# 概要

タイトルの通りですが、ASP.NET Core Web API から [Client Credentials Flow](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow)を使ってユーザー認証なしで [Microsoft Graph API](https://docs.microsoft.com/ja-jp/graph/use-the-api) を呼び出してみます。Microsoft Graph API の呼び出しには [Microsoft Graph SDK](https://docs.microsoft.com/ja-jp/graph/sdks/sdks-overview) を利用します。User Principal Name を指定してユーザー情報を取得するシンプルな Web API を開発していきたいと思います。

# 環境

- Windows 10 Pro Version 20H2
- Visual Studio Community 2019 Version 16.10.2
- Azure Active Directory
- .NET と各パッケージのバージョンは以下の通り

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

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

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

</Project>

```

# アプリの登録

**Azure Active Directory** にアプリを新規登録して必要なアクセス許可を付与します。

1. **Azure Portal** にグローバル管理者アカウントでアクセスします。
1. **Azure Active Directory** を開きます。
1. **アプリの登録**を開きます。
1. **新規登録**を選択します。
1. 任意の表示名を入力します。
1. **サポートされているアカウントの種類**で**この組織ディレクトリのみに含まれるアカウント**を選択します。
1. **リダイレクト URL** は既定のままとします。
1. **登録**をクリックします。
1. 登録されたアプリの **API のアクセス許可**を開きます。
1. 既定のアクセス許可を削除します。
1. **アクセス許可の追加**をクリックします。
1. **Microsoft Graph**を選択します。
1. **アプリケーションの許可**を選択します。
1. **User.Read.All**を選択して**アクセス許可の追加**をクリックします。
1. **<テナント名>に管理者の同意を与えます**をクリックして追加したアクセス許可に管理者の同意を与えます。
1. **証明書とシークレット**を開きます。
1. **新しいクライアントシークレット**をクリックします。
1. **説明**に任意の内容を入力して**追加**をクリックします。
1. 追加されたクライアントシークレットの**値**をメモ帳などにコピーしておきます。
1. **概要**を開きます。
1. **アプリケーション (クライアント) ID** と**ディレクトリ (テナント) ID** をメモ帳などにコピーしておきます。

# プロジェクト作成

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)

# パッケージ追加

Microsoft Graph SDK と [MSAL.NET](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/msal-overview) をインストールします。

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

# appsettings.json の設定と取得

プロジェクト直下の `appsettings.json` を開いて**アプリの登録**で確認した **アプリケーション (クライアント) ID**/**ディレクトリ (テナント) ID**/**クライアントシークレット（値）** の情報を設定します。

```json:appsettings.json
{
  "AzureAdAppSettings": {
    "ApplicationId": "アプリケーション (クライアント) ID",
    "TenantId": "ディレクトリ (テナント) ID",
    "ClientSecret": "クライアントシークレット（値）"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

```

[オプションパターン](https://docs.microsoft.com/ja-jp/aspnet/core/fundamentals/configuration/options)を使用して `appsettings.json` に設定した情報を取得するため。`AzureAdAppSettingsOptions` クラスを追加して `Startup` クラスの `ConfigureServices` メソッドを修正します。

```cs:Options/AzureAdAppSettingsOptions.cs
namespace GraphTutorial.Options
{
    public class AzureAdAppSettingsOptions
    {
        public string ApplicationId { get; set; }
        public string TenantId { get; set; }
        public string ClientSecret { get; set; }
    }
}

```

```cs:Startup.cs
using GraphTutorial.Options;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
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.Configure<AzureAdAppSettingsOptions>(Configuration.GetSection("AzureAdAppSettings")); // 追加
            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();
            });
        }
    }
}

```

# Authentication

[Microsoft Graph Client](https://docs.microsoft.com/ja-jp/graph/sdks/create-client?tabs=CS) のインスタンスを作成するために IAuthenticationProvider の実装を用意します。[参照](https://docs.microsoft.com/en-us/graph/tutorials/dotnet-core?tutorial-step=3)

```cs:Authentication/ClientCredentialsAuthenticationProvider.cs
using GraphTutorial.Options;
using Microsoft.Extensions.Options;
using Microsoft.Graph;
using Microsoft.Identity.Client;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;

namespace GraphTutorial.Authentication
{
    public class ClientCredentialsAuthenticationProvider : IAuthenticationProvider
    {
        private readonly string[] scopes;
        private readonly IConfidentialClientApplication confidentialClientApplication;

        public ClientCredentialsAuthenticationProvider(IOptions<AzureAdAppSettingsOptions> options)
        {
            this.scopes = new[] { "https://graph.microsoft.com/.default" };

            this.confidentialClientApplication = ConfidentialClientApplicationBuilder
                .Create(options.Value.ApplicationId)
                .WithTenantId(options.Value.TenantId)
                .WithClientSecret(options.Value.ClientSecret)
                .Build();
        }

        public async Task<string> GetAccessToken()
        {
            var result = await confidentialClientApplication.AcquireTokenForClient(scopes).ExecuteAsync();
            return result.AccessToken;
        }

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

```

# ApiModel

Graph API からのレスポンスを受け取るためのクラスを用意します。

```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

DI のためにインターフェースを用意します。

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

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

```

Microsoft Graph Client を生成して Graph API を呼び出すサービスクラスを用意します。

```cs:Services/GraphApiService.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` にするとリクエストのたびにアクセストークンを新しく取得します）

```Startup.cs
using GraphTutorial.Authentication;
using GraphTutorial.Options;
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.Configure<AzureAdAppSettingsOptions>(Configuration.GetSection("AzureAdAppSettings"));
            services.AddSingleton<IAuthenticationProvider, ClientCredentialsAuthenticationProvider>(); // 追加
            services.AddSingleton<IGraphApiService, GraphApiService>(); // 追加
            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();
            });
        }
    }
}

```

# 動作確認

デバッグ実行後、以下の URL にアクセスしてユーザー情報が返ってくることを確認します。（`user@example.com` の部分は実在する User Principal Name に置き換えてください。IIS Express で実行した場合は正しいポート番号を正しい番号に置き換えてください。）

```
https://localhost:5001/api/users/user@example.com
```