ねののお庭。

かりかりもふもふ。

【C#】dotnet user-jwts はいいぞ。

.NET 7 から dotnet user-jwts という機能が生えてきました。ASP.NET Core で認証認可に JWT を使って開発している人間にとっては大変便利なのですよ、コレが。

はじめに

今の世の中 WEB API の認証認可は JWT で行っているケースが殆どでしょう。 そんな JWT ですが開発時にも当然使いますよね。SwaggerUI とか Postman に設定したりして。

が、そのような開発時に使う JWT の生成を皆さんはどうしているでしょう? 使っている認証プロバイダにもよると思いますが、概ね多少の面倒が付きまとうのではないでしょうか。

そこで .NET 7 から生えてきた新機能、 dotnet user-jwts の出番。これを使う事で、自身のアプリのソースコードを一切汚さずに、そのアプリのプロジェクト (csproj) で使える JWT が 1 コマンドで生成できます。大変便利。なお、あくまでこの機能は開発時用のものですからね、そこのところお忘れなく。

learn.microsoft.com

dotnet user-jwts の使い方...の前に下準備

とりあえず、ASP.NET Core の WEB API テンプレートを生成しましょう。CLI で dotnet new webapi -o UserJwtSandbox とやってもいいですし、Visual Studio で作成しても良いです。

で、認証の設定を入れていきましょう。 まず、JWT 認証を使うためのパッケージを導入します。

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

そして Program.cs に認証のサービスを追加しましょう。Authority と Audience を設定するだけで, OpenID Connect の仕様に従った認証が出来るようになるのでいい時代です。 Authority とか Audience は自分たちが使っている認証プロバイダから払い出されるものを適切に設定してください。

// 認証サービスを追加!
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://authority.example.com";
        options.Audience = "https://audience.example.com";
    });

ついでに、SwaggerUI で JWT 認証が使えるようにするための設定も同時にしてしまうと良いでしょう。

// SwaggerUI 上で JWT 認証を使えるようにするための設定を追加!
builder.Services.AddSwaggerGen(static options =>
{
    var securitySchema = new OpenApiSecurityScheme
    {
        Description = "Using the Authorization header with the Bearer scheme.",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        Reference = new OpenApiReference
        {
            Type = ReferenceType.SecurityScheme,
            Id = "Bearer"
        }
    };

    options.AddSecurityDefinition("Bearer", securitySchema);

    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        { securitySchema, new[] { "Bearer" } }
    });
});

そして http pipeline に認証のミドルウェアを追加します。

app.UseAuthentication(); // 追加!
app.UseAuthorization();

app.MapControllers();

そして WEB API のテンプレに含まれる WeatherForecastController に [Authorize] 属性を追加しましょう。

[Authorize] // 追加!
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

上記の設定をして SwaggerUI を開くと、右上に [Authorize 🔓] ボタンが現れます。そこクリックすると JWT を入力可能なモーダルが現れるので、取得した JWT を入力しましょう。

これで設定した認証プロバイダからどうにかして JWT を取得し、SwaggerUI 上で JWT を設定する事で、認可がかけられている /WeatherForecast を叩くことが出来るようになりました。ここまでは本題ではなく、準備運動です。

dotnet user-jwts の使い方

作成したプロジェクト (csproj) があるディレクトリでターミナルを開いてください。とりあえず PowerShell を開いたとしましょう。 そしてターミナルで、dotnet user-jwts create とコマンドを叩いてみてください。

$ dotnet user-jwts create

New JWT saved with ID '330371c2'.
Name: neno

Token: xxxxx.yyyyy.zzzz

はい、JWT が生成されます。この JWT は先ほど作成したプロジェクトで既に使うことが出来ます。 SwaggerUI 立ち上げて、生成された JWT を設定してみてるといいでしょう。

どういう JWT が生成されているかは jwt.io とかで確認しましょう。

実際に JWT 使って開発をする場合は、任意の sub を設定したかったり、なにか claim を設定したかったりする事が殆どでしょう。 そのような場合は、--name--claim, --role などのオプションで設定してあげる事ができます。 --name を弄る事で、JWT の sub を変更する事ができます。

$ dotnet user-jwts create --name daiba-nana --claim key=value --role role

使い方はこれだけです。非常に簡単...!

あとは以下のようなコマンドもあります。

// 過去に create した jwt のリストの表示
$ dotnet user-jwts list

// 過去に create した jwt を id から検索して表示
// id は create したタイミングで表示される他、list コマンドを使えば分かる。
$ dotnet user-jwts print 330371c2

// ヘルプ
$ dotnet user-jwts --help

気になるかもしれない点

  • Q : dotnet user-jwts を使ってもコードは汚染されてないように見えるけど、どこに設定生えるの?
    • A: appsettings.Development.json と user secret 2ヶ所に以下のような項目が新規に生成されます。
// appsettings.Development.json
{
    "Authentication": {
        "Schemes": {
            "Bearer": {
                "ValidAudiences": [
                    "http://localhost:xxxx"
                    "https://localhost:yyyy",
                ],
                "ValidIssuer": "dotnet-user-jwts"
            }
        }
    }
}
// user secret
{
    "Authentication:Schemes:Bearer:SigningKeys": [
        {
            "Id": "4e5ac008",
            "Issuer": "dotnet-user-jwts",
            "Value": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "Length": 32
        }
    ]
}
  • Q: 上記の生成される設定項目みると、Schemes:Bearer となってるけど、これだと AddJwtBearer とデフォルトスキーマ名衝突したりしない?(AddJwtBearer で設定した認証サービスは、デフォルトでは Bearer というスキーマ名で登録される)。 OpenID Connect にのっとった認証プロバイダの設定と、dotnet user-jwts で設定したもの、同一のスキーム名に同居できるの?
    • A: 同居可能。認証プロバイダ経由でとってきた JWT も使えるし、dotnet user-jwts で作成した JWT も同時に使える。

ASP.NET Core の内部実装

どういう風に実装されているから同居可能なのか。ちょっと内部実装を覗いてみましょう。(注: フレームワークの内部実装なので、別に知らなくても全然OKだし、読み飛ばしても問題ない内容です) まず、AddJwtBearer すると、いくつかのオーバーロードを経由して最終的にこの AddJwtBearer に行きつきます。

public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action<JwtBearerOptions> configureOptions)
{
  builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<JwtBearerOptions>, JwtBearerConfigureOptions>());
  builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, JwtBearerPostConfigureOptions>());
  return builder.AddScheme<JwtBearerOptions, JwtBearerHandler>(authenticationScheme, displayName, configureOptions);
}

サービスに IConfigureOptions<JwtBearerOptions> と、IPostConfigureOptions<JwtBearerOptions> が追加されてますね。

IPostConfigureOptions<TOptions> はその名の通り、IConfigureOptions<TOptions> の後に呼ばれるものです。 なので、TOptions は一度 IConfigureOptions で設定された後、IPostConfigureOptions で再度なんらかの設定がされます。

まず最初に設定される項目を見るべく、JwtBearerConfigureOptions を覗いてみましょう。

internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBearerOptions>
{
    private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider;
    private static readonly Func<string, TimeSpan> _invariantTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture);
 
    /// <summary>
    /// Initializes a new <see cref="JwtBearerConfigureOptions"/> given the configuration
    /// provided by the <paramref name="configurationProvider"/>.
    /// </summary>
    /// <param name="configurationProvider">An <see cref="IAuthenticationConfigurationProvider"/> instance.</param>\
    public JwtBearerConfigureOptions(IAuthenticationConfigurationProvider configurationProvider)
    {
        _authenticationConfigurationProvider = configurationProvider;
    }
 
    // 意図的に変更してない限り、name は "Bearer"
    public void Configure(string? name, JwtBearerOptions options)
    {
        if (string.IsNullOrEmpty(name))
        {
            return;
        }
 
        // これは configuration["Authentication:Schemes:Bearer"] とほぼ同義
        var configSection = _authenticationConfigurationProvider.GetSchemeConfiguration(name);
 
        if (configSection is null || !configSection.GetChildren().Any())
        {
            return;
        }
 
        var issuer = configSection[nameof(TokenValidationParameters.ValidIssuer)];
        var issuers = configSection.GetSection(nameof(TokenValidationParameters.ValidIssuers)).GetChildren().Select(iss => iss.Value).ToList();
        if (issuer is not null)
        {
            issuers.Add(issuer);
        }
        var audience = configSection[nameof(TokenValidationParameters.ValidAudience)];
        var audiences = configSection.GetSection(nameof(TokenValidationParameters.ValidAudiences)).GetChildren().Select(aud => aud.Value).ToList();
        if (audience is not null)
        {
            audiences.Add(audience);
        }
 
        options.Authority = configSection[nameof(options.Authority)] ?? options.Authority;
        options.BackchannelTimeout = StringHelpers.ParseValueOrDefault(configSection[nameof(options.BackchannelTimeout)], _invariantTimeSpanParse, options.BackchannelTimeout);
        options.Challenge = configSection[nameof(options.Challenge)] ?? options.Challenge;
        options.ForwardAuthenticate = configSection[nameof(options.ForwardAuthenticate)] ?? options.ForwardAuthenticate;
        options.ForwardChallenge = configSection[nameof(options.ForwardChallenge)] ?? options.ForwardChallenge;
        options.ForwardDefault = configSection[nameof(options.ForwardDefault)] ?? options.ForwardDefault;
        options.ForwardForbid = configSection[nameof(options.ForwardForbid)] ?? options.ForwardForbid;
        options.ForwardSignIn = configSection[nameof(options.ForwardSignIn)] ?? options.ForwardSignIn;
        options.ForwardSignOut = configSection[nameof(options.ForwardSignOut)] ?? options.ForwardSignOut;
        options.IncludeErrorDetails = StringHelpers.ParseValueOrDefault(configSection[nameof(options.IncludeErrorDetails)], bool.Parse, options.IncludeErrorDetails);
        options.MapInboundClaims = StringHelpers.ParseValueOrDefault( configSection[nameof(options.MapInboundClaims)], bool.Parse, options.MapInboundClaims);
        options.MetadataAddress = configSection[nameof(options.MetadataAddress)] ?? options.MetadataAddress;
        options.RefreshInterval = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshInterval)], _invariantTimeSpanParse, options.RefreshInterval);
        options.RefreshOnIssuerKeyNotFound = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshOnIssuerKeyNotFound)], bool.Parse, options.RefreshOnIssuerKeyNotFound);
        options.RequireHttpsMetadata = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RequireHttpsMetadata)], bool.Parse, options.RequireHttpsMetadata);
        options.SaveToken = StringHelpers.ParseValueOrDefault(configSection[nameof(options.SaveToken)], bool.Parse, options.SaveToken);
        options.TokenValidationParameters = new()
        {
            ValidateIssuer = issuers.Count > 0,
            ValidIssuers = issuers,
            ValidateAudience = audiences.Count > 0,
            ValidAudiences = audiences,
            ValidateIssuerSigningKey = true,
            IssuerSigningKeys = GetIssuerSigningKeys(configSection, issuers),
        };
    }
 
    private static IEnumerable<SecurityKey> GetIssuerSigningKeys(IConfiguration configuration, List<string?> issuers)
    {
        foreach (var issuer in issuers)
        {
            var signingKey = configuration.GetSection("SigningKeys")
                .GetChildren()
                .SingleOrDefault(key => key["Issuer"] == issuer);
            if (signingKey is not null && signingKey["Value"] is string keyValue)
            {
                yield return new SymmetricSecurityKey(Convert.FromBase64String(keyValue));
            }
        }
    }
 
    /// <inheritdoc />
    public void Configure(JwtBearerOptions options)
    {
        Configure(Options.DefaultName, options);
    }
}

見て分かる通り、configSection からいろいろ引っ張ってきているわけですが、その configSection を返している IAuthenticationConfigurationProvider が何をやっているかというと、 _configuration.GetSection(AuthenticationKey) 、要するに _configuration.GetSection("Authentication") しているだけ。

internal sealed class DefaultAuthenticationConfigurationProvider : IAuthenticationConfigurationProvider
{
    private readonly IConfiguration _configuration;
    private const string AuthenticationKey = "Authentication";
 
    // Note: this generally will never be called except in unit tests as IConfiguration is generally available from the host
    public DefaultAuthenticationConfigurationProvider() : this(new ConfigurationManager())
    { }
 
    public DefaultAuthenticationConfigurationProvider(IConfiguration configuration)
        => _configuration = configuration;
 
    public IConfiguration AuthenticationConfiguration => _configuration.GetSection(AuthenticationKey);
}

またその IAuthenticationConfigurationProvider では GetSchemeConfiguration(name); というメソッドを呼んでいますが、ここが何をやっているかというと、以下のような事をやっています。

public static class AuthenticationConfigurationProviderExtensions
{
    private const string AuthenticationSchemesKey = "Schemes";
 
    /// <summary>
    /// Returns the specified <see cref="IConfiguration"/> object.
    /// </summary>
    /// <param name="provider">An <see cref="IAuthenticationConfigurationProvider"/> instance.</param>
    /// <param name="authenticationScheme">The path to the section to be returned.</param>
    /// <returns>The specified <see cref="IConfiguration"/> object, or null if the requested section does not exist.</returns>
    public static IConfiguration GetSchemeConfiguration(this IAuthenticationConfigurationProvider provider, string authenticationScheme)
    {
        ArgumentNullException.ThrowIfNull(provider, nameof(provider));
 
        if (provider.AuthenticationConfiguration is null)
        {
            throw new InvalidOperationException("There was no top-level authentication property found in configuration.");
        }
 
        return provider.AuthenticationConfiguration.GetSection($"{AuthenticationSchemesKey}:{authenticationScheme}");
    }
}

ここまで見る事で、appsettings.Development.json と user secret に生まれた Authentication:Schemes:Bearer:SigningKeysAuthentication:Schemes:Bearer:ValidIssuerAuthentication:Schemes:Bearer までが JwtBearerConfigureOptions.Configure(string? name, JwtBearerOptions options) メソッド内の configSection で取得できている事が分かります。

Authentication:Schemes:Bearer:SigningKeys の SigningKeys の部分は?というと、以下のように JwtBearerConfigureOptions.Configure(string? name, JwtBearerOptions options) の最後の行に options.TokenValidationParameters を設定している箇所がありますが、そこの IssuerSigningKeys を設定している GetIssuerSigningKeys(configSection, issuers), で用いられています。

internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBearerOptions>
{
     // ...中略....

    public void Configure(string? name, JwtBearerOptions options)
    {
        // ...中略....
        options.TokenValidationParameters = new()
        {
            ValidateIssuer = issuers.Count > 0,
            ValidIssuers = issuers,
            ValidateAudience = audiences.Count > 0,
            ValidAudiences = audiences,
            ValidateIssuerSigningKey = true,
            IssuerSigningKeys = GetIssuerSigningKeys(configSection, issuers),
        };
    }
 
    private static IEnumerable<SecurityKey> GetIssuerSigningKeys(IConfiguration configuration, List<string?> issuers)
    {
        foreach (var issuer in issuers)
        {
            var signingKey = configuration.GetSection("SigningKeys")
                .GetChildren()
                .SingleOrDefault(key => key["Issuer"] == issuer);
            if (signingKey is not null && signingKey["Value"] is string keyValue)
            {
                yield return new SymmetricSecurityKey(Convert.FromBase64String(keyValue));
            }
        }
    }
 
     // ...中略....
}

ここまでで、dotnet user-jwts を使った事で appsettings.Development.json と user secret に生えてきた各種設定が読み込まれている事が分かります。 なお、コード上で設定した Authority は、configSection に含まれていたら上書きされるようになっています。

options.Authority = configSection[nameof(options.Authority)] ?? options.Authority;

次に、JwtBearerPostConfigureOptions の方を見ていくと、OIDC 用の設定があれこれされていることが見て取れます。

public class JwtBearerPostConfigureOptions : IPostConfigureOptions<JwtBearerOptions>
{
    /// <summary>
    /// Invoked to post configure a JwtBearerOptions instance.
    /// </summary>
    /// <param name="name">The name of the options instance being configured.</param>
    /// <param name="options">The options instance to configure.</param>
    public void PostConfigure(string? name, JwtBearerOptions options)
    {
        if (string.IsNullOrEmpty(options.TokenValidationParameters.ValidAudience) && !string.IsNullOrEmpty(options.Audience))
        {
            options.TokenValidationParameters.ValidAudience = options.Audience;
        }
 
        if (options.ConfigurationManager == null)
        {
            if (options.Configuration != null)
            {
                options.ConfigurationManager = new StaticConfigurationManager<OpenIdConnectConfiguration>(options.Configuration);
            }
            else if (!(string.IsNullOrEmpty(options.MetadataAddress) && string.IsNullOrEmpty(options.Authority)))
            {
                if (string.IsNullOrEmpty(options.MetadataAddress) && !string.IsNullOrEmpty(options.Authority))
                {
                    options.MetadataAddress = options.Authority;
                    if (!options.MetadataAddress.EndsWith("/", StringComparison.Ordinal))
                    {
                        options.MetadataAddress += "/";
                    }
 
                    options.MetadataAddress += ".well-known/openid-configuration";
                }
 
                if (options.RequireHttpsMetadata && !options.MetadataAddress.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
                {
                    throw new InvalidOperationException("The MetadataAddress or Authority must use HTTPS unless disabled for development by setting RequireHttpsMetadata=false.");
                }
 
                if (options.Backchannel == null)
                {
                    options.Backchannel = new HttpClient(options.BackchannelHttpHandler ?? new HttpClientHandler());
                    options.Backchannel.DefaultRequestHeaders.UserAgent.ParseAdd("Microsoft ASP.NET Core JwtBearer handler");
                    options.Backchannel.Timeout = options.BackchannelTimeout;
                    options.Backchannel.MaxResponseContentBufferSize = 1024 * 1024 * 10; // 10 MB
                }
 
               // ここに注目
               // ConfigurationManager は OIDC に関する ConfigurationManager である
               // JwtBearerHandler の方で使われているので留意しておく。
                options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(options.MetadataAddress, new OpenIdConnectConfigurationRetriever(),
                    new HttpDocumentRetriever(options.Backchannel) { RequireHttps = options.RequireHttpsMetadata })
                {
                    RefreshInterval = options.RefreshInterval,
                    AutomaticRefreshInterval = options.AutomaticRefreshInterval,
                };
            }
        }
    }
}

ここまでオプション ( JwtBearerOptions ) がどのように構成されるかを見てきたわけですが、実際 C# コードの AddJwtBearer(options => {...});で 設定したオプションと、dotnet user-jwts で appsettings.Development.json と user secret 上に作成された設定が共存している事が見て取れます。 コード中にコメントをしていますが、options.ConfigurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(...) の箇所を念頭に置きながら、次に進みましょう。

次は、実際に送られてきた JWT がどのような検証されているか、という点を見てみましょう。JwtBearerHandler がその責を担っています。

public class JwtBearerHandler : AuthenticationHandler<JwtBearerOptions>
{
    private OpenIdConnectConfiguration? _configuration; // 型に注目!!
    
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        string? token;
        try
        {
            // Give application opportunity to find from a different location, adjust, or reject token
            var messageReceivedContext = new MessageReceivedContext(Context, Scheme, Options);
 
            // SignalR で認証認可使う時に options.Events の設定をするわけですが、実はここで使われている。
            // https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-7.0#built-in-jwt-authentication
            await Events.MessageReceived(messageReceivedContext);
            if (messageReceivedContext.Result != null)
            {
                return messageReceivedContext.Result;
            }
 
            // If application retrieved token from somewhere else, use that.
            token = messageReceivedContext.Token;
 
            if (string.IsNullOrEmpty(token))
            {
                string authorization = Request.Headers.Authorization.ToString();
 
                // If no authorization header found, nothing to process further
                if (string.IsNullOrEmpty(authorization))
                {
                    return AuthenticateResult.NoResult();
                }

                // 通常のフロー。
                // options.Events を設定して独自の処理で token が取れなかった場合は、ここで jwt token が取得される。
                if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
                {
                    token = authorization.Substring("Bearer ".Length).Trim();
                }

                 // If no token found, no further work possible
                if (string.IsNullOrEmpty(token))
                {
                    return AuthenticateResult.NoResult();
                }
            }

            if (_configuration == null && Options.ConfigurationManager != null)
            {
                // この _configuration の型は OpenIdConnectConfiguration
                // 要するに OIDC に関するもの
                _configuration = await Options.ConfigurationManager.GetConfigurationAsync(Context.RequestAborted);
            }
 
            var validationParameters = Options.TokenValidationParameters.Clone();
            if (_configuration != null)
            {
             // _configuration が null でない、つまり OIDC 用の設定がある場合
               // 事前に登録されている ValidIssuers や IssuerSigningKeys にあれこれ concat されている事が分かる。
               // そのため、事前に登録されている SigningKeys 等 (≒ 今回の場合は dotnet user-jwts によって生成される SigningKeys 等)  と OIDC 用の設定は同居出来ている。
                var issuers = new[] { _configuration.Issuer };
                validationParameters.ValidIssuers = validationParameters.ValidIssuers?.Concat(issuers) ?? issuers;
 
                validationParameters.IssuerSigningKeys = validationParameters.IssuerSigningKeys?.Concat(_configuration.SigningKeys)
                    ?? _configuration.SigningKeys;
            }
 
            List<Exception>? validationFailures = null;
            SecurityToken? validatedToken = null;
            foreach (var validator in Options.SecurityTokenValidators)
            {
                if (validator.CanReadToken(token))
                {
                    ClaimsPrincipal principal;
                    try
                    {
                        // ここで concat されたヤツが使われている...!
                        principal = validator.ValidateToken(token, validationParameters, out validatedToken);
                    }

     // ...中略....
    
 }

コード中にあれこれ書きましたが、JwtBearerOptions.ConfigurationManager から OIDC に関する _configuration を取得して、その _configuration から IssuerSigningKeys などを取得し、既存の ValidIssuersIssuerSigningKeys に concat して使われているわけです。 なので当然、AddJwtBearer で設定した OIDC に関する設定と、dotnet user-jwts で生成した設定は同居可能なのでした。

ちなみに、JwtBearerHandler は service collection に AddTransient で追加されています。

おわりに

まぁ、内部のコードをつらつら読んでいきましたが、そんな事は忘れてOK で。 dotnet user-jwts は便利!という事だけ認識していただければ。

JWT を使った認証を使う場合、多くの場合 AddJwtBearer で OIDC を使うための設定とかをしていると思いますが、dotnet user-jwts はそれと競合する心配は一切ありません。 ガンガン使っていきましょう...!

References