かずきのBlog@hatena

すきな言語は C# + XAML の組み合わせ。Azure Functions も好き。最近は Go 言語勉強中。日本マイクロソフトで働いていますが、ここに書いていることは個人的なメモなので会社の公式見解ではありません。

ASP.NET Core 3.0 Preview 8 で gRPC に Azure AD 認証つけてみよう

さて、前回は簡単に呼び出す奴を作ってみました。

blog.okazuki.jp

今回は Azure AD 認証を付けたいと思います。

Azure AD にアプリの登録

では、Azure AD にアプリを登録します。サーバー側とクライアント側の 2 つを登録しましょう。

サーバーアプリの登録

とりあえずシングルテナント(自分のテナントのユーザーだけ)でさくっとサーバー側を作ります。

f:id:okazuki:20190903171712p:plain

サーバー側のアプリが作成されたら、スコープを追加します。「APIの公開」で 「Scope の追加」を選択して適当な名前で作ります。 まず、アプリケーション ID URL を作るように言われるので「保存してから続ける」を選択します。

f:id:okazuki:20190903171920p:plain

続けてスコープの追加です。適当に名前を入れて、同意できる人を管理者とユーザーにしてその他の項目も適当に埋めていきます。

f:id:okazuki:20190903172116p:plain

スコープの追加ボタンを押すとスコープが追加されます。スコープの api://アプリID/スコープ名 は後で使うのでコピーしておきましょう。

クライアントアプリの登録

続けてクライアントのアプリを登録します。今回のクライアントアプリは .NET Core で作った WPF アプリです。 残念ながら各種ライブラリーが、まだ .NET Core には組み込みブラウザーなんてないと思って実装されてるので、そんな感じで登録します。

具体的にいうと、アプリ作成時(作成後でも編集できます)のリダイレクト URI で パブリッククライアント(モバイルとデスクトップ)を選択して http://localhost を設定します。

f:id:okazuki:20190903172444p:plain

アプリが出来たら「APIのアクセス許可」に行って「アクセス許可の追加」を選択します。API の選択画面になるので「自分の API」タブを選択して、先ほど作ったサーバー用アプリに作ったスコープを選択して「アクセス許可の追加」をします。

f:id:okazuki:20190903172809p:plain

後で使うので、クライアントアプリの「概要」ページにいって「アプリケーション(クライアント)ID」をコピーしておきます。

f:id:okazuki:20190903173043p:plain

テナント ID の取得

先ほどと同じアプリの「概要」ページから「ディレクトリ(テナント)ID」をコピーしておきます。

サーバーに認証機能を追加

では、サーバー側に認証機能を追加しましょう。 まず、以下の NuGet パッケージを入れます。

  • Microsoft.AspNetCore.Authentication.JwtBearer 3.0.0-preview8.xxxxx

そして、Startup.cs に JWT トークンによる認証の設定を追加します。

using GrpcService.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace GrpcServer
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            // これと
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    options.Authority = "https://login.microsoftonline.com/ここにテナントID/";
                    options.Audience = "api://サーバー側アプリのクライアントID";
                });
            services.AddAuthorization();

            services.AddGrpc();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            // これを追加
            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGrpcService<GreeterService>();
                endpoints.MapGet("/", async context =>
                {
                    await context.Response.WriteAsync("Hello World!");
                });
            });
        }
    }
}

ここの ConfigureServices で先ほどメモした値を使います。Authority はテナントIDを埋め込んで、Audience にサーバー側アプリのスコープを作成したときに作られた api://アプリID の値を設定します。先ほど控えたスコープの値から最後の /Call.API をとった値になります。

そして gRPC のサービスに Authorize 属性をつけます。ここら辺は REST API と同じ感じですね。

using System.Threading.Tasks;
using Grpc.Core;
using GrpcSample;
using Microsoft.AspNetCore.Authorization;

namespace GrpcService.Services
{
    [Authorize]
    public class GreeterService : Greeter.GreeterBase
    {
        public override Task<GreetReply> Greet(GreetRequest request, ServerCallContext context)
        {
            return Task.FromResult(new GreetReply
            {
                Message = $"Hello {request.Name}",
            });
        }
    }
}

この状態でアプリを起動して API を呼ぼうとすると、認証エラーになります。

f:id:okazuki:20190903174408p:plain

因みに前回は Visual Studio Code で dotnet run で起動したので別によかったのですが Visual Studio だとデフォルトで IIS Express で起動しようとするので、これをやめてやる必要があります。以下のような感じで設定変更できます。

f:id:okazuki:20190903174134p:plain

クライアントにログイン機能を追加

では、クライアントにログイン機能を追加しましょう。MSAL.NET を追加します。パッケージ名は、Microsoft.Identity.Client になります。 現時点での最新の 4.3.1 を入れました。

今回は簡単に実装するため全部 MainWindow.xaml.cs に書いていきます。書き忘れたけどサーバー側も設定をハードコーディングしてるけど、ちゃんと設定から読むようにしてね。

using System;
using System.Net.Http;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using GrpcSample;
using Microsoft.Identity.Client;

namespace GrpcClient
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private string[] Scopes { get; } = new[] { "api://サーバーアプリのID/Call.API" }; // スコープ作った時にコピーしておいたやつ
        private readonly IPublicClientApplication _app;
        public MainWindow()
        {
            InitializeComponent();

            // 本当は設定(appsettings.json とか)から読む
            var options = new PublicClientApplicationOptions
            {
                ClientId = "クライアントアプリのID",
                RedirectUri = "http://localhost",
                TenantId = "テナントのID",
            };
            _app = PublicClientApplicationBuilder.CreateWithApplicationOptions(options).Build();
        }

        private async void CallGrpcServiceButton_Click(object sender, RoutedEventArgs e)
        {
            var accessToken = await GetAccessTokenAsync();

            using (var client = new HttpClient
            {
                BaseAddress = new Uri("https://localhost:5001")
            })
            {
                var greetServices = Grpc.Net.Client.GrpcClient.Create<Greeter.GreeterClient>(client);
                var response = await greetServices.GreetAsync(new GreetRequest
                {
                    Name = textBoxName.Text,
                },
                new Grpc.Core.Metadata
                {
                    { "Authorization", $"Bearer {accessToken}" },                
                });
                MessageBox.Show(response.Message);
            }
        }

        private async Task<string> GetAccessTokenAsync()
        {
            AuthenticationResult r;
            try
            {
                var account = (await _app.GetAccountsAsync())?.FirstOrDefault();
                r = await _app.AcquireTokenSilent(Scopes, account).ExecuteAsync();
            }
            catch (MsalUiRequiredException)
            {
                r = await _app.AcquireTokenInteractive(Scopes)
                    .WithSystemWebViewOptions(new SystemWebViewOptions
                    {
                        OpenBrowserAsync = SystemWebViewOptions.OpenWithChromeEdgeBrowserAsync,
                    })
                    .ExecuteAsync();
            }

            return r.AccessToken;
        }
    }
}

IPublicClientApplication の作成をしているコンストラクターの処理と、アクセストークンの取得をしている GetAccessTokenAsync あたりが注目かな。 そして、最後に gRPC の API を呼び出すメソッドで Grpc.Core.Metadata でお馴染みの Bearer でトークン渡してやる感じです。

では、実行してみましょう。

WPF のアプリでボタンを押すとブラウザーでログイン画面が開きます。アプリを作った Azure AD のテナントのユーザーでサインインしてください。 同意が求められるので逆らわずにいきましょう。

ログインに成功すると、ブラウザーは閉じていいよというメッセージが出ます。なので閉じて(ほっといてもいいけど)WPFアプリ側に戻ると gRPC の API が呼べてますね!やったね!

f:id:okazuki:20190903175948p:plain

認証情報にアクセスしたい

サーバー側で認証情報触りたい場合はメソッドの引数の ServerCallContextGetHttpContext を呼ぶと HttpContext が取れるので、それ経由でアクセスできます。例えば以下のような感じで

using System.Threading.Tasks;
using Grpc.Core;
using GrpcSample;
using Microsoft.AspNetCore.Authorization;

namespace GrpcService.Services
{
    [Authorize]
    public class GreeterService : Greeter.GreeterBase
    {
        public override Task<GreetReply> Greet(GreetRequest request, ServerCallContext context)
        {
            var identity = context.GetHttpContext().User.Identity;
            return Task.FromResult(new GreetReply
            {
                Message = $"Hello {request.Name}, IsAuthorized: {identity.IsAuthenticated}, Your account is {identity.Name}",
            });
        }
    }
}

実行すると、以下のような感じになります。

f:id:okazuki:20190903180359p:plain

まとめ

Azure App Service でホスト出来るようになるのを首を長くして待ってます。

ソースコードは前回のリポジトリに addauth というブランチを切ってあるので見てください。

github.com