コードファースト ASP.NET Core gRPC での JWT Bearer 認証の利用

はじめに

ASP.NET Core gRPC は ASP.NET Core のミドルウェアをサポートしているけど、 protobuf-net.Grpc を使ってコードファーストで実装する場合でも、 JWT Bearer 認証が使えるか試してみた。

Shared プロジェクトを作成

毎度おなじみの、 クライアントとサーバーの両方で使う、サービスコントラクトとデータコントラクトを定義する。 プロジェクトの種類は .NET Core クラスライブラリ。 ServiceContractAttribute を使うために System.ServiceModel.Primitives、 CallContext を使うために protobuf-net.Grpc のパッケージを追加しておく。

今回は JWT を取得するためのサービスコントラクトも定義している。

using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading.Tasks;
using ProtoBuf.Grpc;

namespace CodeFirstGrpc.Shared
{
    [ServiceContract]
    public interface ITokenServie
    {
        ValueTask<TokenReply> TokenAsync(
            TokenRequest request);
    }

    [ServiceContract]
    public interface IGreetingService
    {
        ValueTask<HelloReply> HelloAsync(CallContext context = default);
    }

    [DataContract]
    public class TokenRequest
    {
        [DataMember(Order = 1)]
        public string UserName { get; set; }
    }

    [DataContract]
    public class TokenReply
    {
        [DataMember(Order = 1)]
        public string Token { get; set; }
    }

    [DataContract]
    public class HelloReply
    {
        [DataMember(Order = 1)]
        public string Message { get; set; }
    }
}

Server プロジェクトを作成

gRPC サービスプロジェクトを作成し、コードファーストで gRPC サービスを実装する。 コードファーストでやるために、 protobuf-net.Grpc.AspNetCore パッケージを追加しておく。

Microsoft.AspNetCore.Authentication.JwtBearer パッケージも追加し、 JWT Bearer 認証を組み込む。

using System;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Threading.Tasks;
using CodeFirstGrpc.Shared;
using Grpc.Core;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using ProtoBuf.Grpc;
using ProtoBuf.Grpc.Server;

namespace CodeFirstGrpc.Server
{
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }

    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddCodeFirstGrpc();

            services.AddAuthorization(options =>
            {
                // デフォルトの認証スキーマのポリシーを簡単なものに上書き
                options.AddPolicy(JwtBearerDefaults.AuthenticationScheme, policy =>
                {
                    policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme);
                    policy.RequireClaim(ClaimTypes.Name);
                });
            });

            // JWT Bearer 認証を組み込む
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
                .AddJwtBearer(options =>
                {
                    // サンプルを簡略化するため検証機能を OFF にする
                    // 本番でこんなことをしてはダメ
                    options.TokenValidationParameters =
                        new TokenValidationParameters
                        {
                            ValidateAudience = false,
                            ValidateIssuer = false,
                            ValidateActor = false,
                            ValidateLifetime = true,
                            IssuerSigningKey = SecurityKey
                        };
                });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseRouting();

            // パイプラインに認証と認可を組み込む
            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGrpcService<TokenService>();
                endpoints.MapGrpcService<GreetingService>();
            });
        }

        internal static readonly SymmetricSecurityKey SecurityKey =
            new SymmetricSecurityKey(Guid.NewGuid().ToByteArray());
    }

    public class TokenService : ITokenServie
    {
        public ValueTask<TokenReply> TokenAsync(TokenRequest request)
        {
            var token = GenerateJwtToken(request.UserName);
            return new ValueTask<TokenReply>(new TokenReply
            {
                Token = token,
            });
        }

        string GenerateJwtToken(string name)
        {
            if (string.IsNullOrEmpty(name))
            {
                throw new InvalidOperationException("Name is not specified.");
            }

            var claims = new[]
            {
                new Claim(ClaimTypes.Name, name),
            };
            var credentials = new SigningCredentials(
                Startup.SecurityKey,
                SecurityAlgorithms.HmacSha256);
            var token = new JwtSecurityToken(
                "ExampleServer",
                "ExampleClients",
                claims,
                expires: DateTime.Now.AddSeconds(60),
                signingCredentials: credentials);
            return JwtTokenHandler.WriteToken(token);
        }


        readonly JwtSecurityTokenHandler JwtTokenHandler = new JwtSecurityTokenHandler();
    }

    // 認証必須のサービス
    [Authorize]
    public class GreetingService : IGreetingService
    {
        public ValueTask<HelloReply> HelloAsync(CallContext context = default)
        {
            // 認証に成功していれば HttpContext からユーザーの情報を取得できるはず
            var httpContext = context.ServerCallContext.GetHttpContext();
            var userName = httpContext.User.Identity.Name;
            return new ValueTask<HelloReply>(new HelloReply
            {
                Message = $"Hello, {userName}.",
            });
        }
    }
}

Client プロジェクトを作成

.NET Core コンソールアプリのプロジェクトを作成し、 gRPC サービスを呼び出すためのクライアントを実装する。 コードファーストで実装した gRPC サービスを呼び出すために、 Grpc.Net.Client と protobuf-net.Grpc のパッケージを参照しておく。

using System;
using System.Threading.Tasks;
using CodeFirstGrpc.Shared;
using Grpc.Core;
using Grpc.Net.Client;
using ProtoBuf.Grpc.Client;

namespace CodeFirstGrpc.Client
{
    class Program
    {
        static async Task Main(string[] args)
        {
            var channel = GrpcChannel.ForAddress("https://localhost:5001");

            // JWT Bearer トークン無しで保護されたサービスを呼び出す
            var greetingClient = channel.CreateGrpcService<IGreetingService>();
            try
            {
                _ = await greetingClient.HelloAsync();
            }
            catch (RpcException ex)
            {
                Console.WriteLine(ex.Message);
            }

            // JWT Bearer トークン取得
            var tokenClient = channel.CreateGrpcService<ITokenServie>();
            var tokenReply = await tokenClient.TokenAsync(new TokenRequest
            {
                UserName = "Kubo",
            });

            // JWT Bearer トークンをヘッダーに付与して、保護されたサービスを呼び出す
            // CallOptions は CallContext に暗黙的に変換される
            var callOptions = new CallOptions(headers: new Metadata
            {
                new Metadata.Entry("Authorization", $"Bearer {tokenReply.Token}"),
            });
            var helloRepoly = await greetingClient.HelloAsync(callOptions);
            Console.WriteLine($"Message: {helloRepoly.Message}");

            Console.ReadLine();
        }
    }
}

Authorization ヘッダーは、Metadata に追加すれば良いみたいだった。

実行結果

Authorization ヘッダー無しで認証必須の gRPC サービスを呼び出すと、ちゃんとエラーになった。 Authorization ヘッダーを設定した場合は呼び出し成功。

f:id:griefworker:20200310174300p:plain

おわりに

コードファーストで ASP.NET Core gRPC のサービスを実装した場合でも、 ASP.NET Core の認証・認可ミドルウェアが使えそうだ。 gRPC サービスの認証をどうするかは課題だったので、ASP.NET Core MVC で慣れ親しんだ方法が使えるのは大きい。