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

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

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

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

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

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

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

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

1\. [Azure ポータル](https://portal.azure.com/) にアクセスして、ログイン。ここでは組織アカウントを使用。ログイン後、Azure Active Directory を選択。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/f9e8ccea-9d96-7972-dd0f-780f1882ce4b.png)

2\. 「アプリの登録」を選択し、「新しいアプリケーションの登録」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/3e0990e2-8214-6952-c8d3-d232d9742a43.png)

3\. 名前を指定し、「アプリケーションの種類」から「Web アプリ/API」を選択。任意の「リダイレクト URI」を指定し「作成」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/b7ad8eec-c18b-931e-3cf5-768319fc4a79.png)

4\. アプリが作成されたら「アプリション ID」を確認。その後「設定」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/14196fda-3846-851a-f746-d5cfe5ed1508.png)

5\. 「必要なアクセス許可」をクリックし、「追加」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/4c755e3a-67b6-0232-4c21-9447d1cd6dae.png)

6\. 「API を選択します」をクリックして、「Microsoft Graph」をクリック。「選択」ボタンをクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/e6e1af37-9f4b-dd32-37bd-77e1eba3dc0e.png)

7\. 「アクセス許可を選択します」をクリックし、必要な権限を追加。ここでは Read and write user and shared calendars を選択し、最後に「完了」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/00437263-207a-ebdf-6997-22af80b62bc7.png)

8\. キーメニューをクリックし、説明に名前を、期間を選択して「保存」。値が表示されるのでコピー。この値は画面遷移後はもう表示されない。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/caf3b345-3210-5b34-cec0-c7e905d5ace3.png)

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

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/72087dc0-e0cd-c625-313c-604c06b08d9c.png)

2\. MVC を選択。認証は「個別のユーザーアカウント」を選択して「OK」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/04cba893-9374-619b-6817-83c912f43306.png)

3\. NuGet の管理より「Microsoft.Owin.Security.OpenIdConnect」および「Microsoft.IdentityModel.Clients.ActiveDirectory (ADAL)」を追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/3ab4e5c0-c254-2606-37e1-0c00a1cc05d8.png)

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/91234c8e-5cef-27a9-08ea-e1d72be5f34b.png)

5\. [Azure ポータル](https://portal.azure.com) に登録したアプリケーションの設定より、「応答 URL」に上記アドレスを追加。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/2edaf8ed-5685-6cbb-ff0c-80741e6ddf36.png)

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

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/a7683e95-377f-04cb-20a0-deab85853f3e.png)

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

```csharp:TokenCache.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.IdentityModel.Clients.ActiveDirectory;
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.BeforeAccess = BeforeAccessNotification;
            cache.AfterAccess = 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();
            }
        }

    }
}
```

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

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

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 resourceUri = "https://graph.microsoft.com/";
        private static string authority = "https://login.windows.net/common/";

        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/",
                    RedirectUri = redirectUri,
                    PostLogoutRedirectUri = redirectUri,
                    Scope = OpenIdConnectScope.OpenIdProfile,
                    ResponseType = OpenIdConnectResponseType.CodeIdToken,
                    TokenValidationParameters = new TokenValidationParameters()
                    {
                        ValidateIssuer = false
                    },
                    Notifications = new OpenIdConnectAuthenticationNotifications
                    {
                        AuthorizationCodeReceived = async (context) =>
                        {
                            var code = context.Code;

                            string upn = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Upn).Value;
                            TokenCache tokenCache = new SessionTokenCache(upn,
                              context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase)
                              .GetAdalCacheInstance();

                            AuthenticationContext authContext =
                                new AuthenticationContext(authority, tokenCache);

                            AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
                                code, new Uri(redirectUri), new ClientCredential(clientId, clientSecret), resourceUri);
                        },
                        AuthenticationFailed = (context) =>
                        {
                            context.HandleResponse();
                            context.Response.Redirect("/?errormessage=" + context.Exception.Message);
                            return Task.FromResult(0);
                        }
                    }
                }
            );
        }
    }
}
```

9\. サインアウト時に 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");
}
```

10\. またログインでも 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();
}
```

11\. AntiForgery の対応として、Global.asax.cs を以下のコードと差し替え。

```csharp:Global.asax.cs
using System.IdentityModel.Claims;
using System.Web.Helpers;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;

namespace GraphWebAppDemo
{
    public class MvcApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
            AreaRegistration.RegisterAllAreas();
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}
```

12\. Graph を使うサンプルとして HomeController.cs を以下に書き換え。

```csharp:HomeController.cs
using GraphWebAppDemo.TokenStorage;
using Microsoft.IdentityModel.Clients.ActiveDirectory;
using Newtonsoft.Json.Linq;
using System.Configuration;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Threading.Tasks;
using System.Web.Mvc;

namespace GraphWebAppDemo.Controllers
{
    public class HomeController : Controller
    {
        private static string clientId = ConfigurationManager.AppSettings["ClientId"];
        private static string clientSecret = ConfigurationManager.AppSettings["ClientSecret"];
        private static string graphUrl = "https://graph.microsoft.com";
        private static string tenant = "common";//"graphdemo01.onmicrosoft.com";

        public ActionResult Index()
        {
            return View();
        }

        [Authorize]
        public async Task<ActionResult> About()
        {
            string upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
            TokenCache tokenCache = new SessionTokenCache(upn, HttpContext).GetAdalCacheInstance();

            AuthenticationContext authContext =
                new AuthenticationContext($"https://login.windows.net/{tenant}/", tokenCache);
        
            // Get an access token.
            AuthenticationResult authResult = await authContext.AcquireTokenSilentAsync(
                graphUrl, 
                new ClientCredential(clientId,clientSecret),  
                new UserIdentifier(upn, UserIdentifierType.RequiredDisplayableId));
            
            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();
        }

        public ActionResult Contact()
        {
            ViewBag.Message = "Your contact page.";

            return View();
        }
    }
}
```

13\. 各種設定値を　Web.config に追加します。

```xml:Web.config
<add key="ClientId" value="e3e6b8fe-36a2-4c9e-bc5c-52c1fe7b0504" />
<add key="ClientSecret" value="X8oGRlT5eyWz9g2e....." />
<add key="RedirectUri" value="http://localhost:53154/" />
```

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

1\. F5 キーを押下してアプリケーションを起動。「ログイン」をクリック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/142c40dc-9aff-fda5-4b24-31d692a2313e.png)

2\. サインインして、要求される権限を「承認」

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/96bed725-af00-a847-c2bf-94595c68c1ca.png)

3\. 「詳細」をクリックして Microsoft Graph より情報が取れることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/ec1d3cae-0381-4395-545d-ba15cc5f0dbe.png)

##コードの詳細確認

ここまでのコードを確認してみます。

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

```csharp
app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        ClientId = clientId,
        Authority = $"https://login.microsoftonline.com/common/",
        RedirectUri = redirectUri,
        PostLogoutRedirectUri = redirectUri,
        Scope = OpenIdConnectScope.OpenIdProfile,
        ResponseType = OpenIdConnectResponseType.CodeIdToken,
        TokenValidationParameters = new TokenValidationParameters()
        {
            ValidateIssuer = false
        },
        Notifications = new OpenIdConnectAuthenticationNotifications
        {
...
```

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

```csharp
Notifications = new OpenIdConnectAuthenticationNotifications
{
    AuthorizationCodeReceived = async (context) =>
    {
        var code = context.Code;

        string upn = context.AuthenticationTicket.Identity.FindFirst(ClaimTypes.Upn).Value;
        TokenCache tokenCache = new SessionTokenCache(upn,
            context.OwinContext.Environment["System.Web.HttpContextBase"] as HttpContextBase)
            .GetAdalCacheInstance();

        AuthenticationContext authContext =
            new AuthenticationContext(authority, tokenCache);

        AuthenticationResult authResult = await authContext.AcquireTokenByAuthorizationCodeAsync(
            code, new Uri(redirectUri), new ClientCredential(clientId, clientSecret), resourceUri);
    },
...
```

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

```csharp
string upn = ClaimsPrincipal.Current.FindFirst(ClaimTypes.Upn).Value;
TokenCache tokenCache = new SessionTokenCache(upn, HttpContext).GetAdalCacheInstance();

AuthenticationContext authContext =
    new AuthenticationContext($"https://login.windows.net/{tenant}/", tokenCache);
        
// Get an access token.
AuthenticationResult authResult = await authContext.AcquireTokenSilentAsync(
    graphUrl, 
    new ClientCredential(clientId,clientSecret),  
    new UserIdentifier(upn, UserIdentifierType.RequiredDisplayableId));
```

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

現在、サインインしたにも関わらず、アプリケーションではユーザーの名前が取得できていません。しかし OpenId Connect のプロファイルには名前が含まれるため、上書きしてみましょう。

1\. Startup.Auth.cs の AuthorizationCodeReceived コールバックの最後に以下のコードを追加。

```csharp:Startup.Auth.cs
System.Security.Claims.ClaimsIdentity claimsId = context.AuthenticationTicket.Identity;
claimsId.RemoveClaim(claimsId.FindFirst(ClaimTypes.Name));
claimsId.AddClaim(new System.Security.Claims.Claim(
    System.Security.Claims.ClaimTypes.Name, 
    context.AuthenticationTicket.Identity.FindFirst("name").Value, 
    System.Security.Claims.ClaimValueTypes.String));
```

2\. アプリケーションを実行してログイン。名前が表示されることを確認。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/0b6de3a8-9496-c792-5027-8da8da4a5e2e.png)

# 考慮事項

今回はあくまで動作確認のための開発をしましたが、本番環境では以下の考慮が必要です。

- キャッシュの有効期限が切れることも考えて、アクセストークン取得のエラーはハンドルする
- Web サーバーの増減や再起動を考慮して、キャッシュの場所を検討する

# まとめ
今回の開発を通して、Web アプリケーションを Azure AD v1 でセキュアにすると同時に、OpenId Connect で認証時に認可コードを取得し、認可コードで一度アクセストークンを取得することで、トークンキャッシュが生成される仕組みが見えたと思います。是非試してください。

#　参考
[Azure ポータル](https://portal.azure.com/)
[Azure AD の認証シナリオ](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/active-directory-authentication-scenarios#native-application-to-web-api)
[GitHub:active-directory-webapp-webapi-multitenant-openidconnect-aspnetcore](https://github.com/Azure-Samples/active-directory-webapp-webapi-multitenant-openidconnect-aspnetcore)