Microsoft Graph を使ってみよう : Azure AD v2 エンドポイントでの認証を Web アプリケーション (MVC) と MSAL で行う

# Web アプリケーションでの認証

ユーザーは以下の図のように、ブラウザ経由で ASP.NET MVC アプリケーションに接続します。Azure AD v2 エンドポイントを利用して自分のサイトをセキュアにできます。

以下、[ASP.NET Web アプリへの "Microsoft でサインイン" の追加](https://docs.microsoft.com/en-us/azure/active-directory/develop/guidedsetups/active-directory-aspnetwebapp) より抜粋。
![flow](https://docs.microsoft.com/en-us/azure/includes/media/active-directory-develop-guidedsetup-aspnetwebapp-intro/aspnetbrowsergeneral.png)

しかし Web アプリケーションが Microsoft Graph に接続してユーザーに関連する情報を取得する場合、ユーザーのアクセストークンが必要となる一方、アクセストークンを要求するのは ASP.NET MVC アプリケーションとなります。そこで MSAL ではそのための手段として、OpenId Connect によるユーザー認証と認可コードの取得、およびサービス側が、認可コードを利用してアクセストークンを取得する仕組みを用意しています。

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

- C# MVC Web アプリケーション
- OpenId と認可コードフローでトークン取得
- MSAL を利用

## OpenId Connect と認可コードフロー

Web アプリケーションにユーザーが OpenId Connect を使用してログインした場合、認可コードが Azure AD より発行されます。Web アプリケーションはこのコードを使って、ユーザーのアクセストークンを取得できます。

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

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\. [ユーザー委任シナリオ](https://qiita.com/kenakamu/items/81c3dff2bbdc5562418e)で作成したアプリケーションを利用、または新規に登録。プラットフォームの項目で「プラットフォームの追加」をクリック。Web アプリケーションを選択。

<img width="606" alt="Capture.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/214116/89cfe6c2-7f02-855c-d1c0-8963bb7ec514.png">

3\. アプリケーションシークレットの項目で「新しいパスワードを生成」をクリック。パスワードが表示されるのでコピーして保存。
※このパスワードは画面を消すと二度と取得できない。

<img width="870" alt="Capture.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/214116/6e5068bd-10f8-5870-ed0b-05d84548ab82.png">

4\. Microsoft Graph のアクセス許可の「委任されたアクセス許可」項目で、「追加」をクリック。

<img width="619" alt="Capture.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/214116/6f181201-75a0-11b5-5e64-d504ad3be1a3.png">

5\. 必要な権限を追加。ここでは Calendars.ReadWrite を選択。

<img width="621" alt="Capture.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/214116/b488e75f-d2da-dd31-38c8-cb5970cdebca.png">

6\. 最後に「保存」をクリック。


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

1\. Visual Studio で新しい ASP.NET Web アプリケーションプロジェクトを作成。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e4f20f6a-7f67-7984-3a83-19687236b320.png)

2\. MVC を選択。「認証の変更」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/775adcf7-44cb-9e1e-9614-d585fabfe574.png)

3\. 「個別のユーザーアカウント」を選択して、「OK」。元の画面に戻るので、再度「OK」をクリックしてプロジェクト作成完了。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/8b1cbb46-5b3a-f51a-7a89-765da8c99590.png)

4\. NuGet の管理より、「Microsoft.Identity.Client」、「Microsoft.Owin.Security.OpenIdConnect」をインストール。MSAL はプレビューのため、「プレリリースを含める」にチェックを入れる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/399264e0-25c2-e65e-ef62-02ef7ec4a5a3.png)

5\. 作成したプロジェクトを右クリックしてプロパティを表示。Web のセクションから「プロジェクトの URL」 を確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/b28cecf9-9c56-7723-b98f-daef35146632.png)

6\. Web.Config の configuration/appSettings 内に必要な情報を追加。

```xml:web.config/configuration/appSettings
<add key="ClientId" value="登録したアプリケーションの ID" />
<add key="ClientSecret" value="作成したパスワード" />
<add key="GraphScopes" value="User.Read" />
<add key="RedirectUri" value="プロジェクトの URL" />
```
7\.  [Microsoft アプリ登録ポータル](https://apps.dev.microsoft.com/) に戻って、Web プラットフォームの「リダイレクト URL」から「URL の追加」をクリック。プロジェクトの URL を追加後、「保存」。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e7e87ff2-f447-0f9b-ee4c-b36af9b2c760.png)

8\. ユーザーのログインとトークン管理のヘルパークラスを作成。プロジェクトに TokenStorage フォルダを作成し、SessionTokenCache.cs ファイルを追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/96cd2895-ce40-bc10-57d5-8e0d94eb8101.png)

9\. 中身を以下のコードと書き換え。尚コードは GitHub にある [aspnet-connect-rest-sample](https://github.com/microsoftgraph/aspnet-connect-rest-sample) のものを拝借。これまでの記事でも紹介した通り、MSAL が利用する UserTokenCache に利用される。

```csharp:SessionTokenCache.cs
/* 
*  Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. 
*  See LICENSE in the source repository root for complete license information. 
*/

using Microsoft.Identity.Client;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Web;

namespace GraphWebAppDemo.TokenStorage
{

    // Store the user's token information.
    public class SessionTokenCache
    {
        private static ReaderWriterLockSlim SessionLock = new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion);
        string UserId = string.Empty;
        string CacheId = string.Empty;
        HttpContextBase httpContext = null;

        TokenCache cache = new TokenCache();

        public SessionTokenCache(string userId, HttpContextBase httpcontext)
        {
            // not object, we want the SUB
            UserId = userId;
            CacheId = UserId + "_TokenCache";
            httpContext = httpcontext;
            Load();
        }

        public TokenCache GetMsalCacheInstance()
        {
            cache.SetBeforeAccess(BeforeAccessNotification);
            cache.SetAfterAccess(AfterAccessNotification);
            Load();
            return cache;
        }

        public void SaveUserStateValue(string state)
        {
            SessionLock.EnterWriteLock();
            httpContext.Session[CacheId + "_state"] = state;
            SessionLock.ExitWriteLock();
        }
        public string ReadUserStateValue()
        {
            string state = string.Empty;
            SessionLock.EnterReadLock();
            state = (string)httpContext.Session[CacheId + "_state"];
            SessionLock.ExitReadLock();
            return state;
        }
        public void Load()
        {
            SessionLock.EnterReadLock();
            cache.Deserialize((byte[])httpContext.Session[CacheId]);
            SessionLock.ExitReadLock();
        }

        public void Persist()
        {
            SessionLock.EnterWriteLock();

            // Optimistically set HasStateChanged to false. We need to do it early to avoid losing changes made by a concurrent thread.
            cache.HasStateChanged = false;

            // Reflect changes in the persistent store
            httpContext.Session[CacheId] = cache.Serialize();
            SessionLock.ExitWriteLock();
        }

        // Triggered right before MSAL needs to access the cache.
        // Reload the cache from the persistent store in case it changed since the last access.
        void BeforeAccessNotification(TokenCacheNotificationArgs args)
        {
            Load();
        }

        // Triggered right after MSAL accessed the cache.
        void AfterAccessNotification(TokenCacheNotificationArgs args)
        {
            // if the access operation resulted in a cache update
            if (cache.HasStateChanged)
            {
                Persist();
            }
        }

    }
}
```

10\. App_Start フォルダ内 Startup.Auth.cs を以下のコードに書き換えて、OpenId Connect を利用するように変更。

```csharp:Startup.Auth.cs
using System.Web;
using Owin;
using Microsoft.Owin.Security;
using Microsoft.Owin.Security.Cookies;
using Microsoft.Owin.Security.OpenIdConnect;
using System.Configuration;
using System.Threading.Tasks;
using GraphWebAppDemo.TokenStorage;
using System.IdentityModel.Tokens;
using System.IdentityModel.Claims;
using Microsoft.Identity.Client;
using Microsoft.IdentityModel.Tokens;

namespace GraphWebAppDemo
{
    public partial class Startup
    {
        private static string clientId = ConfigurationManager.AppSettings["ClientId"];
        private static string clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
        private static string redirectUri = ConfigurationManager.AppSettings["RedirectUri"];
        private static string graphScopes = ConfigurationManager.AppSettings["GraphScopes"];

        public void ConfigureAuth(IAppBuilder app)
        {
            app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

            app.UseCookieAuthentication(new CookieAuthenticationOptions());

            app.UseOpenIdConnectAuthentication(
                new OpenIdConnectAuthenticationOptions
                {
                    ClientId = clientId,
                    Authority = "https://login.microsoftonline.com/common/v2.0",
                    PostLogoutRedirectUri = redirectUri,
                    RedirectUri = redirectUri,
                    Scope = "openid email profile offline_access " + graphScopes,
                    TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidateIssuer = false,
                        // 本番運用のサーバーでは Issuer を検証し、正しいアプリケーションから戻ってきたものか確認。 
                        //     IssuerValidator = (issuer, token, tvp) =>
                        //     {
                        //         if (MyCustomTenantValidation(issuer)) 
                        //             return issuer;
                        //         else
                        //             throw new SecurityTokenInvalidIssuerException("Invalid issuer");
                        //     },
                    },
                    Notifications = new OpenIdConnectAuthenticationNotifications
                    {
                        AuthorizationCodeReceived = async (context) =>
                        {
                            var code = context.Code;
                            string signedInUserID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;

                            TokenCache userTokenCache = new SessionTokenCache(signedInUserID,
                                context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase).GetMsalCacheInstance();
                            ConfidentialClientApplication cca = new ConfidentialClientApplication(
                                clientId,
                                redirectUri,
                                new ClientCredential(clientSecret),
                                userTokenCache,
                                null);
                            string[] scopes = graphScopes.Split(new char[] { ' ' });

                            AuthenticationResult result = await cca.AcquireTokenByAuthorizationCodeAsync(code, scopes);
                        },
                        AuthenticationFailed = (context) =>
                        {
                            context.HandleResponse();
                            context.Response.Redirect("/Error?message=" + context.Exception.Message);
                            return Task.FromResult(0);
                        }
                    }
                });
        }
    }
}
```

11\. サインアウト時に OpenId Connect のキャッシュを消せるよう、AccountController.cs の LogOff メソッドを以下のコードで書き換え。using の追加を必要に応じて追加。

```csharp:AccountController.cs
//
// POST: /Account/LogOff
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult LogOff()
{
    if (Request.IsAuthenticated)
    {
        HttpContext.GetOwinContext().Authentication.SignOut(OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);
    }

    return RedirectToAction("Index", "Home");
}
```

12\. またログインでも OpenId Connect を利用するよう、Login メソッドを以下に書き換え

```csharp:AccountController.cs
//
// GET: /Account/Login
[AllowAnonymous]
public ActionResult Login(string returnUrl)
{
    if (!Request.IsAuthenticated)
    {
        // Signal OWIN to send an authorization request to Azure.
        HttpContext.GetOwinContext().Authentication.Challenge(
            new AuthenticationProperties { RedirectUri = "/" },
            OpenIdConnectAuthenticationDefaults.AuthenticationType);
    }
    ViewBag.ReturnUrl = returnUrl;
    return View();
}
```

13\. AntiForgery の対応として、Global.asax.cs の Application_Start メソッドを以下のコードと差し替え。

```csharp:Global.asax.cs
protected void Application_Start()
{
    AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
    AreaRegistration.RegisterAllAreas();
    FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
    RouteConfig.RegisterRoutes(RouteTable.Routes);
    BundleConfig.RegisterBundles(BundleTable.Bundles);
}
```

14\. Graph を使うサンプルとして HomeController.cs の About メソッドを以下に書き換え。

```csharp:HomeController.cs
using GraphWebAppDemo.TokenStorage;
using Newtonsoft.Json.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Configuration;
using System.Threading.Tasks;
using System.Net.Http.Headers;

[Authorize]
public async Task<ActionResult> About()
{
    string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
    TokenCache userTokenCache = new SessionTokenCache(signedInUserID, HttpContext).GetMsalCacheInstance();
    ConfidentialClientApplication cca = new ConfidentialClientApplication(
            clientId,
            redirectUri,
            new ClientCredential(clientSecret),
            userTokenCache, null);

    // Get an access token.
    var authResult = await cca.AcquireTokenSilentAsync(graphScopes.Split(new char[] { ' ' }), cca.Users.FirstOrDefault());

    using (HttpClient client = new HttpClient())
    {
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

        var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
        var me = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
        ViewBag.Message = me.Value<string>("displayName");
    }

    return View();
}
```

## アプリケーションのテスト

1\. F5 キーを押下してデバッグ実行。画面が起動したら「ログイン」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/310cb720-9941-7efb-4c09-e0b584dfbed8.png)

2\.　サインインページが出るので、サインイン。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/5b41d757-a629-7e83-4a38-33fddb0c4944.png)

3\. 「詳細」をクリック。名前が取得できることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ff97931f-40c6-12c4-1a57-c8d96b30963f.png)

## コードの詳細確認

**OpenId Connect と MSAL の連携**
MVC アプリケーションには OpenId Connect を利用してサインインしていますが、Scope として Microsoft Graph の権限も指定しています。これにより必要なアクセストークンを取得するための認可コードが Azure AD より返されます。

```csharp
app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        ClientId = clientId,
        Authority = "https://login.microsoftonline.com/common/v2.0",
        PostLogoutRedirectUri = redirectUri,
        RedirectUri = redirectUri,
        Scope = "openid email profile offline_access " + graphScopes,
        TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = false,
            // 本番運用のサーバーでは Issuer を検証し、正しいアプリケーションから戻ってきたものか確認。 
            //     IssuerValidator = (issuer, token, tvp) =>
            //     {
            //         if (MyCustomTenantValidation(issuer)) 
            //             return issuer;
            //         else
            //             throw new SecurityTokenInvalidIssuerException("Invalid issuer");
            //     },
        },
...
```

認可トークン取得成功時のコールバック関数で、MSAL の ConfidentialClientApplication を作成し、AcquireTokenByAuthorizationCodeAsync メソッドを実行して有効なアクセストークンが取得できるか検証するとともに、UserToken キャッシュにユーザー情報をキャッシュしています。尚、一度でも認可トークンが発行された場合、期限が切れるか、サインアウトするまで、コールバックは発生しません。

```csharp
Notifications = new OpenIdConnectAuthenticationNotifications
{
    AuthorizationCodeReceived = async (context) =>
    {
        var code = context.Code;
        string signedInUserID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;

        TokenCache userTokenCache = new SessionTokenCache(signedInUserID,
            context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase).GetMsalCacheInstance();
        ConfidentialClientApplication cca = new ConfidentialClientApplication(
            clientId,
            redirectUri,
            new ClientCredential(clientSecret),
            userTokenCache,
            null);
        string[] scopes = graphScopes.Split(new char[] { ' ' });

        AuthenticationResult result = await cca.AcquireTokenByAuthorizationCodeAsync(code, scopes);
    },
...
```
**OpenId Connect へのログインとログアウト**
Owin の Authentication.Challenge を呼び出してログインを実行しています。

```csharp
HttpContext.GetOwinContext().Authentication.Challenge(
    new AuthenticationProperties { RedirectUri = "/" },
    OpenIdConnectAuthenticationDefaults.AuthenticationType)
```

またログアウト時も Owin の Authentication.SignOut を呼び出して OpenId Connect およびクッキー認証ともにサインアウトしています。

```csharp
HttpContext.GetOwinContext().Authentication.SignOut(OpenIdConnectAuthenticationDefaults.AuthenticationType, CookieAuthenticationDefaults.AuthenticationType);           
```

**Microsoft Graph の呼び出し**
既にユーザートークンのキャッシュが終わっているため、ConfidentialClientApplication の AcquireTokenSilentAsync メソッドを、ユーザーを指定して実行しています。上記の OpenId Connect と認可コードの処理が失敗している場合は、必然的にこちらのコードも失敗します。

```csharp
string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
TokenCache userTokenCache = new SessionTokenCache(signedInUserID, HttpContext).GetMsalCacheInstance();
ConfidentialClientApplication cca = new ConfidentialClientApplication(
        clientId,
        redirectUri,
        new ClientCredential(clientSecret),
        userTokenCache, null);

// Get an access token.
var authResult = await cca.AcquireTokenSilentAsync(graphScopes.Split(new char[] { ' ' }), cca.Users.FirstOrDefault());

using (HttpClient client = new HttpClient())
{
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

    var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
    var me = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
    ViewBag.Message = me.Value<string>("displayName");
}
```

# ログインユーザーの表示名を取得する

現在、サインインしたにも関わらず、アプリケーションではユーザーの名前が取得できていません。これはユーザーのクレーム情報に名前が含まれないからです。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/7366baca-0684-876f-ead6-2d03fbf25cc1.png)

**Azure AD v2 の OpenId Connect 制限**

[プロトコルに関する制限事項](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/active-directory-v2-limitations#restrictions-on-protocols) にある通り、OpenId Connect でスコープの Profile を指定しても、ユーザー情報が返りません。そこで OpenId Connect にログイン後、さらに Microsoft Graph にアクセスし、名前を補完してみます。

1\. Startup.Auth.cs のAuthorizationCodeReceived コールバックを以下のコードと差し替え。

```csharp:Startup.Auth.cs
AuthorizationCodeReceived = async (context) =>
{
    var code = context.Code;
    string signedInUserID = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.NameIdentifier).Value;

    TokenCache userTokenCache = new SessionTokenCache(signedInUserID,
        context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase).GetMsalCacheInstance();
    ConfidentialClientApplication cca = new ConfidentialClientApplication(
        clientId,
        redirectUri,
        new ClientCredential(clientSecret),
        userTokenCache,
        null);
    string[] scopes = graphScopes.Split(new char[] { ' ' });

    AuthenticationResult result = await cca.AcquireTokenByAuthorizationCodeAsync(code, scopes);

    using (HttpClient client = new HttpClient())
    {
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken);

        var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me");
        var me = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
        System.Security.Claims.ClaimsIdentity claimsId = context.AuthenticationTicket.Identity;
        claimsId.AddClaim(new System.Security.Claims.Claim(
            System.Security.Claims.ClaimTypes.Name, me.Value<string>("displayName"), System.Security.Claims.ClaimValueTypes.String));
    }
},
```

2\. F5 キーを押下してプログラムを実行。サインイン状態の場合は一旦ログオフして、再度サインイン。名前が出ることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/59489bc9-d64c-8ed0-e5bb-c7df338376f1.png)

## コードの詳細確認

ではコードの中身を見ていきましょう。

**Microsoft Graph からユーザー情報の取得**
認可コードから AuthenticationResult を取得した後、そのまま /me にアクセスして名前を取得。

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

**クレームを追加**
現在のクレーム ID を取得して、名前クレームを追加。

```csharp
System.Security.Claims.ClaimsIdentity claimsId = context.AuthenticationTicket.Identity;
claimsId.AddClaim(new System.Security.Claims.Claim(
        System.Security.Claims.ClaimTypes.Name, me.Value<string>("displayName"), 
        System.Security.Claims.ClaimValueTypes.String));
```

# 増分同意を追加する

Azure AD v2 の機能として増分同意機能がありますが、現在は OpenId Connect でログインした際に取得した認可トークンを使っているため、増分同意に対応していません。

今回は「問い合わせ」メニューを変更して、連絡先一覧を取得できるようにし、増分同意を実装します。

1\. まずアプリケーションに権限を追加。[Microsoft アプリ登録ポータル](https://apps.dev.microsoft.com/) にログインして、「委任されたアクセス許可」に Contacts.Read 権限を追加して保存。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f9cc59f2-a442-cfd7-6ce6-72b3eb3aea1e.png)

2\. 次に Models フォルダーに Contact.cs ファイルを追加し、以下のコードを差し替え。

```csharp:Contact.cs
using Newtonsoft.Json;
using System.Collections.Generic;

namespace GraphWebAppDemo.Models
{
    public class Contact
    {
        [JsonProperty("id")]
        public string ContactId { get; set; }
        [JsonProperty("displayName")]
        public string DisplayName { get; set; }
        [JsonProperty("jobTitle")]
        public string JobTitle { get; set; }
        [JsonProperty("emailAddresses")]
        public List<EmailAddress> EmailAddresses { get; set; }

        public class EmailAddress
        {
            [JsonProperty("name")]
            public string Name { get; set; }
            [JsonProperty("address")]
            public string Address { get; set; }
        }
    }
}
```

3\. Shared フォルダにある _Layout.cshtml の 「問い合わせ」を「連絡先」に変更。

```html:_Layout.cshtml
<li>@Html.ActionLink("ホーム", "Index", "Home")</li>
<li>@Html.ActionLink("詳細", "About", "Home")</li>
<li>@Html.ActionLink("連絡先", "Contact", "Home")</li>
```

4\. HomeController.cs の Contact メソッドを以下のコードに差し替え。

```csharp:HomeController.cs
public async Task<ActionResult> Contact()
{
    string signedInUserID = ClaimsPrincipal.Current.FindFirst(ClaimTypes.NameIdentifier).Value;
    TokenCache userTokenCache = new SessionTokenCache(signedInUserID, HttpContext).GetMsalCacheInstance();
    ConfidentialClientApplication cca = new ConfidentialClientApplication(
            clientId,
            redirectUri,
            new ClientCredential(clientSecret),
            userTokenCache, null);

    var scopes = new string[] { "Contacts.Read" };

    try
    {
        var authResult = await cca.AcquireTokenSilentAsync(scopes, cca.Users.FirstOrDefault());
        using (HttpClient client = new HttpClient())
        {
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

            var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me/contacts");
            var resultJson = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
            var contacts = JsonConvert.DeserializeObject<List<Contact>>(resultJson["value"].ToString());

            return View(contacts);
        }
    }
    catch (MsalUiRequiredException)
    { 
        try
        {
            string authReqUrl = (await cca.GetAuthorizationRequestUrlAsync(scopes, null, null)).ToString();
            ViewBag.AuthorizationRequest = authReqUrl;
        }
        catch (Exception ee)
        {
            Response.Write(ee.Message);
        }
    }
            
    return View();
}
```

5\. 最後に画面の対応。Views/Home フォルダの Contact.cshtml を以下のコードに差し替え。

```html:Contact.cshtml
@model List<GraphWebAppDemo.Models.Contact>
@{
    ViewBag.Title = "連絡先一覧";
}

<h2>@ViewBag.Title</h2>

@if (ViewBag.AuthorizationRequest != null)
{
    <text>
        追加の同意が必要です。<a href="@ViewBag.AuthorizationRequest">こちら</a>をクリックしてください。
    </text>
}
else
{
    foreach (var contact in Model)
    {
        <text>名前: @contact.DisplayName</text><br />
        <text>タイトル: @contact.JobTitle</text><br />
        if (contact.EmailAddresses.Count > 0)
        {
            <text>メール: @contact.EmailAddresses.First().Address</text><br />
        }
    }
}
```

6\. F5 キーを押下してデバッグ開始。ログインしている場合は一旦ログオフして、再度ログイン。その後「連絡先」メニューをクリック。追加同意を求められる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e19fb400-8b30-1672-f67e-6fdba5977718.png)

7\. リンクをクリックするとサインインが求められるので、サインイン。ここで追加の同意が求められる。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/48433150-8674-ff33-5719-b98fd7023acb.png)

8\. 同意後、再度連絡先をクリック。一覧が取得できていることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/3ee951bf-611c-c9c4-b832-817f2e21d72e.png)

##コードの詳細確認

追加した内容を確認してみましょう。

**アクセストークン取得失敗の処理**
スコープが変わり、まだ同意が取れていない場合は、AcquireTokenSilentAsync が失敗します。そこで増分同意のアドレスを GetAuthorizationRequestUrlAsync メソッドで取得して画面に返します。

```csharp
try
{
    var authResult = await cca.AcquireTokenSilentAsync(scopes, cca.Users.FirstOrDefault());
...
}
catch (MsalUiRequiredException)
{ 
    try
    {
        string authReqUrl = (await cca.GetAuthorizationRequestUrlAsync(scopes, null, null)).ToString();
        ViewBag.AuthorizationRequest = authReqUrl;
    }
...
}
```

**Microsoft Graph の呼び出し**
前回同様 HttpClient を使っています。/me/contacts はコレクションを返すため、JSON のパースも value プロパティを配列としてパースしています。

```csharp
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

var httpResponse = await client.GetAsync("https://graph.microsoft.com/v1.0/me/contacts");
var resultJson = JToken.Parse(await httpResponse.Content.ReadAsStringAsync());
var contacts = JsonConvert.DeserializeObject<List<Contact>>(resultJson["value"].ToString());

return View(contacts);
```

**Contact クラス**
MVC でモデルを画面にバインドするには、クラスとして定義されたオブジェクトが使いやすいため、今回は Contact クラスを作成しました。

# 考慮事項

**ユーザートークンキャッシュの場所**
クライアントアプリケーションと異なり、Web アプリケーションは複数インスタンスで稼働する可能性が高い他、状況によってはサーバーが再起動されます。今回の開発でもアプリケーションを再起動するたびに、サインアウト/サインインが必要になったため、複数のインスタンスからアクセスでき、かつ永続化されるストレージにキャッシュを保存するようにしてください。

# まとめ
今回は MVC を例に Web アプリケーションにおける Microsoft Graph の利用を見ていきましたが、OpenId Connect のスコープに Microsoft Graph の権限を指定し、取得した認可コードを使ってアクセストークンを取得するフローは、他の Web アプリケーションでも同様です。GitHub にある多くのサンプルも同じパターンで実装しています。

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

# 参照
[Azure Active Directory v2.0 と OpenID Connect プロトコル](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/active-directory-v2-protocols-oidc)
[v2.0 プロトコル: OAuth 2.0 承認コード フロー](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/active-directory-v2-protocols-oauth-code)
[ASP.NET Web アプリへの "Microsoft でサインイン" の追加](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/guidedsetups/active-directory-aspnetwebapp)
[aspnet-connect-rest-sample](https://github.com/microsoftgraph/aspnet-connect-rest-sample)
[active-directory-dotnet-webapp-openidconnect-v2](https://github.com/Azure-Samples/active-directory-dotnet-webapp-openidconnect-v2)