Browse Source

Add dotnet user-jwts tool and runtime support (#41520)

* Add dotnet dev-jwts tool

* Add dotnet dev-jwts tool

* Address feedback from review

* Rename project file

* Write auth config to app settings

* Address more feedback

* :seal:

* Apply suggestions from code review

Co-authored-by: Brennan <[email protected]>

* Address more feedback

* Add framework support for authentication changes

* Add tests for user-jwts CLI and react to feedback

* Move ConsoleTable implementation to avoid conflicts in ProjectTemplates

* Update existing auth tests and fix middleware registration

* Update AzureAdB2C tests and auth app builder

* Fix build and move registration check

* Fix up resolution for Certificate test sources

* Fix write stream configuration for writing key material

* Fix handling missing config section when processing options

Co-authored-by: Brennan <[email protected]>
Safia Abdalla 3 years ago
parent
commit
b034a7da67
49 changed files with 1884 additions and 2 deletions
  1. 60 0
      AspNetCore.sln
  2. 1 0
      eng/Signing.props
  3. 7 0
      src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs
  4. 5 0
      src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs
  5. 6 0
      src/DefaultBuilder/src/Microsoft.AspNetCore.csproj
  6. 1 0
      src/DefaultBuilder/src/PublicAPI.Unshipped.txt
  7. 48 0
      src/DefaultBuilder/src/WebApplicationAuthenticationBuilder.cs
  8. 19 0
      src/DefaultBuilder/src/WebApplicationBuilder.cs
  9. 20 0
      src/Http/Authentication.Abstractions/src/IAuthenticationConfigurationProvider.cs
  10. 2 0
      src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt
  11. 1 0
      src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs
  12. 3 0
      src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs
  13. 1 0
      src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs
  14. 21 0
      src/Security/Authentication/Core/src/DefaultAuthenticationConfigurationProvider.cs
  15. 17 0
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/MinimalJwtBearerSample.csproj
  16. 32 0
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Program.cs
  17. 31 0
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Properties/launchSettings.json
  18. 26 0
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json
  19. 9 0
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.json
  20. 72 0
      src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs
  21. 13 0
      src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs
  22. 1 0
      src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt
  23. 19 0
      src/Security/Authentication/test/AuthenticationMiddlewareTests.cs
  24. 1 0
      src/Security/Authentication/test/CertificateTests.cs
  25. 1 0
      src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
  26. 9 0
      src/Security/Authentication/test/SharedAuthenticationTests.cs
  27. 3 2
      src/Security/Security.slnf
  28. 2 0
      src/Shared/test/Certificates/Certificates.cs
  29. 2 0
      src/Tools/Tools.slnf
  30. 69 0
      src/Tools/dotnet-user-jwts/src/Commands/ClearCommand.cs
  31. 204 0
      src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs
  32. 56 0
      src/Tools/dotnet-user-jwts/src/Commands/DeleteCommand.cs
  33. 75 0
      src/Tools/dotnet-user-jwts/src/Commands/KeyCommand.cs
  34. 74 0
      src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs
  35. 64 0
      src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs
  36. 32 0
      src/Tools/dotnet-user-jwts/src/Commands/ProjectCommandLineApplication.cs
  37. 83 0
      src/Tools/dotnet-user-jwts/src/Helpers/ConsoleTable.cs
  38. 181 0
      src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs
  39. 13 0
      src/Tools/dotnet-user-jwts/src/Helpers/DevJwtDefaults.cs
  40. 34 0
      src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs
  41. 78 0
      src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs
  42. 15 0
      src/Tools/dotnet-user-jwts/src/Helpers/JwtCreatorOptions.cs
  43. 88 0
      src/Tools/dotnet-user-jwts/src/Helpers/JwtIssuer.cs
  44. 44 0
      src/Tools/dotnet-user-jwts/src/Helpers/JwtStore.cs
  45. 52 0
      src/Tools/dotnet-user-jwts/src/Program.cs
  46. 25 0
      src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj
  47. 102 0
      src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs
  48. 146 0
      src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs
  49. 16 0
      src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj

+ 60 - 0
AspNetCore.sln

@@ -1710,6 +1710,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Html.A
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RateLimiting", "RateLimiting", "{1D865E78-7A66-4CA9-92EE-2B350E45281F}"
 EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-jwts", "src\Tools\dotnet-user-jwts\src\dotnet-user-jwts.csproj", "{B34CB502-0286-4939-B25F-45998528A802}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet-user-jwts", "dotnet-user-jwts", "{AB4B9E75-719C-4589-B852-20FBFD727730}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalJwtBearerSample", "src\Security\Authentication\JwtBearer\samples\MinimalJwtBearerSample\MinimalJwtBearerSample.csproj", "{7F079E92-32D5-4257-B95B-CFFB0D49C160}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet-user-jwts.Tests", "src\Tools\dotnet-user-jwts\test\dotnet-user-jwts.Tests.csproj", "{89896261-C5DD-4901-BCA7-7A5F718BC008}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -10247,6 +10255,54 @@ Global
 		{487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x64.Build.0 = Release|Any CPU
 		{487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x86.ActiveCfg = Release|Any CPU
 		{487EF7BE-5009-4C70-B79E-45519BDD9603}.Release|x86.Build.0 = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|arm64.Build.0 = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|x64.Build.0 = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Debug|x86.Build.0 = Debug|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|Any CPU.Build.0 = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|arm64.ActiveCfg = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|arm64.Build.0 = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|x64.ActiveCfg = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|x64.Build.0 = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|x86.ActiveCfg = Release|Any CPU
+		{B34CB502-0286-4939-B25F-45998528A802}.Release|x86.Build.0 = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|arm64.Build.0 = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|x64.Build.0 = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Debug|x86.Build.0 = Debug|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|Any CPU.Build.0 = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|arm64.ActiveCfg = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|arm64.Build.0 = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|x64.ActiveCfg = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|x64.Build.0 = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|x86.ActiveCfg = Release|Any CPU
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160}.Release|x86.Build.0 = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|arm64.Build.0 = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|x64.Build.0 = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Debug|x86.Build.0 = Debug|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|Any CPU.Build.0 = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|arm64.ActiveCfg = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|arm64.Build.0 = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|x64.ActiveCfg = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|x64.Build.0 = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|x86.ActiveCfg = Release|Any CPU
+		{89896261-C5DD-4901-BCA7-7A5F718BC008}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -11094,6 +11150,10 @@ Global
 		{51D07AA9-6297-4F66-A7BD-71CE7E3F4A3F} = {0F84F170-57D0-496B-8E2C-7984178EF69F}
 		{487EF7BE-5009-4C70-B79E-45519BDD9603} = {412D4C15-F48F-4DB1-940A-131D1AA87088}
 		{1D865E78-7A66-4CA9-92EE-2B350E45281F} = {E5963C9F-20A6-4385-B364-814D2581FADF}
+		{B34CB502-0286-4939-B25F-45998528A802} = {AB4B9E75-719C-4589-B852-20FBFD727730}
+		{AB4B9E75-719C-4589-B852-20FBFD727730} = {0B200A66-B809-4ED3-A790-CB1C2E80975E}
+		{7F079E92-32D5-4257-B95B-CFFB0D49C160} = {7FD32066-C831-4E29-978C-9A2215E85C67}
+		{89896261-C5DD-4901-BCA7-7A5F718BC008} = {AB4B9E75-719C-4589-B852-20FBFD727730}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}

+ 1 - 0
eng/Signing.props

@@ -79,6 +79,7 @@
     <FileSignInfo Include="dotnet-user-secrets.exe" CertificateName="MicrosoftDotNet500" />
     <FileSignInfo Include="dotnet-watch.exe" CertificateName="MicrosoftDotNet500" />
     <FileSignInfo Include="dotnet-openapi.exe" CertificateName="MicrosoftDotNet500" />
+    <FileSignInfo Include="dotnet-user-jwts.exe" CertificateName="MicrosoftDotNet500" />
     <FileSignInfo Include="Microsoft.AspNetCore.Blazor.Build.exe" CertificateName="MicrosoftDotNet500" />
     <FileSignInfo Include="sni.dll" CertificateName="MicrosoftDotNet500" />
 

+ 7 - 0
src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.JwtBearer;
 using Microsoft.AspNetCore.Authentication.OpenIdConnect;
 using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
@@ -21,6 +22,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -288,6 +290,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -305,6 +308,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -340,6 +344,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -373,6 +378,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -473,6 +479,7 @@ public class AzureADAuthenticationBuilderExtensionsTests
     {
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         services.AddAuthentication()
             .AddAzureADBearer(o => { })

+ 5 - 0
src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs

@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.JwtBearer;
 using Microsoft.AspNetCore.Authentication.OpenIdConnect;
 using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
@@ -262,6 +263,7 @@ public class AzureADB2CAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -279,6 +281,7 @@ public class AzureADB2CAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -315,6 +318,7 @@ public class AzureADB2CAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()
@@ -348,6 +352,7 @@ public class AzureADB2CAuthenticationBuilderExtensionsTests
         // Arrange
         var services = new ServiceCollection();
         services.AddSingleton<ILoggerFactory>(new NullLoggerFactory());
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         // Act
         services.AddAuthentication()

+ 6 - 0
src/DefaultBuilder/src/Microsoft.AspNetCore.csproj

@@ -11,6 +11,8 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Authentication" />
+    <Reference Include="Microsoft.AspNetCore.Authorization.Policy" />
     <Reference Include="Microsoft.AspNetCore.Diagnostics" />
     <Reference Include="Microsoft.AspNetCore.HostFiltering" />
     <Reference Include="Microsoft.AspNetCore.Hosting" />
@@ -32,4 +34,8 @@
     <Reference Include="Microsoft.Extensions.Logging.EventSource" />
   </ItemGroup>
 
+  <ItemGroup>
+    <InternalsVisibleTo Include="Microsoft.AspNetCore.Authentication.Test" />
+  </ItemGroup>
+
 </Project>

+ 1 - 0
src/DefaultBuilder/src/PublicAPI.Unshipped.txt

@@ -1,3 +1,4 @@
 #nullable enable
 Microsoft.AspNetCore.Builder.WebApplication.Use(System.Func<Microsoft.AspNetCore.Http.RequestDelegate!, Microsoft.AspNetCore.Http.RequestDelegate!>! middleware) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
+Microsoft.AspNetCore.Builder.WebApplicationBuilder.Authentication.get -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
 static Microsoft.Extensions.Hosting.GenericHostBuilderExtensions.ConfigureWebHostDefaults(this Microsoft.Extensions.Hosting.IHostBuilder! builder, System.Action<Microsoft.AspNetCore.Hosting.IWebHostBuilder!>! configure, System.Action<Microsoft.Extensions.Hosting.WebHostBuilderOptions!>! configureOptions) -> Microsoft.Extensions.Hosting.IHostBuilder!

+ 48 - 0
src/DefaultBuilder/src/WebApplicationAuthenticationBuilder.cs

@@ -0,0 +1,48 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+internal class WebApplicationAuthenticationBuilder : AuthenticationBuilder
+{
+    public bool IsAuthenticationConfigured { get; private set; }
+
+    public WebApplicationAuthenticationBuilder(IServiceCollection services) : base(services) { }
+
+    public override AuthenticationBuilder AddPolicyScheme(string authenticationScheme, string? displayName, Action<PolicySchemeOptions> configureOptions)
+    {
+        RegisterServices(authenticationScheme);
+        return base.AddPolicyScheme(authenticationScheme, displayName, configureOptions);
+    }
+
+    public override AuthenticationBuilder AddRemoteScheme<TOptions, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(string authenticationScheme, string? displayName, Action<TOptions>? configureOptions)
+    {
+        RegisterServices(authenticationScheme);
+        return base.AddRemoteScheme<TOptions, THandler>(authenticationScheme, displayName, configureOptions);
+    }
+
+    public override AuthenticationBuilder AddScheme<TOptions, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(string authenticationScheme, string? displayName, Action<TOptions>? configureOptions)
+    {
+        RegisterServices(authenticationScheme);
+        return base.AddScheme<TOptions, THandler>(authenticationScheme, displayName, configureOptions);
+    }
+
+    public override AuthenticationBuilder AddScheme<TOptions, [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>(string authenticationScheme, Action<TOptions>? configureOptions)
+    {
+        RegisterServices(authenticationScheme);
+        return base.AddScheme<TOptions, THandler>(authenticationScheme, configureOptions);
+    }
+
+    private void RegisterServices(string authenticationScheme)
+    {
+        if (!IsAuthenticationConfigured)
+        {
+            IsAuthenticationConfigured = true;
+            Services.AddAuthentication(authenticationScheme);
+            Services.AddAuthorization();
+        }
+    }
+}

+ 19 - 0
src/DefaultBuilder/src/WebApplicationBuilder.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Diagnostics;
+using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -16,9 +17,11 @@ namespace Microsoft.AspNetCore.Builder;
 public sealed class WebApplicationBuilder
 {
     private const string EndpointRouteBuilderKey = "__EndpointRouteBuilder";
+    private const string AuthenticationMiddlewareSetKey = "__AuthenticationMiddlewareSet";
 
     private readonly HostApplicationBuilder _hostApplicationBuilder;
     private readonly ServiceDescriptor _genericWebHostServiceDescriptor;
+    private readonly WebApplicationAuthenticationBuilder _webAuthBuilder;
 
     private WebApplication? _builtApplication;
 
@@ -79,6 +82,7 @@ public sealed class WebApplicationBuilder
 
         Host = new ConfigureHostBuilder(bootstrapHostBuilder.Context, Configuration, Services);
         WebHost = new ConfigureWebHostBuilder(webHostContext, Configuration, Services);
+        _webAuthBuilder = new WebApplicationAuthenticationBuilder(Services);
     }
 
     /// <summary>
@@ -113,6 +117,11 @@ public sealed class WebApplicationBuilder
     /// </summary>
     public ConfigureHostBuilder Host { get; }
 
+    /// <summary>
+    /// An <see cref="AuthenticationBuilder"/> for configuration authentication-related properties.
+    /// </summary>
+    public AuthenticationBuilder Authentication => _webAuthBuilder;
+
     /// <summary>
     /// Builds the <see cref="WebApplication"/>.
     /// </summary>
@@ -166,6 +175,16 @@ public sealed class WebApplicationBuilder
             }
         }
 
+        if (_webAuthBuilder.IsAuthenticationConfigured)
+        {
+            // Don't add more than one instance of the middleware
+            if (!_builtApplication.Properties.ContainsKey(AuthenticationMiddlewareSetKey))
+            {
+                _builtApplication.UseAuthentication();
+                _builtApplication.UseAuthorization();
+            }
+        }
+
         // Wire the source pipeline to run in the destination pipeline
         app.Use(next =>
         {

+ 20 - 0
src/Http/Authentication.Abstractions/src/IAuthenticationConfigurationProvider.cs

@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+/// <summary>
+/// Provides an interface for implmenting a construct that provides
+/// access to specific configuration sections.
+/// </summary>
+public interface IAuthenticationConfigurationProvider
+{
+    /// <summary>
+    /// Returns the specified <see cref="ConfigurationSection"/> object.
+    /// </summary>
+    /// <param name="authenticationScheme">The path to the section to be returned.</param>
+    /// <returns>The specified <see cref="ConfigurationSection"/> object, or null if the requested section does not exist.</returns>
+    IConfiguration GetAuthenticationSchemeConfiguration(string authenticationScheme);
+}

+ 2 - 0
src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt

@@ -1 +1,3 @@
 #nullable enable
+Microsoft.AspNetCore.Authentication.IAuthenticationConfigurationProvider
+Microsoft.AspNetCore.Authentication.IAuthenticationConfigurationProvider.GetAuthenticationSchemeConfiguration(string! authenticationScheme) -> Microsoft.Extensions.Configuration.IConfiguration!

+ 1 - 0
src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Security.Cryptography.X509Certificates;
+using Microsoft.AspNetCore.Authentication.Certificate;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.TestHost;

+ 3 - 0
src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs

@@ -10,6 +10,8 @@ namespace Microsoft.AspNetCore.Builder;
 /// </summary>
 public static class AuthAppBuilderExtensions
 {
+    internal const string AuthenticationMiddlewareSetKey = "__AuthenticationMiddlewareSet";
+
     /// <summary>
     /// Adds the <see cref="AuthenticationMiddleware"/> to the specified <see cref="IApplicationBuilder"/>, which enables authentication capabilities.
     /// </summary>
@@ -22,6 +24,7 @@ public static class AuthAppBuilderExtensions
             throw new ArgumentNullException(nameof(app));
         }
 
+        app.Properties[AuthenticationMiddlewareSetKey] = true;
         return app.UseMiddleware<AuthenticationMiddleware>();
     }
 }

+ 1 - 0
src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs

@@ -27,6 +27,7 @@ public static class AuthenticationServiceCollectionExtensions
         services.AddDataProtection();
         services.AddWebEncoders();
         services.TryAddSingleton<ISystemClock, SystemClock>();
+        services.TryAddSingleton<IAuthenticationConfigurationProvider, DefaultAuthenticationConfigurationProvider>();
         return new AuthenticationBuilder(services);
     }
 

+ 21 - 0
src/Security/Authentication/Core/src/DefaultAuthenticationConfigurationProvider.cs

@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+internal sealed class DefaultAuthenticationConfigurationProvider : IAuthenticationConfigurationProvider
+{
+    private readonly IConfiguration _configuration;
+
+    public DefaultAuthenticationConfigurationProvider(IConfiguration configuration)
+    {
+        _configuration = configuration;
+    }
+
+    public IConfiguration GetAuthenticationSchemeConfiguration(string authenticationScheme)
+    {
+        return _configuration.GetSection($"Authentication:Schemes:{authenticationScheme}");
+    }
+}

+ 17 - 0
src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/MinimalJwtBearerSample.csproj

@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <UserSecretsId>MinimalJwtBearerSample-20151210102827</UserSecretsId>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore" />
+    <Reference Include="Microsoft.AspNetCore.Authentication" />
+    <Reference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
+    <Reference Include="Microsoft.AspNetCore.Authorization.Policy" />
+  </ItemGroup>
+
+</Project>

+ 32 - 0
src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Program.cs

@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.AspNetCore.Builder;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Authentication.AddJwtBearer();
+builder.Authentication.AddJwtBearer("ClaimedDetails");
+
+builder.Services.AddAuthorization(options =>
+    options.AddPolicy("is_admin", policy =>
+    {
+        policy.RequireAuthenticatedUser();
+        policy.RequireClaim("is_admin", "true");
+    }));
+
+var app = builder.Build();
+
+app.MapGet("/protected", (ClaimsPrincipal user) => $"Hello {user.Identity?.Name}!")
+    .RequireAuthorization();
+
+app.MapGet("/protected-with-claims", (ClaimsPrincipal user) =>
+{
+    return $"Glory be to the admin {user.Identity?.Name}!";
+})
+.RequireAuthorization("is_admin");
+
+app.Run();

+ 31 - 0
src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Properties/launchSettings.json

@@ -0,0 +1,31 @@
+{
+  "$schema": "https://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:56852",
+      "sslPort": 44385
+    }
+  },
+  "profiles": {
+    "MinimalJwtBearerSample": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "launchUrl": "protected",
+      "applicationUrl": "https://localhost:7259;http://localhost:5259",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "launchUrl": "protected",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}

+ 26 - 0
src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json

@@ -0,0 +1,26 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "Authentication": {
+    "Schemes": {
+      "Bearer": {
+        "Audiences": [
+          "https://localhost:7259",
+          "http://localhost:5259"
+        ],
+        "ClaimsIssuer": "dotnet-user-jwts"
+      },
+      "ClaimedDetails": {
+        "Audiences": [
+          "https://localhost:7259",
+          "http://localhost:5259"
+        ],
+        "ClaimsIssuer": "dotnet-user-jwts"
+      }
+    }
+  }
+}

+ 9 - 0
src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.json

@@ -0,0 +1,9 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Information",
+      "Microsoft.AspNetCore": "Warning"
+    }
+  },
+  "AllowedHosts": "*"
+}

+ 72 - 0
src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs

@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.AspNetCore.Authentication;
+
+internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBearerOptions>
+{
+    private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider;
+    private readonly IConfiguration _configuration;
+
+    /// <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>
+    /// <param name="configuration">An <see cref="IConfiguration"/> instance for accessing configuration elements not in the schema.</param>
+    public JwtBearerConfigureOptions(IAuthenticationConfigurationProvider configurationProvider, IConfiguration configuration)
+    {
+        _authenticationConfigurationProvider = configurationProvider;
+        _configuration = configuration;
+    }
+
+    /// <inheritdoc />
+    public void Configure(string? name, JwtBearerOptions options)
+    {
+        if (string.IsNullOrEmpty(name))
+        {
+            return;
+        }
+
+        var configSection = _authenticationConfigurationProvider.GetAuthenticationSchemeConfiguration(name);
+
+        if (configSection is null || !configSection.GetChildren().Any())
+        {
+            return;
+        }
+
+        var issuer = configSection["ClaimsIssuer"];
+        var audiences = configSection.GetSection("Audiences").GetChildren().Select(aud => aud.Value).ToArray();
+        options.TokenValidationParameters = new()
+        {
+            ValidateIssuer = issuer is not null,
+            ValidIssuers = new[] { issuer },
+            ValidateAudience = audiences.Length > 0,
+            ValidAudiences = audiences,
+            ValidateIssuerSigningKey = true,
+            IssuerSigningKey = GetIssuerSigningKey(_configuration, issuer),
+        };
+    }
+
+    private static SecurityKey GetIssuerSigningKey(IConfiguration configuration, string? issuer)
+    {
+        var jwtKeyMaterialSecret = configuration[$"{issuer}:KeyMaterial"];
+        var jwtKeyMaterial = !string.IsNullOrEmpty(jwtKeyMaterialSecret)
+            ? Convert.FromBase64String(jwtKeyMaterialSecret)
+            : RandomNumberGenerator.GetBytes(32);
+        return new SymmetricSecurityKey(jwtKeyMaterial);
+    }
+
+    /// <inheritdoc />
+    public void Configure(JwtBearerOptions options)
+    {
+        Configure(Options.DefaultName, options);
+    }
+}

+ 13 - 0
src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs

@@ -24,6 +24,18 @@ public static class JwtBearerExtensions
     public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder)
         => builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { });
 
+    /// <summary>
+    /// Enables JWT-bearer authentication using a pre-defined scheme.
+    /// <para>
+    /// JWT bearer authentication performs authentication by extracting and validating a JWT token from the <c>Authorization</c> request header.
+    /// </para>
+    /// </summary>
+    /// <param name="builder">The <see cref="AuthenticationBuilder"/>.</param>
+    /// <param name="authenticationScheme">The authentication scheme.</param>
+    /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
+    public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme)
+        => builder.AddJwtBearer(authenticationScheme, _ => { });
+
     /// <summary>
     /// Enables JWT-bearer authentication using the default scheme <see cref="JwtBearerDefaults.AuthenticationScheme"/>.
     /// <para>
@@ -62,6 +74,7 @@ public static class JwtBearerExtensions
     /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
     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);
     }

+ 1 - 0
src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt

@@ -1,3 +1,4 @@
 #nullable enable
 *REMOVED*Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerPostConfigureOptions.PostConfigure(string! name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void
 Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerPostConfigureOptions.PostConfigure(string? name, Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerOptions! options) -> void
+static Microsoft.Extensions.DependencyInjection.JwtBearerExtensions.AddJwtBearer(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!

+ 19 - 0
src/Security/Authentication/test/AuthenticationMiddlewareTests.cs

@@ -151,6 +151,25 @@ public class AuthenticationMiddlewareTests
         Assert.Same(context.User, newTicket.Principal);
     }
 
+    [Fact]
+    public async Task WebApplicationBuilder_RegistersAuthenticationMiddlewares()
+    {
+        var builder = WebApplication.CreateBuilder();
+        builder.Authentication.AddJwtBearer();
+        await using var app = builder.Build();
+
+        var webAppAuthBuilder = Assert.IsType<WebApplicationAuthenticationBuilder>(builder.Authentication);
+        Assert.True(webAppAuthBuilder.IsAuthenticationConfigured);
+
+        // Authentication middleware isn't registered until application
+        // is built on startup
+        Assert.False(app.Properties.ContainsKey("__AuthenticationMiddlewareSet"));
+
+        await app.StartAsync();
+
+        Assert.True(app.Properties.ContainsKey("__AuthenticationMiddlewareSet"));
+    }
+
     private HttpContext GetHttpContext(
         Action<IServiceCollection> registerServices = null,
         IAuthenticationService authenticationService = null)

+ 1 - 0
src/Security/Authentication/test/CertificateTests.cs

@@ -6,6 +6,7 @@ using System.Net;
 using System.Security.Claims;
 using System.Security.Cryptography.X509Certificates;
 using System.Xml.Linq;
+using Microsoft.AspNetCore.Authentication.Certificate;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Http;

+ 1 - 0
src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj

@@ -36,6 +36,7 @@
   </ItemGroup>
 
   <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore" />
     <Reference Include="Microsoft.AspNetCore.Authentication.Certificate" />
     <Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />
     <Reference Include="Microsoft.AspNetCore.Authentication.Facebook" />

+ 9 - 0
src/Security/Authentication/test/SharedAuthenticationTests.cs

@@ -4,6 +4,7 @@
 using System.Security.Claims;
 using Microsoft.AspNetCore.Authentication.Tests;
 using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Authentication;
@@ -25,6 +26,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task CanForwardDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
 
         var builder = services.AddAuthentication(o =>
         {
@@ -165,6 +167,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task ForwardForbidWinsOverDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;
@@ -214,6 +217,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task ForwardAuthenticateOnlyRunsTransformOnceByDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var transform = new RunOnce();
         var builder = services.AddSingleton<IClaimsTransformation>(transform).AddAuthentication(o =>
         {
@@ -244,6 +248,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task ForwardAuthenticateWinsOverDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;
@@ -283,6 +288,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task ForwardChallengeWinsOverDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;
@@ -322,6 +328,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task ForwardSelectorWinsOverDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;
@@ -391,6 +398,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task NullForwardSelectorUsesDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;
@@ -460,6 +468,7 @@ public abstract class SharedAuthenticationTests<TOptions> where TOptions : Authe
     public async Task SpecificForwardWinsOverSelectorAndDefault()
     {
         var services = new ServiceCollection().AddLogging();
+        services.AddSingleton<IConfiguration>(new ConfigurationManager());
         var builder = services.AddAuthentication(o =>
         {
             o.DefaultScheme = DefaultScheme;

+ 3 - 2
src/Security/Security.slnf

@@ -6,6 +6,7 @@
       "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj",
       "src\\DataProtection\\DataProtection\\src\\Microsoft.AspNetCore.DataProtection.csproj",
       "src\\DefaultBuilder\\src\\Microsoft.AspNetCore.csproj",
+      "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
       "src\\Hosting\\Abstractions\\src\\Microsoft.AspNetCore.Hosting.Abstractions.csproj",
       "src\\Hosting\\Hosting\\src\\Microsoft.AspNetCore.Hosting.csproj",
       "src\\Hosting\\Server.Abstractions\\src\\Microsoft.AspNetCore.Hosting.Server.Abstractions.csproj",
@@ -15,7 +16,6 @@
       "src\\Http\\Headers\\src\\Microsoft.Net.Http.Headers.csproj",
       "src\\Http\\Http.Abstractions\\src\\Microsoft.AspNetCore.Http.Abstractions.csproj",
       "src\\Http\\Http.Extensions\\src\\Microsoft.AspNetCore.Http.Extensions.csproj",
-      "src\\Extensions\\Features\\src\\Microsoft.Extensions.Features.csproj",
       "src\\Http\\Http.Features\\src\\Microsoft.AspNetCore.Http.Features.csproj",
       "src\\Http\\Http\\src\\Microsoft.AspNetCore.Http.csproj",
       "src\\Http\\Metadata\\src\\Microsoft.AspNetCore.Metadata.csproj",
@@ -38,6 +38,7 @@
       "src\\Security\\Authentication\\Facebook\\src\\Microsoft.AspNetCore.Authentication.Facebook.csproj",
       "src\\Security\\Authentication\\Google\\src\\Microsoft.AspNetCore.Authentication.Google.csproj",
       "src\\Security\\Authentication\\JwtBearer\\samples\\JwtBearerSample\\JwtBearerSample.csproj",
+      "src\\Security\\Authentication\\JwtBearer\\samples\\MinimalJwtBearerSample\\MinimalJwtBearerSample.csproj",
       "src\\Security\\Authentication\\JwtBearer\\src\\Microsoft.AspNetCore.Authentication.JwtBearer.csproj",
       "src\\Security\\Authentication\\MicrosoftAccount\\src\\Microsoft.AspNetCore.Authentication.MicrosoftAccount.csproj",
       "src\\Security\\Authentication\\Negotiate\\samples\\NegotiateAuthSample\\NegotiateAuthSample.csproj",
@@ -69,4 +70,4 @@
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
     ]
   }
-}
+}

+ 2 - 0
src/Shared/test/Certificates/Certificates.cs

@@ -4,6 +4,8 @@
 using System.Security.Cryptography;
 using System.Security.Cryptography.X509Certificates;
 
+namespace Microsoft.AspNetCore.Authentication.Certificate;
+
 public static class Certificates
 {
     private static string ServerEku = "1.3.6.1.5.5.7.3.1";

+ 2 - 0
src/Tools/Tools.slnf

@@ -99,6 +99,8 @@
       "src\\Tools\\dotnet-dev-certs\\src\\dotnet-dev-certs.csproj",
       "src\\Tools\\dotnet-getdocument\\src\\dotnet-getdocument.csproj",
       "src\\Tools\\dotnet-sql-cache\\src\\dotnet-sql-cache.csproj",
+      "src\\Tools\\dotnet-user-jwts\\src\\dotnet-user-jwts.csproj",
+      "src\\Tools\\dotnet-user-jwts\\test\\dotnet-user-jwts.Tests.csproj",
       "src\\Tools\\dotnet-user-secrets\\src\\dotnet-user-secrets.csproj",
       "src\\Tools\\dotnet-user-secrets\\test\\dotnet-user-secrets.Tests.csproj",
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"

+ 69 - 0
src/Tools/dotnet-user-jwts/src/Commands/ClearCommand.cs

@@ -0,0 +1,69 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class ClearCommand
+{
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("clear", cmd =>
+        {
+            cmd.Description = "Delete all issued JWTs for a project";
+
+            var forceOption = cmd.Option(
+                "--force",
+                "Don't prompt for confirmation before deleting JWTs",
+                CommandOptionType.NoValue);
+
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), forceOption.HasValue());
+            });
+        });
+    }
+
+    private static int Execute(IReporter reporter, string projectPath, bool force)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var project, out var userSecretsId))
+        {
+            return 1;
+        }
+        var jwtStore = new JwtStore(userSecretsId);
+        var count = jwtStore.Jwts.Count;
+
+        if (count == 0)
+        {
+            reporter.Output($"There are no JWTs to delete from {project}.");
+            return 0;
+        }
+
+        if (!force)
+        {
+            reporter.Output($"Are you sure you want to delete {count} JWT(s) for {project}?{Environment.NewLine} [Y]es / [N]o");
+            if (Console.ReadLine().Trim().ToUpperInvariant() != "Y")
+            {
+                reporter.Output("Canceled, no JWTs were deleted.");
+                return 0;
+            }
+        }
+
+        var appsettingsFilePath = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        foreach (var jwt in jwtStore.Jwts)
+        {
+            JwtAuthenticationSchemeSettings.RemoveScheme(appsettingsFilePath, jwt.Value.Scheme);
+        }
+
+        jwtStore.Jwts.Clear();
+        jwtStore.Save();
+
+        reporter.Output($"Deleted {count} token(s) from {project} successfully.");
+
+        return 0;
+    }
+}

+ 204 - 0
src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs

@@ -0,0 +1,204 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.Linq;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class CreateCommand
+{
+    private static readonly string[] _dateTimeFormats = new[] {
+        "yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy/MM/dd", "yyyy/MM/dd HH:mm" };
+    private static readonly string[] _timeSpanFormats = new[] {
+        @"d\dh\hm\ms\s", @"d\dh\hm\m", @"d\dh\h", @"d\d",
+        @"h\hm\ms\s", @"h\hm\m", @"h\h",
+        @"m\ms\s", @"m\m",
+        @"s\s"
+    };
+
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("create", cmd =>
+        {
+            cmd.Description = "Issue a new JSON Web Token";
+
+            var schemeNameOption = cmd.Option(
+                "--scheme",
+                "The scheme name to use for the generated token. Defaults to 'Bearer'",
+                CommandOptionType.SingleValue
+                );
+
+            var nameOption = cmd.Option(
+                "--name",
+                "The name of the user to create the JWT for. Defaults to the current environment user.",
+                CommandOptionType.SingleValue);
+
+            var audienceOption = cmd.Option(
+                "--audience",
+                "The audiences to create the JWT for. Defaults to the URLs configured in the project's launchSettings.json",
+                CommandOptionType.MultipleValue);
+
+            var issuerOption = cmd.Option(
+                "--issuer",
+                "The issuer of the JWT. Defaults to the dotnet-user-jwts",
+                CommandOptionType.SingleValue);
+
+            var scopesOption = cmd.Option(
+                "--scope",
+                "A scope claim to add to the JWT. Specify once for each scope.",
+                CommandOptionType.MultipleValue);
+
+            var rolesOption = cmd.Option(
+                "--role",
+                "A role claim to add to the JWT. Specify once for each role",
+                CommandOptionType.MultipleValue);
+
+            var claimsOption = cmd.Option(
+                "--claim",
+                "Claims to add to the JWT. Specify once for each claim in the format \"name=value\"",
+                CommandOptionType.MultipleValue);
+
+            var notBeforeOption = cmd.Option(
+                "--not-before",
+                @"The UTC date & time the JWT should not be valid before in the format 'yyyy-MM-dd [[HH:mm[[:ss]]]]'. Defaults to the date & time the JWT is created",
+                CommandOptionType.SingleValue);
+
+            var expiresOnOption = cmd.Option(
+                "--expires-on",
+                @"The UTC date & time the JWT should expire in the format 'yyyy-MM-dd [[[[HH:mm]]:ss]]'. Defaults to 6 months after the --not-before date. " +
+                         "Do not use this option in conjunction with the --valid-for option.",
+                CommandOptionType.SingleValue);
+
+            var validForOption = cmd.Option(
+                "--valid-for",
+                "The period the JWT should expire after. Specify using a number followed by a period type like 'd' for days, 'h' for hours, " +
+                         "'m' for minutes, and 's' for seconds, e.g. '365d'. Do not use this option in conjunction with the --expires-on option.",
+                CommandOptionType.SingleValue);
+
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                var (options, isValid) = ValidateArguments(
+                    cmd.Reporter, cmd.ProjectOption, schemeNameOption, nameOption, audienceOption, issuerOption, notBeforeOption, expiresOnOption, validForOption, rolesOption, scopesOption, claimsOption);
+
+                if (!isValid)
+                {
+                    return 1;
+                }
+
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), options);
+            });
+        });
+    }
+
+    private static (JwtCreatorOptions, bool) ValidateArguments(
+        IReporter reporter,
+        CommandOption projectOption,
+        CommandOption schemeNameOption,
+        CommandOption nameOption,
+        CommandOption audienceOption,
+        CommandOption issuerOption,
+        CommandOption notBeforeOption,
+        CommandOption expiresOnOption,
+        CommandOption validForOption,
+        CommandOption rolesOption,
+        CommandOption scopesOption,
+        CommandOption claimsOption)
+    {
+        var isValid = true;
+        var project = DevJwtCliHelpers.GetProject(projectOption.Value());
+        var scheme = schemeNameOption.HasValue() ? schemeNameOption.Value() : "Bearer";
+        var name = nameOption.HasValue() ? nameOption.Value() : Environment.UserName;
+
+        var audience = audienceOption.HasValue() ? audienceOption.Values : DevJwtCliHelpers.GetAudienceCandidatesFromLaunchSettings(project).ToList();
+        if (audience is null)
+        {
+            reporter.Error("Could not determine the project's HTTPS URL. Please specify an audience for the JWT using the --audience option.");
+            isValid = false;
+        }
+        var issuer = issuerOption.HasValue() ? issuerOption.Value() : DevJwtsDefaults.Issuer;
+
+        var notBefore = DateTime.UtcNow;
+        if (notBeforeOption.HasValue())
+        {
+            if (!ParseDate(notBeforeOption.Value(), out notBefore))
+            {
+                reporter.Error(@"The date provided for --not-before could not be parsed. Dates must consist of a date and can include an optional timestamp.");
+                isValid = false;
+            }
+        }
+
+        var expiresOn = notBefore.AddMonths(3);
+        if (expiresOnOption.HasValue())
+        {
+            if (!ParseDate(expiresOnOption.Value(), out expiresOn))
+            {
+                reporter.Error(@"The date provided for --expires-on could not be parsed. Dates must consist of a date and can include an optional timestamp.");
+                isValid = false;
+            }
+        }
+
+        if (validForOption.HasValue())
+        {
+            if (!TimeSpan.TryParseExact(validForOption.Value(), _timeSpanFormats, CultureInfo.InvariantCulture, out var validForValue))
+            {
+                reporter.Error("The period provided for --valid-for could not be parsed. Ensure you use a format like '10d', '22h', '45s' etc.");
+            }
+            expiresOn = notBefore.Add(validForValue);
+        }
+
+        var roles = rolesOption.HasValue() ? rolesOption.Values : new List<string>();
+        var scopes = scopesOption.HasValue() ? scopesOption.Values : new List<string>();
+
+        var claims = new Dictionary<string, string>();
+        if (claimsOption.HasValue())
+        {
+            if (!DevJwtCliHelpers.TryParseClaims(claimsOption.Values, out claims))
+            {
+                reporter.Error("Malformed claims supplied. Ensure each claim is in the format \"name=value\".");
+                isValid = false;
+            }
+        }
+
+        return (new JwtCreatorOptions(scheme, name, audience, issuer, notBefore, expiresOn, roles, scopes, claims), isValid);
+
+        static bool ParseDate(string datetime, out DateTime parsedDateTime) =>
+            DateTime.TryParseExact(datetime, _dateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsedDateTime);
+    }
+
+    private static int Execute(
+        IReporter reporter,
+        string projectPath,
+        JwtCreatorOptions options)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var project, out var userSecretsId))
+        {
+            return 1;
+        }
+        var keyMaterial = DevJwtCliHelpers.GetOrCreateSigningKeyMaterial(userSecretsId);
+
+        var jwtIssuer = new JwtIssuer(options.Issuer, keyMaterial);
+        var jwtToken = jwtIssuer.Create(options);
+
+        var jwtStore = new JwtStore(userSecretsId);
+        var jwt = Jwt.Create(options.Scheme, jwtToken, JwtIssuer.WriteToken(jwtToken), options.Scopes, options.Roles, options.Claims);
+        if (options.Claims is { } customClaims)
+        {
+            jwt.CustomClaims = customClaims;
+        }
+        jwtStore.Jwts.Add(jwtToken.Id, jwt);
+        jwtStore.Save();
+
+        var appsettingsFilePath = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        var settingsToWrite = new JwtAuthenticationSchemeSettings(options.Scheme, options.Audiences, options.Issuer);
+        settingsToWrite.Save(appsettingsFilePath);
+
+        reporter.Output($"New JWT saved with ID '{jwtToken.Id}'.");
+
+        return 0;
+    }
+}

+ 56 - 0
src/Tools/dotnet-user-jwts/src/Commands/DeleteCommand.cs

@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class DeleteCommand
+{
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("delete", cmd =>
+        {
+            cmd.Description = "Delete a given JWT";
+
+            var idArgument = cmd.Argument("[id]", "The ID of the JWT to delete");
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                if (idArgument.Value is null)
+                {
+                    cmd.ShowHelp();
+                    return 0;
+                }
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), idArgument.Value);
+            });
+        });
+    }
+
+    private static int Execute(IReporter reporter, string projectPath, string id)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var project, out var userSecretsId))
+        {
+            return 1;
+        }
+        var jwtStore = new JwtStore(userSecretsId);
+
+        if (!jwtStore.Jwts.ContainsKey(id))
+        {
+            reporter.Error($"[ERROR] No JWT with ID '{id}' found");
+            return 1;
+        }
+
+        var jwt = jwtStore.Jwts[id];
+        var appsettingsFilePath = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        JwtAuthenticationSchemeSettings.RemoveScheme(appsettingsFilePath, jwt.Scheme);
+        jwtStore.Jwts.Remove(id);
+        jwtStore.Save();
+
+        reporter.Output($"Deleted JWT with ID '{id}'");
+
+        return 0;
+    }
+}

+ 75 - 0
src/Tools/dotnet-user-jwts/src/Commands/KeyCommand.cs

@@ -0,0 +1,75 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class KeyCommand
+{
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("key", cmd =>
+        {
+            cmd.Description = "Display or reset the signing key used to issue JWTs";
+
+            var resetOption = cmd.Option(
+                "--reset",
+                "Reset the signing key. This will invalidate all previously issued JWTs for this project.",
+                CommandOptionType.NoValue);
+
+            var forceOption = cmd.Option(
+                "--force",
+                "Don't prompt for confirmation before resetting the signing key.",
+                CommandOptionType.NoValue);
+
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), resetOption.HasValue(), forceOption.HasValue());
+            });
+        });
+    }
+
+    private static int Execute(IReporter reporter, string projectPath, bool reset, bool force)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var _, out var userSecretsId))
+        {
+            return 1;
+        }
+
+        if (reset == true)
+        {
+            if (!force)
+            {
+                reporter.Output("Are you sure you want to reset the JWT signing key? This will invalidate all JWTs previously issued for this project.\n [Y]es / [N]o");
+                if (Console.ReadLine().Trim().ToUpperInvariant() != "Y")
+                {
+                    reporter.Output("Key reset canceled.");
+                    return 0;
+                }
+            }
+
+            var key = DevJwtCliHelpers.CreateSigningKeyMaterial(userSecretsId, reset: true);
+            reporter.Output($"New signing key created: {Convert.ToBase64String(key)}");
+            return 0; 
+        }
+
+        var projectConfiguration = new ConfigurationBuilder()
+            .AddUserSecrets(userSecretsId)
+            .Build();
+        var signingKeyMaterial = projectConfiguration[DevJwtsDefaults.SigningKeyConfigurationKey];
+
+        if (signingKeyMaterial is null)
+        {
+            reporter.Output("Signing key for JWTs was not found. One will be created automatically when the first JWT is created, or you can force creation of a key with the --reset option.");
+            return 0;
+        }
+
+        reporter.Output($"Signing Key: {signingKeyMaterial}");
+        return 0;
+    }
+}

+ 74 - 0
src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs

@@ -0,0 +1,74 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class ListCommand
+{
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("list", cmd =>
+        {
+            cmd.Description = "Lists the JWTs issued for the project";
+
+            var showTokensOption = cmd.Option(
+                "--show-tokens",
+                "Indicates whether JWT base64 strings should be shown",
+                CommandOptionType.NoValue);
+
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), showTokensOption.HasValue());
+            });
+        });
+    }
+
+    private static int Execute(IReporter reporter, string projectPath, bool showTokens)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var project, out var userSecretsId))
+        {
+            return 1;
+        }
+        var jwtStore = new JwtStore(userSecretsId);
+
+        reporter.Output($"Project: {project}");
+        reporter.Output($"User Secrets ID: {userSecretsId}");
+
+        if (jwtStore.Jwts is { Count: > 0 } jwts)
+        {
+            var table = new ConsoleTable(reporter);
+            table.AddColumns("Id", "Scheme Name", "Audience", "Issued", "Expires");
+
+            if (showTokens)
+            {
+                table.AddColumns("Encoded Token");
+            }
+
+            foreach (var jwtRow in jwts)
+            {
+                var jwt = jwtRow.Value;
+                if (showTokens)
+                {
+                    table.AddRow(jwt.Id, jwt.Scheme, jwt.Audience, jwt.Issued.ToString("O"), jwt.Expires.ToString("O"), jwt.Token);
+                }
+                else
+                {
+                    table.AddRow(jwt.Id, jwt.Scheme, jwt.Audience, jwt.Issued.ToString("O"), jwt.Expires.ToString("O"));
+                }
+            }
+
+            table.Write();
+        }
+        else
+        {
+            reporter.Output("No JWTs created yet!");
+        }
+
+        return 0;
+    }
+}

+ 64 - 0
src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs

@@ -0,0 +1,64 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IdentityModel.Tokens.Jwt;
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+internal sealed class PrintCommand
+{
+    public static void Register(ProjectCommandLineApplication app)
+    {
+        app.Command("print", cmd =>
+        {
+            cmd.Description = "Print the details of a given JWT";
+
+            var idArgument = cmd.Argument("[id]", "The ID of the JWT to print");
+
+            var showFullOption = cmd.Option(
+                "--show-full",
+                "Whether to show the full JWT contents in addition to the compact serialized format",
+                CommandOptionType.NoValue);
+
+            cmd.HelpOption("-h|--help");
+
+            cmd.OnExecute(() =>
+            {
+                if (idArgument.Value is null)
+                {
+                    cmd.ShowHelp();
+                    return 0;
+                }
+                return Execute(cmd.Reporter, cmd.ProjectOption.Value(), idArgument.Value, showFullOption.HasValue());
+            });
+        });
+    }
+
+    private static int Execute(IReporter reporter, string projectPath, string id, bool showFull)
+    {
+        if (!DevJwtCliHelpers.GetProjectAndSecretsId(projectPath, reporter, out var _, out var userSecretsId))
+        {
+            return 1;
+        }
+        var jwtStore = new JwtStore(userSecretsId);
+
+        if (!jwtStore.Jwts.ContainsKey(id))
+        {
+            reporter.Output($"No token with ID '{id}' found");
+            return 1;
+        }
+
+        reporter.Output($"Found JWT with ID '{id}'");
+        var jwt = jwtStore.Jwts[id];
+        JwtSecurityToken fullToken;
+
+        if (showFull)
+        {
+            fullToken = JwtIssuer.Extract(jwt.Token);
+            DevJwtCliHelpers.PrintJwt(reporter, jwt, fullToken);
+        }
+
+        return 0;
+    }
+}

+ 32 - 0
src/Tools/dotnet-user-jwts/src/Commands/ProjectCommandLineApplication.cs

@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class ProjectCommandLineApplication : CommandLineApplication
+{
+    public CommandOption ProjectOption { get; private set; }
+
+    public IReporter Reporter { get; private set; }
+
+    public ProjectCommandLineApplication(IReporter reporter, bool throwOnUnexpectedArg = true, bool continueAfterUnexpectedArg = false, bool treatUnmatchedOptionsAsArguments = false)
+        : base(throwOnUnexpectedArg, continueAfterUnexpectedArg, treatUnmatchedOptionsAsArguments)
+    {
+        ProjectOption = Option(
+            "-p|--project",
+            "The path of the project to operate on. Defaults to the project in the current directory",
+            CommandOptionType.SingleValue);
+        Reporter = reporter;
+    }
+
+    public ProjectCommandLineApplication Command(string name, Action<ProjectCommandLineApplication> configuration)
+    {
+        var command = new ProjectCommandLineApplication(Reporter) { Name = name, Parent = this };
+        Commands.Add(command);
+        configuration(command);
+        return command;
+    }
+}

+ 83 - 0
src/Tools/dotnet-user-jwts/src/Helpers/ConsoleTable.cs

@@ -0,0 +1,83 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.Extensions.CommandLineUtils;
+
+internal sealed class ConsoleTable
+{
+    private readonly List<string> _columns = new();
+    private readonly List<object[]> _rows = new();
+    private readonly IReporter _reporter;
+
+    public ConsoleTable(IReporter reporter)
+    {
+        _reporter = reporter;
+    }
+
+    public void AddColumns(params string[] names)
+    {
+        _columns.AddRange(names);
+    }
+
+    public void AddRow(params object[] values)
+    {
+        if (values == null)
+        {
+            throw new ArgumentNullException(nameof(values));
+        }
+
+        if (!_columns.Any())
+        {
+            throw new Exception("Columns must be set before rows can be added.");
+        }
+
+        if (_columns.Count != values.Length)
+        {
+            throw new Exception(
+                $"The number of columns in the table '{_columns.Count}' does not match the number of columns in the row '{values.Length}'.");
+        }
+
+        _rows.Add(values);
+    }
+
+    public void Write()
+    {
+        var builder = new StringBuilder();
+
+        var maxColumnLengths = _columns
+            .Select((t, i) => _rows.Select(x => x[i])
+                .Concat(new[] { _columns[i] })
+                .Where(x => x != null)
+                .Select(x => x!.ToString()!.Length).Max())
+            .ToList();
+
+        var formatRow = Enumerable.Range(0, _columns.Count)
+            .Select(i => " | {" + i + ", " + maxColumnLengths[i] + "}")
+            .Aggregate((previousRowColumn, nextRowColumn) => previousRowColumn + nextRowColumn) + " |";
+
+        var formattedRows = _rows.Select(row => string.Format(CultureInfo.InvariantCulture, formatRow, row)).ToList();
+        var columnHeaders = string.Format(CultureInfo.InvariantCulture, formatRow, _columns.ToArray());
+        var rowDivider = $" {new string('-', columnHeaders.Length - 1)} ";
+
+        builder.AppendLine(rowDivider);
+        builder.AppendLine(columnHeaders);
+
+        foreach (var formattedRow in formattedRows)
+        {
+            builder.AppendLine(rowDivider);
+            builder.AppendLine(formattedRow);
+        }
+
+        builder.AppendLine(rowDivider);
+
+        _reporter.Output(builder.ToString());
+    }
+}

+ 181 - 0
src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs

@@ -0,0 +1,181 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Text.Json;
+using System.Xml.Linq;
+using System.Xml.XPath;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration.UserSecrets;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal static class DevJwtCliHelpers
+{
+    public static string GetUserSecretsId(string projectFilePath)
+    {
+        var projectDocument = XDocument.Load(projectFilePath, LoadOptions.PreserveWhitespace);
+        var existingUserSecretsId = projectDocument.XPathSelectElements("//UserSecretsId").FirstOrDefault();
+
+        if (existingUserSecretsId == null)
+        {
+            return null;
+        }
+
+        return existingUserSecretsId.Value;
+    }
+
+    public static string GetProject(string projectPath = null)
+    {
+        if (projectPath is not null)
+        {
+            return projectPath;
+        }
+
+        var csprojFiles = Directory.EnumerateFileSystemEntries(Directory.GetCurrentDirectory(), "*.*proj", SearchOption.TopDirectoryOnly)
+                .Where(f => !".xproj".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase))
+                .ToList();
+        if (csprojFiles is [var path])
+        {
+            return path;
+        }
+        return null;
+    }
+
+    public static bool GetProjectAndSecretsId(string projectPath, IReporter reporter, out string project, out string userSecretsId)
+    {
+        project = GetProject(projectPath);
+        userSecretsId = null;
+        if (project == null)
+        {
+            reporter.Error($"No project found at `-p|--project` path or current directory.");
+            return false;
+        }
+
+        userSecretsId = GetUserSecretsId(project);
+        if (userSecretsId == null)
+        {
+            reporter.Error($"Project does not contain a user secrets ID.");
+            return false;
+        }
+        return true;
+    }
+
+    public static byte[] GetOrCreateSigningKeyMaterial(string userSecretsId)
+    {
+        var projectConfiguration = new ConfigurationBuilder()
+            .AddUserSecrets(userSecretsId)
+            .Build();
+
+        var signingKeyMaterial = projectConfiguration[DevJwtsDefaults.SigningKeyConfigurationKey];
+
+        var keyMaterial = new byte[DevJwtsDefaults.SigningKeyLength];
+        if (signingKeyMaterial is not null && Convert.TryFromBase64String(signingKeyMaterial, keyMaterial, out var bytesWritten) && bytesWritten == DevJwtsDefaults.SigningKeyLength)
+        {
+            return keyMaterial;
+        }
+
+        return CreateSigningKeyMaterial(userSecretsId);
+    }
+
+    public static byte[] CreateSigningKeyMaterial(string userSecretsId, bool reset = false)
+    {
+        // Create signing material and save to user secrets
+        var newKeyMaterial = System.Security.Cryptography.RandomNumberGenerator.GetBytes(DevJwtsDefaults.SigningKeyLength);
+        var secretsFilePath = PathHelper.GetSecretsPathFromSecretsId(userSecretsId);
+
+        IDictionary<string, string> secrets = null;
+        if (File.Exists(secretsFilePath))
+        {
+            using var secretsFileStream = new FileStream(secretsFilePath, FileMode.Open, FileAccess.Read);
+            if (secretsFileStream.Length > 0)
+            {
+                secrets = JsonSerializer.Deserialize<IDictionary<string, string>>(secretsFileStream) ?? new Dictionary<string, string>();
+            }
+        }
+
+        secrets ??= new Dictionary<string, string>();
+
+        if (reset && secrets.ContainsKey(DevJwtsDefaults.SigningKeyConfigurationKey))
+        {
+            secrets.Remove(DevJwtsDefaults.SigningKeyConfigurationKey);
+        }
+        secrets.Add(DevJwtsDefaults.SigningKeyConfigurationKey, Convert.ToBase64String(newKeyMaterial));
+
+        using var secretsWriteStream = new FileStream(secretsFilePath, FileMode.Create, FileAccess.Write);
+        JsonSerializer.Serialize(secretsWriteStream, secrets);
+
+        return newKeyMaterial;
+    }
+
+    public static string[] GetAudienceCandidatesFromLaunchSettings(string project)
+    {
+        ArgumentException.ThrowIfNullOrEmpty(nameof(project));
+
+        var launchSettingsFilePath = Path.Combine(Path.GetDirectoryName(project)!, "Properties", "launchSettings.json");
+        if (File.Exists(launchSettingsFilePath))
+        {
+            using var launchSettingsFileStream = new FileStream(launchSettingsFilePath, FileMode.Open, FileAccess.Read);
+            if (launchSettingsFileStream.Length > 0)
+            {
+                var launchSettingsJson = JsonDocument.Parse(launchSettingsFileStream);
+                if (launchSettingsJson.RootElement.TryGetProperty("profiles", out var profiles))
+                {
+                    var profilesEnumerator = profiles.EnumerateObject();
+                    foreach (var profile in profilesEnumerator)
+                    {
+                        if (profile.Value.TryGetProperty("commandName", out var commandName))
+                        {
+                            if (commandName.ValueEquals("Project"))
+                            {
+                                if (profile.Value.TryGetProperty("applicationUrl", out var applicationUrl))
+                                {
+                                    var value = applicationUrl.GetString();
+                                    if (value is { } applicationUrls)
+                                    {
+                                        return applicationUrls.Split(";");
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+
+        return null;
+    }
+
+    public static void PrintJwt(IReporter reporter, Jwt jwt, JwtSecurityToken fullToken = null)
+    {
+        reporter.Output(JsonSerializer.Serialize(jwt, new JsonSerializerOptions { WriteIndented = true }));
+
+        if (fullToken is not null)
+        {
+            reporter.Output($"Token Header: {fullToken.Header.SerializeToJson()}");
+            reporter.Output($"Token Payload: {fullToken.Payload.SerializeToJson()}");
+        }
+        reporter.Output($"Compact Token: {jwt.Token}");
+    }
+
+    public static bool TryParseClaims(List<string> input, out Dictionary<string, string> claims)
+    {
+        claims = new Dictionary<string, string>();
+        foreach (var claim in input)
+        {
+            var parts = claim.Split('=');
+            if (parts.Length != 2)
+            {
+                return false;
+            }
+
+            var key = parts[0];
+            var value = parts[1];
+
+            claims.Add(key, value);
+        }
+        return true;
+    }
+}

+ 13 - 0
src/Tools/dotnet-user-jwts/src/Helpers/DevJwtDefaults.cs

@@ -0,0 +1,13 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal static class DevJwtsDefaults
+{
+    public static string Issuer => "dotnet-user-jwts";
+
+    public static string SigningKeyConfigurationKey => $"{Issuer}:KeyMaterial";
+
+    public static int SigningKeyLength => 32;
+}

+ 34 - 0
src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs

@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+public record Jwt(string Id, string Scheme, string Name, string Audience, DateTimeOffset NotBefore, DateTimeOffset Expires, DateTimeOffset Issued, string Token)
+{
+    public IEnumerable<string> Scopes { get; set; } = new List<string>();
+
+    public IEnumerable<string> Roles { get; set; } = new List<string>();
+
+    public IDictionary<string, string> CustomClaims { get; set; } = new Dictionary<string, string>();
+
+    public override string ToString() => Token;
+
+    public static Jwt Create(
+        string scheme,
+        JwtSecurityToken token,
+        string encodedToken,
+        IEnumerable<string> scopes = null,
+        IEnumerable<string> roles = null,
+        IDictionary<string, string> customClaims = null)
+    {
+        return new Jwt(token.Id, scheme, token.Subject, token.Audiences.FirstOrDefault(), token.ValidFrom, token.ValidTo, token.IssuedAt, encodedToken)
+        {
+            Scopes = scopes,
+            Roles = roles,
+            CustomClaims = customClaims
+        };
+    }
+}

+ 78 - 0
src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs

@@ -0,0 +1,78 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed record JwtAuthenticationSchemeSettings(string SchemeName, List<string> Audiences, string ClaimsIssuer)
+{
+    private const string AuthenticationKey = "Authentication";
+    private const string SchemesKey = "Schemes";
+
+    private static readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions
+    {
+        WriteIndented = true,
+    };
+
+    public void Save(string filePath)
+    {
+        using var reader = new FileStream(filePath, FileMode.Open, FileAccess.Read);
+        var config = JsonSerializer.Deserialize<JsonObject>(reader, _jsonSerializerOptions);
+        reader.Close();
+
+        var settingsObject = new JsonObject
+        {
+            [nameof(Audiences)] = new JsonArray(Audiences.Select(aud => JsonValue.Create(aud)).ToArray()),
+            [nameof(ClaimsIssuer)] = ClaimsIssuer
+        };
+
+        if (config[AuthenticationKey] is JsonObject authentication)
+        {
+            if (authentication[SchemesKey] is JsonObject schemes)
+            {
+                // If a scheme with the same name has already been registered, we
+                // override with the latest token's options
+                schemes[SchemeName] = settingsObject;    
+            }
+            else
+            {
+                authentication.Add(SchemesKey, new JsonObject
+                {
+                    [SchemeName] = settingsObject
+                });
+            }
+        }
+        else
+        {
+            config[AuthenticationKey] = new JsonObject
+            {
+                [SchemesKey] = new JsonObject
+                {
+                    [SchemeName] = settingsObject
+                }
+            };
+        }
+
+        using var writer = new FileStream(filePath, FileMode.Open, FileAccess.Write);
+        JsonSerializer.Serialize(writer, config, _jsonSerializerOptions);
+    }
+
+    public static void RemoveScheme(string filePath, string name)
+    {
+        using var reader = new FileStream(filePath, FileMode.Open, FileAccess.Read);
+        var config = JsonSerializer.Deserialize<JsonObject>(reader);
+        reader.Close();
+
+        if (config[AuthenticationKey] is JsonObject authentication &&
+            authentication[SchemesKey] is JsonObject schemes)
+        {
+            schemes.Remove(name);
+        }
+
+        using var writer = new FileStream(filePath, FileMode.Create, FileAccess.Write);
+        JsonSerializer.Serialize(writer, config, _jsonSerializerOptions);
+    }
+}

+ 15 - 0
src/Tools/dotnet-user-jwts/src/Helpers/JwtCreatorOptions.cs

@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed record JwtCreatorOptions(
+    string Scheme,
+    string Name,
+    List<string> Audiences,
+    string Issuer,
+    DateTime NotBefore,
+    DateTime ExpiresOn,
+    List<string> Roles,
+    List<string> Scopes,
+    Dictionary<string, string> Claims);

+ 88 - 0
src/Tools/dotnet-user-jwts/src/Helpers/JwtIssuer.cs

@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Security.Claims;
+using System.Security.Principal;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+internal sealed class JwtIssuer
+{
+    private readonly SymmetricSecurityKey _signingKey;
+
+    public JwtIssuer(string issuer, byte[] signingKeyMaterial)
+    {
+        Issuer = issuer;
+        _signingKey = new SymmetricSecurityKey(signingKeyMaterial);
+    }
+
+    public string Issuer { get; }
+
+    public JwtSecurityToken Create(JwtCreatorOptions options)
+    {
+        var identity = new GenericIdentity(options.Name);
+
+        identity.AddClaim(new Claim(JwtRegisteredClaimNames.Sub, options.Name));
+
+        var id = Guid.NewGuid().ToString().GetHashCode().ToString("x", CultureInfo.InvariantCulture);
+        identity.AddClaim(new Claim(JwtRegisteredClaimNames.Jti, id));
+
+        if (options.Scopes is { } scopesToAdd)
+        {
+            identity.AddClaims(scopesToAdd.Select(s => new Claim("scope", s)));
+        }
+
+        if (options.Roles is { } rolesToAdd)
+        {
+            identity.AddClaims(rolesToAdd.Select(r => new Claim(ClaimTypes.Role, r)));
+        }
+
+        if (options.Claims is { Count: > 0 } claimsToAdd)
+        {
+            identity.AddClaims(claimsToAdd.Select(kvp => new Claim(kvp.Key, kvp.Value)));
+        }
+
+        // Although the JwtPayload supports having multiple audiences registered, the
+        // creator methods and constructors don't provide a way of setting multiple
+        // audiences. Instead, we have to register an `aud` claim for each audience
+        // we want to add so that the multiple audiences are populated correctly.
+        if (options.Audiences is { Count: > 0} audiences)
+        {
+            identity.AddClaims(audiences.Select(aud => new Claim(JwtRegisteredClaimNames.Aud, aud)));
+        }
+
+        var handler = new JwtSecurityTokenHandler();
+        var jwtSigningCredentials = new SigningCredentials(_signingKey, SecurityAlgorithms.HmacSha256Signature);
+        var jwtToken = handler.CreateJwtSecurityToken(Issuer, audience: null, identity, options.NotBefore, options.ExpiresOn, issuedAt: DateTime.UtcNow, jwtSigningCredentials);
+        return jwtToken;
+    }
+
+    public static string WriteToken(JwtSecurityToken token)
+    {
+        var handler = new JwtSecurityTokenHandler();
+        return handler.WriteToken(token);
+    }
+
+    public static JwtSecurityToken Extract(string token) => new JwtSecurityToken(token);
+
+    public bool IsValid(string encodedToken)
+    {
+        var handler = new JwtSecurityTokenHandler();
+        var tokenValidationParameters = new TokenValidationParameters
+        {
+            IssuerSigningKey = _signingKey,
+            ValidateAudience = false,
+            ValidateIssuer = false,
+            ValidateIssuerSigningKey = true
+        };
+        if (handler.ValidateToken(encodedToken, tokenValidationParameters, out _).Identity?.IsAuthenticated == true)
+        {
+            return true;
+        }
+        return false;
+    }
+}

+ 44 - 0
src/Tools/dotnet-user-jwts/src/Helpers/JwtStore.cs

@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json;
+using Microsoft.Extensions.Configuration.UserSecrets;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+public class JwtStore
+{
+    private const string FileName = "user-jwts.json";
+    private readonly string _userSecretsId;
+    private readonly string _filePath;
+
+    public JwtStore(string userSecretsId)
+    {
+        _userSecretsId = userSecretsId;
+        _filePath = Path.Combine(Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(userSecretsId)), FileName);
+        Load();
+    }
+
+    public IDictionary<string, Jwt> Jwts { get; private set; } = new Dictionary<string, Jwt>();
+
+    public void Load()
+    {
+        if (File.Exists(_filePath))
+        {
+            using var fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read);
+            if (fileStream.Length > 0)
+            {
+                Jwts = JsonSerializer.Deserialize<IDictionary<string, Jwt>>(fileStream) ?? new Dictionary<string, Jwt>();
+            }
+        }
+    }
+
+    public void Save()
+    {
+        if (Jwts is not null)
+        {
+            using var fileStream = new FileStream(_filePath, FileMode.Create, FileAccess.Write);
+            JsonSerializer.Serialize(fileStream, Jwts);
+        }
+    }
+}

+ 52 - 0
src/Tools/dotnet-user-jwts/src/Program.cs

@@ -0,0 +1,52 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.CommandLineUtils;
+using Microsoft.Extensions.Tools.Internal;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+
+public class Program
+{
+    private readonly IConsole _console;
+    private readonly IReporter _reporter;
+
+    public Program(IConsole console)
+    {
+        _console = console;
+        _reporter = new ConsoleReporter(console);
+    }
+
+    public static void Main(string[] args)
+    {
+        new Program(PhysicalConsole.Singleton).Run(args);
+    }
+
+    public void Run(string[] args)
+    {
+        ProjectCommandLineApplication userJwts = new(_reporter)
+        {
+            Name = "dotnet user-jwts"
+        };
+
+        userJwts.HelpOption("-h|--help");
+
+        // dotnet user-jwts list
+        ListCommand.Register(userJwts);
+        // dotnet user-jwts create
+        CreateCommand.Register(userJwts);
+        // dotnet user-jwts print ecd045
+        PrintCommand.Register(userJwts);
+        // dotnet user-jwts delete ecd045
+        DeleteCommand.Register(userJwts);
+        // dotnet user-jwts clear
+        ClearCommand.Register(userJwts);
+        // dotnet user-jwts key
+        KeyCommand.Register(userJwts);
+
+        // Show help information if no subcommand/option was specified.
+        userJwts.OnExecute(() => userJwts.ShowHelp());
+
+        userJwts.Execute(args);
+    }
+}

+ 25 - 0
src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj

@@ -0,0 +1,25 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <OutputType>exe</OutputType>
+    <Description>Command line tool to manage JSON Web Tokens in a user application.</Description>
+    <GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
+    <PackageTags>configuration;authentication;authorization;jwt</PackageTags>
+    <RootNamespace>Microsoft.AspNetCore.Authentication.JwtBearer.Tools</RootNamespace>
+    <PackAsTool>true</PackAsTool>
+    <!-- This package is for internal use only. It contains a CLI which is bundled in the .NET Core SDK. -->
+    <IsShippingPackage>false</IsShippingPackage>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)CommandLineUtils\**\*.cs" LinkBase="Shared" />
+    <Compile Include="$(ToolSharedSourceRoot)CommandLine\**\*.cs" LinkBase="Shared" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Reference Include="System.IdentityModel.Tokens.Jwt" />
+    <Reference Include="Microsoft.Extensions.Configuration.Abstractions" />
+    <Reference Include="Microsoft.Extensions.Configuration" />
+    <Reference Include="Microsoft.Extensions.Configuration.UserSecrets" />
+  </ItemGroup>
+</Project>

+ 102 - 0
src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs

@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using Microsoft.Extensions.Configuration.UserSecrets;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests;
+
+public class UserJwtsTestFixture : IDisposable
+{
+    private Stack<Action> _disposables = new Stack<Action>();
+    private string TestSecretsId = Guid.NewGuid().ToString();
+
+    private const string ProjectTemplate = @"<Project Sdk=""Microsoft.NET.Sdk"">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net7.0</TargetFramework>
+    {0}
+    <EnableDefaultCompileItems>false</EnableDefaultCompileItems>
+  </PropertyGroup>
+</Project>";
+
+    private const string LaunchSettingsTemplate = @"
+{
+  ""profiles"": {
+    ""HttpApiSampleApp"": {
+      ""commandName"": ""Project"",
+      ""dotnetRunMessages"": true,
+      ""launchBrowser"": true,
+      ""applicationUrl"": ""https://localhost:5001;http://localhost:5000"",
+      ""environmentVariables"": {
+        ""ASPNETCORE_ENVIRONMENT"": ""Development""
+      }
+    }
+  }
+}";
+
+    public string CreateProject(bool hasSecret = true)
+    {
+        var projectPath = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "userjwtstest", Guid.NewGuid().ToString()));
+        Directory.CreateDirectory(Path.Combine(projectPath.FullName, "Properties"));
+        var prop = hasSecret ? $"<UserSecretsId>{TestSecretsId}</UserSecretsId>" : string.Empty;
+        if (hasSecret)
+        {
+            Directory.CreateDirectory(Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(TestSecretsId)));
+        }
+
+        File.WriteAllText(
+            Path.Combine(projectPath.FullName, "TestProject.csproj"),
+            string.Format(CultureInfo.InvariantCulture, ProjectTemplate, prop));
+
+        File.WriteAllText(Path.Combine(projectPath.FullName, "Properties", "launchSettings.json"),
+            LaunchSettingsTemplate);
+
+        File.WriteAllText(
+            Path.Combine(projectPath.FullName, "appsettings.Development.json"),
+            "{}");
+
+        if (hasSecret)
+        {
+            _disposables.Push(() =>
+            {
+                try
+                {
+                    var secretsDir = Path.GetDirectoryName(PathHelper.GetSecretsPathFromSecretsId(TestSecretsId));
+                    TryDelete(TestSecretsId);
+                }
+                catch { }
+            });
+        }
+     
+        _disposables.Push(() => TryDelete(projectPath.FullName));
+
+        return projectPath.FullName;
+    }
+
+    private static void TryDelete(string directory)
+    {
+        try
+        {
+            if (Directory.Exists(directory))
+            {
+                Directory.Delete(directory, true);
+            }
+        }
+        catch (Exception)
+        {
+            Console.WriteLine("Failed to delete " + directory);
+        }
+    }
+
+    public void Dispose()
+    {
+        while (_disposables.Count > 0)
+        {
+            _disposables.Pop()?.Invoke();
+        }
+    }
+}

+ 146 - 0
src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs

@@ -0,0 +1,146 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using Microsoft.AspNetCore.Testing;
+using Microsoft.Extensions.Tools.Internal;
+using Microsoft.AspNetCore.Authentication.JwtBearer.Tools;
+using Xunit;
+using Xunit.Abstractions;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests;
+
+public class UserJwtsTests : IClassFixture<UserJwtsTestFixture>
+{
+    private readonly TestConsole _console;
+    private readonly UserJwtsTestFixture _fixture;
+    private readonly ITestOutputHelper _testOut;
+
+    public UserJwtsTests(UserJwtsTestFixture fixture, ITestOutputHelper output)
+    {
+        _fixture = fixture;
+        _testOut = output;
+        _console = new TestConsole(output);
+    }
+
+    [Fact]
+    public void List_NoTokensForNewProject()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var app = new Program(_console);
+
+        app.Run(new[] { "list", "--project", project });
+        Assert.Contains("No JWTs created yet!", _console.GetOutput());
+    }
+
+    [Fact]
+    public void List_HandlesNoSecretsInProject()
+    {
+        var project = Path.Combine(_fixture.CreateProject(false), "TestProject.csproj");
+        var app = new Program(_console);
+
+        app.Run(new[] { "list", "--project", project });
+        Assert.Contains("Project does not contain a user secrets ID.", _console.GetOutput());
+    }
+
+    [Fact]
+    public void Create_WarnsOnNoSecretInproject()
+    {
+        var project = Path.Combine(_fixture.CreateProject(false), "TestProject.csproj");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project });
+        Assert.Contains("Project does not contain a user secrets ID.", _console.GetOutput());
+    }
+
+    [Fact]
+    public void Create_WritesGeneratedTokenToDisk()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var appsettings = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project });
+        Assert.Contains("New JWT saved", _console.GetOutput());
+        Assert.Contains("dotnet-user-jwts", File.ReadAllText(appsettings));
+    }
+
+    [Fact]
+    public void Print_ReturnsNothingForMissingToken()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var app = new Program(_console);
+
+        app.Run(new[] { "print", "invalid-id", "--project", project });
+        Assert.Contains("No token with ID 'invalid-id' found", _console.GetOutput());
+    }
+
+    [Fact]
+    public void List_ReturnsIdForGeneratedToken()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var appsettings = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project, "--scheme", "MyCustomScheme" });
+        Assert.Contains("New JWT saved", _console.GetOutput());
+
+        app.Run(new[] { "list", "--project", project });
+        Assert.Contains("MyCustomScheme", _console.GetOutput());
+    }
+
+    [Fact]
+    public void Delete_RemovesGeneratedToken()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var appsettings = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project });
+        var matches = Regex.Matches(_console.GetOutput(), "New JWT saved with ID '(.*?)'");
+        var id = matches.SingleOrDefault().Groups[1].Value;
+        app.Run(new[] { "create", "--project", project, "--scheme", "Scheme2" });
+
+        app.Run(new[] { "delete", id, "--project", project });
+        var appsettingsContent = File.ReadAllText(appsettings);
+        Assert.DoesNotContain("Bearer", appsettingsContent);
+        Assert.Contains("Scheme2", appsettingsContent);
+    }
+
+    [Fact]
+    public void Clear_RemovesGeneratedTokens()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var appsettings = Path.Combine(Path.GetDirectoryName(project), "appsettings.Development.json");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project });
+        app.Run(new[] { "create", "--project", project, "--scheme", "Scheme2" });
+
+        Assert.Contains("New JWT saved", _console.GetOutput());
+
+        app.Run(new[] { "clear", "--project", project, "--force" });
+        var appsettingsContent = File.ReadAllText(appsettings);
+        Assert.DoesNotContain("Bearer", appsettingsContent);
+        Assert.DoesNotContain("Scheme2", appsettingsContent);
+    }
+
+    [Fact]
+    public void Key_CanResetSigningKey()
+    {
+        var project = Path.Combine(_fixture.CreateProject(), "TestProject.csproj");
+        var app = new Program(_console);
+
+        app.Run(new[] { "create", "--project", project });
+        app.Run(new[] { "key", "--project", project });
+        Assert.Contains("Signing Key:", _console.GetOutput());
+
+        app.Run(new[] { "key", "--reset", "--force", "--project", project });
+        Assert.Contains("New signing key created:", _console.GetOutput());
+    }
+}

+ 16 - 0
src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj

@@ -0,0 +1,16 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <AssemblyName>Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests</AssemblyName>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Compile Include="$(ToolSharedSourceRoot)TestHelpers\**\*.cs" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\src\dotnet-user-jwts.csproj" />
+  </ItemGroup>
+
+</Project>