4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-05-30

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

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

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

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

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

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

1. Microsoft アプリ登録ポータル にアクセスして、ログイン。ここでは組織アカウントを使用。

Capture.PNG

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

image.png

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

image.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

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

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

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

image.png

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

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

アプリケーションの開発

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

WebAPI の開発

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

image.png

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

image.png

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

image.png

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

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 を以下のコードに差し替え。

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。

<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 ポータル にログインし、「Azure Active Directory」選択後、プロパティから確認可能です。

image.png

WebAPI クライアントの開発

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

image.png

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

image.png

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

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 にあるアドレスを指定。

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

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

image.png

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

コードの詳細を確認

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

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

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

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 の時と異なり、「リソース/スコープ」のフォーマットで指定されます。

private static string[] scopes = new string[] { "api://1bc3f11c-5074-407c-8c9c-8b2d20933db6/access_as_user" };

Web API からユーザーの代理で Microsoft Graph にアクセス

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

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

image.png

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

image.png

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

image.png

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

<add key="ClientSecret" value="xhkxxxxxxxxxxxxx"/>
<add key="RedirectUri" value="http://localhost:64133"/>

5. ValuesController.cs を以下のコードと差し替え。

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

コードの詳細を確認

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

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

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

var bootstrapContext = ClaimsPrincipal.Current.Identities.First().BootstrapContext
    as BootstrapContext;
UserAssertion userAssertion = new UserAssertion(bootstrapContext.Token);

次に ConfidentialClientApplication を作成。

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");

まとめ

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

目次に戻る

参照

Azure Active Directory v2.0 と OAuth 2.0 の On-Behalf-Of フロー
GitHub: Calling a ASP.NET Core Web API from a WPF application using Azure AD V2
Building Web API Solutions with Authentication (英語動画)

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?