Microsoft Graph を使ってみよう : Azure AD v2 エンドポイントでの代理認証フローを Web API と MSAL で行う

WebAPI はバックエンドとして利用されるため、ユーザーのサインイン画面を Web API として出す機会がありません。そのため以下のフローを取ります。

- 独自 WebAPI 自体を Azure AD でセキュアにする
- クライアントが WebAPI を利用する際に Azure AD にて認証し、トークンを Web API に渡す
- WebAPI が受け取ったユーザー情報を元に、代理でアクセストークンを取得する

今回は以下のアプリケーションを作ります。

- C# ASP.NET WebAPI 
- C# コンソールアプリケーションクライアント
- 代理認証シナリオ
- MSAL を利用

# WebAPI 用のアプリケーション登録

OAuth 2.0 を使うために、まず初めにアプリケーションを登録します。

1\. [Microsoft アプリ登録ポータル](https://apps.dev.microsoft.com/) にアクセスして、ログイン。ここでは組織アカウントを使用。

<img width="924" alt="Capture.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/214116/a7d0dde0-654f-ff1d-7925-c72aaf83ffd8.png">

2\. 新規にアプリケーションを作成。ここでは名前を「Graph WebAPI Demo Application」と設定。プラットフォームの項目で「プラットフォームの追加」をクリック。Web API を選択。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/9774ddaf-bf9b-1803-8e29-33fe263b0637.png)

3\. 作成されたら、「アプリケーション ID URI」を確認。スコープは自動で access_as_user が作成される。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/97cd052e-f7a4-83f2-e586-62fb30f294e8.png)

4\. 一旦アプリケーションを保存。その後以下アドレスにアクセスして管理者権限で一度承認を実行。これは作成したアプリケーションを Azure AD 側に登録するためだが、本来であれば自動でできてほしい。
※client_id は登録したものに変更

https://login.microsoftonline.com/graphdemo01.onmicrosoft.com/adminconsent
?client_id=1bc3f11c-5074-407c-8c9c-8b2d20933db6
&state=12345
&redirect_uri=http://localhost

5\. 以下のエラーが出るが、登録したいだけなので、問題はない。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/174e03ae-9299-459a-8d3e-8180fb97ba5f.png)

# クライアント用のアプリケーション登録

もう 1 つクライアント用のアプリケーションを登録します。実際のシナリオでは WebAPI とクライアントが、異なるアプリケーションになる可能性が高いため、テストとしては上記と同じアプリケーションに「ネイティブアプリケーション」を登録してもいいのですが、ここでは別に登録します。

1\. 新規にアプリケーションを登録。名前は「Graph WebAPI Demo Client Application」を指定。プラットフォームの項目で「プラットフォームの追加」をクリックし「ネイティブアプリケーション」を選択。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/47b44da4-e8bf-742c-06d5-a0699c17f5a5.png)

2\. 「Microsoft Graph のアクセス許可」に既定である「User.Read」を削除。アプリケーション ID をコピーしておき、「保存」をクリック。

これでアプリケーション自体は Microsoft Graph にアクセスはできません。

# アプリケーションの開発

ここでは、まず WebAPI を Azure AD v2 エンドポイントでセキュアにするところまで作成します。

## WebAPI の開発

1\. Visual Studio より新しい ASP.NET Web アプリケーションを作成。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/6f207601-cf3f-3c81-456f-28ea77f2fee9.png)

2\. 「Web API」を選択し、認証は「個別のユーザーアカウント」を指定。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2ee3da04-bcd6-358a-0a4c-8e096ee62fea.png)

3\. NuGet の管理より「Microsoft.Identity.Clinet」、「Microsoft.Owin.Security.OpenIdConnect 3.1.0」、「Microsoft.Owin.Security.Jwt 3.1.0」を追加。MSAL はプレビューのため、「プレリリースを含める」にチェック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/9bf02a17-92ba-745d-ce87-f8117ad7069e.png)

4\. App_Start フォルダに OpenIdConnectCachingSecurityTokenProvider.cs ファイルを追加し、以下のコードで差し替え。このクラスはトークンフォーマットの指定に必要。

```csharp:OpenIdConnectCachingSecurityTokenProvider.cs
using Microsoft.IdentityModel.Protocols;
using Microsoft.Owin.Security.Jwt;
using System.Collections.Generic;
using System.IdentityModel.Tokens;
using System.Threading;

namespace GraphWebAPIDemo.App_Start
{
    public class OpenIdConnectCachingSecurityTokenProvider : IIssuerSecurityTokenProvider
    {
        public ConfigurationManager<OpenIdConnectConfiguration> _configManager;
        private string _issuer;
        private IEnumerable<SecurityToken> _tokens;
        private readonly string _metadataEndpoint;

        private readonly ReaderWriterLockSlim _synclock = new ReaderWriterLockSlim();

        public OpenIdConnectCachingSecurityTokenProvider(string metadataEndpoint)
        {
            _metadataEndpoint = metadataEndpoint;
            _configManager = new ConfigurationManager<OpenIdConnectConfiguration>(metadataEndpoint);

            RetrieveMetadata();
        }

        /// <summary>
        /// Gets the issuer the credentials are for.
        /// </summary>
        /// <value>
        /// The issuer the credentials are for.
        /// </value>
        public string Issuer
        {
            get
            {
                RetrieveMetadata();
                _synclock.EnterReadLock();
                try
                {
                    return _issuer;
                }
                finally
                {
                    _synclock.ExitReadLock();
                }
            }
        }

        /// <summary>
        /// Gets all known security tokens.
        /// </summary>
        /// <value>
        /// All known security tokens.
        /// </value>
        public IEnumerable<SecurityToken> SecurityTokens
        {
            get
            {
                RetrieveMetadata();
                _synclock.EnterReadLock();
                try
                {
                    return _tokens;
                }
                finally
                {
                    _synclock.ExitReadLock();
                }
            }
        }

        private void RetrieveMetadata()
        {
            _synclock.EnterWriteLock();
            try
            {
                OpenIdConnectConfiguration config = _configManager.GetConfigurationAsync().Result;
                _issuer = config.Issuer;
                _tokens = config.SigningTokens;
            }
            finally
            {
                _synclock.ExitWriteLock();
            }
        }
    }
}
```

5\. Startup.Auth.cs を以下のコードに差し替え。

```csharp:Startup.Auth.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNet.Identity;
using Microsoft.AspNet.Identity.EntityFramework;
using Microsoft.Owin;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.Google;
using Microsoft.Owin.Security.OAuth;
using Owin;
using GraphWebApiDemo.Providers;
using GraphWebApiDemo.Models;
using System.Configuration;
using Microsoft.Owin.Security.Jwt;
using GraphWebAPIDemo.App_Start;
using System.IdentityModel.Tokens;

namespace GraphWebApiDemo
{
    public partial class Startup
    {
        public static string PublicClientId { get; private set; }

        // 認証の構成の詳細については、https://go.microsoft.com/fwlink/?LinkId=301864 を参照してください
        public void ConfigureAuth(IAppBuilder app)
        {
            var tokenValidationParameters = new TokenValidationParameters
            {
                ValidAudience = ConfigurationManager.AppSettings["ClientId"],
                ValidIssuer = ConfigurationManager.AppSettings["Issuer"],
                SaveSigninToken = true
            };

            // The more familiar UseWindowsAzureActiveDirectoryBearerAuthentication does not work
            // with the Azure AD V2 endpoint, so use UseOAuthBearerAuthentication instead.
            app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
            {
                AccessTokenFormat = new JwtFormat(
                    tokenValidationParameters,
                new OpenIdConnectCachingSecurityTokenProvider("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
                )
            });
        }
    }
}
```
6\. Web.Config の apPSettings に以下のキーを追加。ClientId は WebAPI アプリケーション ID、Issuer のテナント ID はアプリケーションを登録している Azure AD テナントの ID。

```xml
<appSettings>
  <add key="ClientId" value="1bc3f11c-5074-407c-8c9c-8b2d20933db6"/>
  <add key="Issuer" value="https://login.microsoftonline.com/00a88ff8-2bc8-4b65-bd69-c5979cd2b9b0/v2.0"/>
</appSettings>
```

※ Azure AD テナントの ID は [Azure ポータル](https://portal.azure.com) にログインし、「Azure Active Directory」選択後、プロパティから確認可能です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/c886007e-92a3-1316-7638-e1bde79bb7f5.png)

## WebAPI クライアントの開発

1\. 次にクライアントアプリケーションの追加。ソリューションに新しくコンソールアプリケーションを追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/97012161-6c7a-6afb-a72f-76af1c5fd4cc.png)

2\. NuGet の管理より「Microsoft.Identity.Client」、「JSON.NET」を追加。また参照の追加より System.Security アセンブリを追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f1f4e30c-1c61-5a14-9450-d08cc6b4cca8.png)

3\. TokenCacheHelper.cs を追加し、コードを以下と差し替え。

```csharp:TokenCacheHelper.cs
using Microsoft.Identity.Client;
using System.IO;
using System.Security.Cryptography;

namespace GraphWebAPIClientDemo
{
    public partial class Program
    {
        static class TokenCacheHelper
        {
            /// <summary>
            /// Get the user token cache
            /// </summary>
            /// <returns></returns>
            public static TokenCache GetUserCache()
            {
                if (usertokenCache == null)
                {
                    usertokenCache = new TokenCache();
                    usertokenCache.SetBeforeAccess(BeforeAccessNotification);
                    usertokenCache.SetAfterAccess(AfterAccessNotification);
                }
                return usertokenCache;
            }

            static TokenCache usertokenCache;

            /// <summary>
            /// Path to the token cache
            /// </summary>
            public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin";

            private static readonly object FileLock = new object();

            public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
            {
                lock (FileLock)
                {
                    args.TokenCache.Deserialize(File.Exists(CacheFilePath)
                        ? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
                                                    null,
                                                    DataProtectionScope.CurrentUser)
                        : null);
                }
            }

            public static void AfterAccessNotification(TokenCacheNotificationArgs args)
            {
                // if the access operation resulted in a cache update
                if (args.TokenCache.HasStateChanged)
                {
                    lock (FileLock)
                    {
                        // reflect changesgs in the persistent store
                        File.WriteAllBytes(CacheFilePath,
                                            ProtectedData.Protect(args.TokenCache.Serialize(),
                                                                    null,
                                                                    DataProtectionScope.CurrentUser)
                                            );
                        // once the write operationtakes place restore the HasStateChanged bit to filse
                        args.TokenCache.HasStateChanged = false;
                    }
                }
            }
        }
    }
}
```

4\. Program.cs を以下のコードと差し替え。ClientId と scopes に指定しているリソースは、登録したアプリケーション ID と「{アプリケーション ID URI}/access_as_user」を指定。また apiServerUrl は WebAPI プロジェクトのプロパティより、Web にあるアドレスを指定。

```csharp:Program.cs
using Microsoft.Identity.Client;
using System;
using System.Linq;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using System.Net.Http.Headers;
using Newtonsoft.Json;

namespace GraphWebAPIClientDemo
{
    public partial class Program
    {
        private static string clientId = "550dcc01-129d-4b51-b80c-96995a6848e3";
        private static PublicClientApplication pca;
        private static string[] scopes = new string[] { "api://1bc3f11c-5074-407c-8c9c-8b2d20933db6/access_as_user" };
        private static Uri apiServerUrl = new Uri("http://localhost:64133");

        static void Main(string[] args)
        {
            #region GraphClient and Auth

            pca = new PublicClientApplication(
                clientId,
                "https://login.microsoftonline.com/common/v2.0",
                TokenCacheHelper.GetUserCache());

            #endregion

            Program p = new Program();
            p.Run().Wait();
            Console.ReadLine();
        }

        private async Task Run()
        {
            using (HttpClient client = new HttpClient())
            {
                client.DefaultRequestHeaders.Authorization =
                    new AuthenticationHeaderValue("Bearer", await GetAccessTokenAsync(scopes));

                client.BaseAddress = apiServerUrl;
                var result = await client.GetAsync("/api/values");
                if (result.IsSuccessStatusCode)
                    Console.WriteLine(JsonConvert.SerializeObject(
                        JsonConvert.DeserializeObject(await result.Content.ReadAsStringAsync()),
                        Formatting.Indented));                
            }
        }

        private async Task<string> GetAccessTokenAsync(string[] scopes)
        {
            AuthenticationResult authResult = null;

            try
            {
                authResult = await pca.AcquireTokenSilentAsync(scopes,
                    pca.Users.FirstOrDefault());
            }
            catch (MsalUiRequiredException ex)
            {
                authResult = await pca.AcquireTokenAsync(scopes, pca.Users.FirstOrDefault(), UIBehavior.Consent, null);
            }

            if (authResult != null)
                return authResult.AccessToken;
            else
                return null;
        }
    }
}
```

## 動作確認

1\. WebAPI プロジェクトを先に起動して、その後、クライアントアプリを実行。サインイン画面が出るので、「承諾をクリック」。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/0c2d2c89-d7c9-8442-2a91-d308d323fac9.png)

2\. 認証が成功し、テンプレート通り値が返ることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/dac0eb12-a499-48c1-4fa9-04f220ab5629.png)

これでまず Web API が Azure AD v2 でセキュアにできるところまで来ました。


## コードの詳細を確認

この時点でのコードをまず見ていきます。

**OAuth 2.0 認証**
OWIN の UseOAuthBearerAuthentication を使って JWT フォーマットのトークンを利用するように指定。この際、TokenValidationParameters に対して、登録した　Web API 向けに正しい発行機関が発行したトークンかを検証するよう指定しています。またアクセストークンのフォーマット形式として JwtFormat を指定し、パラメーターやメタデータ確認用のクラスを渡しています。

Azure AD v2 用のミドルウェアが出るともう少し奇麗に書けそうです。

```csharp
var tokenValidationParameters = new TokenValidationParameters
{
    ValidAudience = ConfigurationManager.AppSettings["ClientId"],
    ValidIssuer = ConfigurationManager.AppSettings["Issuer"],
    SaveSigninToken = true
};

// The more familiar UseWindowsAzureActiveDirectoryBearerAuthentication does not work
// with the Azure AD V2 endpoint, so use UseOAuthBearerAuthentication instead.
app.UseOAuthBearerAuthentication(new OAuthBearerAuthenticationOptions
{
    AccessTokenFormat = new JwtFormat(
        tokenValidationParameters,
    new OpenIdConnectCachingSecurityTokenProvider("https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration")
    )
});
```
**クライアントからの認証**
WebAPI は Azure AD v2 エンドポイントに登録されているため、MSAL を利用できます。スコープは Microsoft Graph の時と異なり、「リソース/スコープ」のフォーマットで指定されます。

```csharp
private static string[] scopes = new string[] { "api://1bc3f11c-5074-407c-8c9c-8b2d20933db6/access_as_user" };
```
# Web API からユーザーの代理で Microsoft Graph にアクセス

ユーザーの認証が終わったので、次に Web API 側でユーザーの代わりにアクセストークンを取得するフローを追加します。

1\. まず登録済アプリケーションでシークレットを取得。[Microsoft アプリ登録ポータル](https://apps.dev.microsoft.com/) にアクセスして、ログイン。WebAPI 用に登録したアプリケーションを開き、「アプリケーションシークレット」項目で「新しいパスワードを生成」をクリック。パスワードが表示されたらコピー。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/61d9c940-60c7-0cdd-59db-b51b66e99c1f.png)

2\. プラットフォームの追加より Web を追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/42fa3584-9d4e-d775-0b09-4c90b4e69229.png)

3\. リダイレクト URL に WebAPI のアドレスを設定して、アプリケーションを保存。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/3ae49549-5307-1ba4-1116-503ad7cf103f.png)

4\. Web.config の appSettings に取得したシークレット用のキーとリダイレクト URL 用を追加。

```xml
<add key="ClientSecret" value="xhkxxxxxxxxxxxxx"/>
<add key="RedirectUri" value="http://localhost:64133"/>
```
5\. ValuesController.cs を以下のコードと差し替え。

```csharp
using Microsoft.Identity.Client;
using Newtonsoft.Json.Linq;
using System.Collections.Generic;
using System.Configuration;
using System.IdentityModel.Tokens;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Http;

namespace GraphWebApiDemo.Controllers
{
    [Authorize]
    public class ValuesController : ApiController
    {
        // GET api/values
        public async Task<IEnumerable<string>> Get()
        {
            string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
            if (addinScopes.Contains("access_as_user"))
            {
                // Get the raw token that the add-in page received from the Office host.
                var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
                    as BootstrapContext;
                UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token);

                // Get the access token for MS Graph. 
                ClientCredential clientCred = new ClientCredential(ConfigurationManager.AppSettings["ClientSecret"]);
                ConfidentialClientApplication cca =
                    new ConfidentialClientApplication(ConfigurationManager.AppSettings["ClientID"],
                                                      ConfigurationManager.AppSettings["RedirectUri"], clientCred, null, null);
                string[] graphScopes = { "User.Read" };

                AuthenticationResult result = await cca.AcquireTokenOnBehalfOfAsync(graphScopes, userAssertion, "https://login.microsoftonline.com/common/oauth2/v2.0");

                using(HttpClient client = new HttpClient())
                {
                    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);
                    var responseMessage = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
                    JToken me = JToken.Parse(await responseMessage.Content.ReadAsStringAsync());

                    List<string> results = new List<string>();

                    foreach(JProperty property in me.Children())
                    {
                        results.Add(property.Value.ToString());
                    }

                    return results;
                }
            }
            return new string[] { "失敗", "Graph にアクセスできませんでした。" };
            
        }

        // GET api/values/5
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        public void Post([FromBody]string value)
        {
        }

        // PUT api/values/5
        public void Put(int id, [FromBody]string value)
        {
        }

        // DELETE api/values/5
        public void Delete(int id)
        {
        }
    }
}
```

## 動作確認

WebAPI プロジェクトを先に起動して、その後、クライアントアプリを実行。一度承認しているため、結果が返ります。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/78915c84-aa30-1c7b-ee83-e16dea4ecbb2.png)


## コードの詳細を確認

**WebAPI としてのアクセストークン確認**
access_as_user スコープで取得しているはずのトークンを再度確認しています。

```csharp
string[] addinScopes = ClaimsPrincipal.Current.FindFirst("http://schemas.microsoft.com/identity/claims/scope").Value.Split(' ');
if (addinScopes.Contains("access_as_user"))
```

**代理でアクセストークンを取得**
代理取得のために、まず UserAssertion というユーザー情報を作ります。

```csharp
var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
    as BootstrapContext;
UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token);
```
次に ConfidentialClientApplication を作成。

```csharp
ClientCredential clientCred = new ClientCredential(ConfigurationManager.AppSettings["ClientSecret"]);
ConfidentialClientApplication cca =
    new ConfidentialClientApplication(ConfigurationManager.AppSettings["ClientID"],
                                        ConfigurationManager.AppSettings["RedirectUri"], clientCred, null, null);
````

最後にスコープを指定してトークンを取得。

```csharp
string[] graphScopes = { "User.Read" };
AuthenticationResult result = await cca.AcquireTokenOnBehalfOfAsync(graphScopes, userAssertion, "https://login.microsoftonline.com/common/oauth2/v2.0");
```

# まとめ
WebAPI あら Microsoft Graph を呼び出すシナリオは、上記のように WebAPI 自体のセキュア化と、代理フローを分けて考えると分かりやすいと思います。是非試してください。

[目次に戻る](https://qiita.com/kenakamu/items/6a1ca508dc093779eefa) 

# 参照
[Azure Active Directory v2.0 と OAuth 2.0 の On-Behalf-Of フロー](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/active-directory-v2-protocols-oauth-on-behalf-of)
[GitHub: Calling a ASP.NET Core Web API from a WPF application using Azure AD V2](https://github.com/azure-samples/active-directory-dotnet-native-aspnetcore-v2)
[Building Web API Solutions with Authentication (英語動画)](https://www.youtube.com/watch?v=5qZRzwjKNJ4&t=282s)