diff --git a/AspNetCore.sln b/AspNetCore.sln index 88ed078aec9e..02b6e7c51c77 100644 --- a/AspNetCore.sln +++ b/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} diff --git a/eng/Signing.props b/eng/Signing.props index 66f8dc2ce805..db20e2e9a45f 100644 --- a/eng/Signing.props +++ b/eng/Signing.props @@ -79,6 +79,7 @@ + diff --git a/src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs b/src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs index 8070f11abb66..616e24982958 100644 --- a/src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs +++ b/src/Azure/AzureAD/Authentication.AzureAD.UI/test/AzureADAuthenticationBuilderExtensionsTests.cs @@ -6,6 +6,7 @@ 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 void AddAzureAD_AddsAllAuthenticationHandlers() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -288,6 +290,7 @@ public void AddAzureADBearer_AddsAllAuthenticationHandlers() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -305,6 +308,7 @@ public void AddAzureADBearer_ConfiguresAllOptions() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -340,6 +344,7 @@ public void AddAzureADBearer_CanOverrideJwtBearerOptionsConfiguration() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -373,6 +378,7 @@ public void AddAzureADBearer_RegisteringJwtBearerHasNoImpactOnAzureAAExtensions( // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -473,6 +479,7 @@ public void AddAzureADBearer_SkipsOptionsValidationForNonAzureCookies() { var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); services.AddAuthentication() .AddAzureADBearer(o => { }) diff --git a/src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs b/src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs index 8c61fdb79844..36abc8d6d58d 100644 --- a/src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs +++ b/src/Azure/AzureAD/Authentication.AzureADB2C.UI/test/AzureAdB2CAuthenticationBuilderExtensionsTests.cs @@ -6,6 +6,7 @@ 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 void AddAzureADB2CBearer_AddsAllAuthenticationHandlers() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -279,6 +281,7 @@ public void AddAzureADB2CBearer_ConfiguresAllOptions() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -315,6 +318,7 @@ public void AddAzureADB2CBearer_CanOverrideJwtBearerOptionsConfiguration() // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() @@ -348,6 +352,7 @@ public void AddAzureADB2CBearer_RegisteringJwtBearerHasNoImpactOnAzureAAExtensio // Arrange var services = new ServiceCollection(); services.AddSingleton(new NullLoggerFactory()); + services.AddSingleton(new ConfigurationManager()); // Act services.AddAuthentication() diff --git a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj index 2c66a8407db3..669ecb8037bb 100644 --- a/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj +++ b/src/DefaultBuilder/src/Microsoft.AspNetCore.csproj @@ -11,6 +11,8 @@ + + @@ -32,4 +34,8 @@ + + + + diff --git a/src/DefaultBuilder/src/PublicAPI.Unshipped.txt b/src/DefaultBuilder/src/PublicAPI.Unshipped.txt index 88ff0b5ecc80..6eba7bee0ff8 100644 --- a/src/DefaultBuilder/src/PublicAPI.Unshipped.txt +++ b/src/DefaultBuilder/src/PublicAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable Microsoft.AspNetCore.Builder.WebApplication.Use(System.Func! 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! configure, System.Action! configureOptions) -> Microsoft.Extensions.Hosting.IHostBuilder! diff --git a/src/DefaultBuilder/src/WebApplicationAuthenticationBuilder.cs b/src/DefaultBuilder/src/WebApplicationAuthenticationBuilder.cs new file mode 100644 index 000000000000..f95ca8a05896 --- /dev/null +++ b/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 configureOptions) + { + RegisterServices(authenticationScheme); + return base.AddPolicyScheme(authenticationScheme, displayName, configureOptions); + } + + public override AuthenticationBuilder AddRemoteScheme(string authenticationScheme, string? displayName, Action? configureOptions) + { + RegisterServices(authenticationScheme); + return base.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + } + + public override AuthenticationBuilder AddScheme(string authenticationScheme, string? displayName, Action? configureOptions) + { + RegisterServices(authenticationScheme); + return base.AddScheme(authenticationScheme, displayName, configureOptions); + } + + public override AuthenticationBuilder AddScheme(string authenticationScheme, Action? configureOptions) + { + RegisterServices(authenticationScheme); + return base.AddScheme(authenticationScheme, configureOptions); + } + + private void RegisterServices(string authenticationScheme) + { + if (!IsAuthenticationConfigured) + { + IsAuthenticationConfigured = true; + Services.AddAuthentication(authenticationScheme); + Services.AddAuthorization(); + } + } +} diff --git a/src/DefaultBuilder/src/WebApplicationBuilder.cs b/src/DefaultBuilder/src/WebApplicationBuilder.cs index c5eabba6816d..b96b15117432 100644 --- a/src/DefaultBuilder/src/WebApplicationBuilder.cs +++ b/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 @@ internal WebApplicationBuilder(WebApplicationOptions options, Action @@ -113,6 +117,11 @@ internal WebApplicationBuilder(WebApplicationOptions options, Action public ConfigureHostBuilder Host { get; } + /// + /// An for configuration authentication-related properties. + /// + public AuthenticationBuilder Authentication => _webAuthBuilder; + /// /// Builds the . /// @@ -166,6 +175,16 @@ private void ConfigureApplication(WebHostBuilderContext context, IApplicationBui } } + 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 => { diff --git a/src/Http/Authentication.Abstractions/src/IAuthenticationConfigurationProvider.cs b/src/Http/Authentication.Abstractions/src/IAuthenticationConfigurationProvider.cs new file mode 100644 index 000000000000..0665de7acb0b --- /dev/null +++ b/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; + +/// +/// Provides an interface for implmenting a construct that provides +/// access to specific configuration sections. +/// +public interface IAuthenticationConfigurationProvider +{ + /// + /// Returns the specified object. + /// + /// The path to the section to be returned. + /// The specified object, or null if the requested section does not exist. + IConfiguration GetAuthenticationSchemeConfiguration(string authenticationScheme); +} diff --git a/src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..efe32d90a931 100644 --- a/src/Http/Authentication.Abstractions/src/PublicAPI.Unshipped.txt +++ b/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! diff --git a/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs b/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs index a729ef21e0e5..332fe321aaf2 100644 --- a/src/Middleware/HttpOverrides/test/CertificateForwardingTest.cs +++ b/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; diff --git a/src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs b/src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs index 8efc58c59305..5c210ef7e907 100644 --- a/src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs +++ b/src/Security/Authentication/Core/src/AuthAppBuilderExtensions.cs @@ -10,6 +10,8 @@ namespace Microsoft.AspNetCore.Builder; /// public static class AuthAppBuilderExtensions { + internal const string AuthenticationMiddlewareSetKey = "__AuthenticationMiddlewareSet"; + /// /// Adds the to the specified , which enables authentication capabilities. /// @@ -22,6 +24,7 @@ public static IApplicationBuilder UseAuthentication(this IApplicationBuilder app throw new ArgumentNullException(nameof(app)); } + app.Properties[AuthenticationMiddlewareSetKey] = true; return app.UseMiddleware(); } } diff --git a/src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs b/src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs index b29001aa37a7..2e34894d99b2 100644 --- a/src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs +++ b/src/Security/Authentication/Core/src/AuthenticationServiceCollectionExtensions.cs @@ -27,6 +27,7 @@ public static AuthenticationBuilder AddAuthentication(this IServiceCollection se services.AddDataProtection(); services.AddWebEncoders(); services.TryAddSingleton(); + services.TryAddSingleton(); return new AuthenticationBuilder(services); } diff --git a/src/Security/Authentication/Core/src/DefaultAuthenticationConfigurationProvider.cs b/src/Security/Authentication/Core/src/DefaultAuthenticationConfigurationProvider.cs new file mode 100644 index 000000000000..057e1a5ad1d4 --- /dev/null +++ b/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}"); + } +} diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/MinimalJwtBearerSample.csproj b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/MinimalJwtBearerSample.csproj new file mode 100644 index 000000000000..e0ab758b6a83 --- /dev/null +++ b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/MinimalJwtBearerSample.csproj @@ -0,0 +1,17 @@ + + + + $(DefaultNetCoreTargetFramework) + MinimalJwtBearerSample-20151210102827 + enable + enable + + + + + + + + + + diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Program.cs b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Program.cs new file mode 100644 index 000000000000..ff0c3ecd22cb --- /dev/null +++ b/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(); diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Properties/launchSettings.json b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/Properties/launchSettings.json new file mode 100644 index 000000000000..ea8f54aeb1a4 --- /dev/null +++ b/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" + } + } + } +} diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json new file mode 100644 index 000000000000..9fe2b7a74f48 --- /dev/null +++ b/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" + } + } + } +} \ No newline at end of file diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.json b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs new file mode 100644 index 000000000000..7004f06a40be --- /dev/null +++ b/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 +{ + private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider; + private readonly IConfiguration _configuration; + + /// + /// Initializes a new given the configuration + /// provided by the . + /// + /// An instance. + /// An instance for accessing configuration elements not in the schema. + public JwtBearerConfigureOptions(IAuthenticationConfigurationProvider configurationProvider, IConfiguration configuration) + { + _authenticationConfigurationProvider = configurationProvider; + _configuration = configuration; + } + + /// + 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); + } + + /// + public void Configure(JwtBearerOptions options) + { + Configure(Options.DefaultName, options); + } +} diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs index 12022ed0780d..f4a7ec2e9ae3 100644 --- a/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs +++ b/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, _ => { }); + /// + /// Enables JWT-bearer authentication using a pre-defined scheme. + /// + /// JWT bearer authentication performs authentication by extracting and validating a JWT token from the Authorization request header. + /// + /// + /// The . + /// The authentication scheme. + /// A reference to after the operation has completed. + public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme) + => builder.AddJwtBearer(authenticationScheme, _ => { }); + /// /// Enables JWT-bearer authentication using the default scheme . /// @@ -62,6 +74,7 @@ public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder buil /// A reference to after the operation has completed. public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action configureOptions) { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, JwtBearerConfigureOptions>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, JwtBearerPostConfigureOptions>()); return builder.AddScheme(authenticationScheme, displayName, configureOptions); } diff --git a/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt index 2c729aeee8d2..e66f11d82ec1 100644 --- a/src/Security/Authentication/JwtBearer/src/PublicAPI.Unshipped.txt +++ b/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! diff --git a/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs b/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs index f6ac9acce57b..eb883f111d7a 100644 --- a/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs +++ b/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs @@ -151,6 +151,25 @@ public async Task IAuthenticateResultFeature_SettingResultSetsUser() 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(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 registerServices = null, IAuthenticationService authenticationService = null) diff --git a/src/Security/Authentication/test/CertificateTests.cs b/src/Security/Authentication/test/CertificateTests.cs index 03fd439a3660..018ccb139171 100644 --- a/src/Security/Authentication/test/CertificateTests.cs +++ b/src/Security/Authentication/test/CertificateTests.cs @@ -6,6 +6,7 @@ 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; diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj index a65c8ad9271f..72dad5f713a3 100644 --- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj +++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj @@ -36,6 +36,7 @@ + diff --git a/src/Security/Authentication/test/SharedAuthenticationTests.cs b/src/Security/Authentication/test/SharedAuthenticationTests.cs index 4ee984335d59..f1085b604169 100644 --- a/src/Security/Authentication/test/SharedAuthenticationTests.cs +++ b/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 where TOptions : Authe public async Task CanForwardDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { @@ -165,6 +167,7 @@ public async Task ForwardSignOutWinsOverDefault() public async Task ForwardForbidWinsOverDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; @@ -214,6 +217,7 @@ public Task TransformAsync(ClaimsPrincipal principal) public async Task ForwardAuthenticateOnlyRunsTransformOnceByDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var transform = new RunOnce(); var builder = services.AddSingleton(transform).AddAuthentication(o => { @@ -244,6 +248,7 @@ public async Task ForwardAuthenticateOnlyRunsTransformOnceByDefault() public async Task ForwardAuthenticateWinsOverDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; @@ -283,6 +288,7 @@ public async Task ForwardAuthenticateWinsOverDefault() public async Task ForwardChallengeWinsOverDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; @@ -322,6 +328,7 @@ public async Task ForwardChallengeWinsOverDefault() public async Task ForwardSelectorWinsOverDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; @@ -391,6 +398,7 @@ public async Task ForwardSelectorWinsOverDefault() public async Task NullForwardSelectorUsesDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; @@ -460,6 +468,7 @@ public async Task NullForwardSelectorUsesDefault() public async Task SpecificForwardWinsOverSelectorAndDefault() { var services = new ServiceCollection().AddLogging(); + services.AddSingleton(new ConfigurationManager()); var builder = services.AddAuthentication(o => { o.DefaultScheme = DefaultScheme; diff --git a/src/Security/Security.slnf b/src/Security/Security.slnf index 0dd86cd84fd2..e8b8685e04b7 100644 --- a/src/Security/Security.slnf +++ b/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" ] } -} +} \ No newline at end of file diff --git a/src/Shared/test/Certificates/Certificates.cs b/src/Shared/test/Certificates/Certificates.cs index 8124e9cdf0f1..d0c2a0c043b6 100644 --- a/src/Shared/test/Certificates/Certificates.cs +++ b/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"; diff --git a/src/Tools/Tools.slnf b/src/Tools/Tools.slnf index 8527dde99dcc..e277c5a31543 100644 --- a/src/Tools/Tools.slnf +++ b/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" diff --git a/src/Tools/dotnet-user-jwts/src/Commands/ClearCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/ClearCommand.cs new file mode 100644 index 000000000000..e0880812fc8e --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/CreateCommand.cs new file mode 100644 index 000000000000..17ac345c5945 --- /dev/null +++ b/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(); + var scopes = scopesOption.HasValue() ? scopesOption.Values : new List(); + + var claims = new Dictionary(); + 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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/DeleteCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/DeleteCommand.cs new file mode 100644 index 000000000000..83b287b81a2f --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/KeyCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/KeyCommand.cs new file mode 100644 index 000000000000..1637d7d7f6ed --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/ListCommand.cs new file mode 100644 index 000000000000..8013e298997f --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs b/src/Tools/dotnet-user-jwts/src/Commands/PrintCommand.cs new file mode 100644 index 000000000000..3d144b9c5001 --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Commands/ProjectCommandLineApplication.cs b/src/Tools/dotnet-user-jwts/src/Commands/ProjectCommandLineApplication.cs new file mode 100644 index 000000000000..a15391cc99b6 --- /dev/null +++ b/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 configuration) + { + var command = new ProjectCommandLineApplication(Reporter) { Name = name, Parent = this }; + Commands.Add(command); + configuration(command); + return command; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/ConsoleTable.cs b/src/Tools/dotnet-user-jwts/src/Helpers/ConsoleTable.cs new file mode 100644 index 000000000000..b7773b719df4 --- /dev/null +++ b/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 _columns = new(); + private readonly List _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()); + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtCliHelpers.cs new file mode 100644 index 000000000000..40e35506b27c --- /dev/null +++ b/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 secrets = null; + if (File.Exists(secretsFilePath)) + { + using var secretsFileStream = new FileStream(secretsFilePath, FileMode.Open, FileAccess.Read); + if (secretsFileStream.Length > 0) + { + secrets = JsonSerializer.Deserialize>(secretsFileStream) ?? new Dictionary(); + } + } + + secrets ??= new Dictionary(); + + 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 input, out Dictionary claims) + { + claims = new Dictionary(); + 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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtDefaults.cs b/src/Tools/dotnet-user-jwts/src/Helpers/DevJwtDefaults.cs new file mode 100644 index 000000000000..595d7c510b46 --- /dev/null +++ b/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; +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs b/src/Tools/dotnet-user-jwts/src/Helpers/Jwt.cs new file mode 100644 index 000000000000..e78112fa6cda --- /dev/null +++ b/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 Scopes { get; set; } = new List(); + + public IEnumerable Roles { get; set; } = new List(); + + public IDictionary CustomClaims { get; set; } = new Dictionary(); + + public override string ToString() => Token; + + public static Jwt Create( + string scheme, + JwtSecurityToken token, + string encodedToken, + IEnumerable scopes = null, + IEnumerable roles = null, + IDictionary 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 + }; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs b/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs new file mode 100644 index 000000000000..b8108f5294c7 --- /dev/null +++ b/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 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(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(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); + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/JwtCreatorOptions.cs b/src/Tools/dotnet-user-jwts/src/Helpers/JwtCreatorOptions.cs new file mode 100644 index 000000000000..589f3d5d07f6 --- /dev/null +++ b/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 Audiences, + string Issuer, + DateTime NotBefore, + DateTime ExpiresOn, + List Roles, + List Scopes, + Dictionary Claims); diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/JwtIssuer.cs b/src/Tools/dotnet-user-jwts/src/Helpers/JwtIssuer.cs new file mode 100644 index 000000000000..cf086d1d769f --- /dev/null +++ b/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; + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/JwtStore.cs b/src/Tools/dotnet-user-jwts/src/Helpers/JwtStore.cs new file mode 100644 index 000000000000..8bffc9d9c2ce --- /dev/null +++ b/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 Jwts { get; private set; } = new Dictionary(); + + public void Load() + { + if (File.Exists(_filePath)) + { + using var fileStream = new FileStream(_filePath, FileMode.Open, FileAccess.Read); + if (fileStream.Length > 0) + { + Jwts = JsonSerializer.Deserialize>(fileStream) ?? new Dictionary(); + } + } + } + + public void Save() + { + if (Jwts is not null) + { + using var fileStream = new FileStream(_filePath, FileMode.Create, FileAccess.Write); + JsonSerializer.Serialize(fileStream, Jwts); + } + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Program.cs b/src/Tools/dotnet-user-jwts/src/Program.cs new file mode 100644 index 000000000000..24d2ea9dafbe --- /dev/null +++ b/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); + } +} diff --git a/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj b/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj new file mode 100644 index 000000000000..93b34c52c64b --- /dev/null +++ b/src/Tools/dotnet-user-jwts/src/dotnet-user-jwts.csproj @@ -0,0 +1,25 @@ + + + $(DefaultNetCoreTargetFramework) + exe + Command line tool to manage JSON Web Tokens in a user application. + false + configuration;authentication;authorization;jwt + Microsoft.AspNetCore.Authentication.JwtBearer.Tools + true + + false + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs b/src/Tools/dotnet-user-jwts/test/UserJwtsTestFixture.cs new file mode 100644 index 000000000000..08e003c8e456 --- /dev/null +++ b/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 _disposables = new Stack(); + private string TestSecretsId = Guid.NewGuid().ToString(); + + private const string ProjectTemplate = @" + + Exe + net7.0 + {0} + false + +"; + + 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 ? $"{TestSecretsId}" : 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(); + } + } +} diff --git a/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs b/src/Tools/dotnet-user-jwts/test/UserJwtsTests.cs new file mode 100644 index 000000000000..ac0cf3d95723 --- /dev/null +++ b/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 +{ + 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()); + } +} diff --git a/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj b/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj new file mode 100644 index 000000000000..84d7ec58c998 --- /dev/null +++ b/src/Tools/dotnet-user-jwts/test/dotnet-user-jwts.Tests.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultNetCoreTargetFramework) + Microsoft.AspNetCore.Authentication.JwtBearer.Tools.Tests + + + + + + + + + + + \ No newline at end of file