diff --git a/AspNetCore.sln b/AspNetCore.sln index 61d5a0a2cdbc..8c3e49003b60 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1737,6 +1737,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Blazor.WebAssembl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimitingSample", "src\Middleware\RateLimiting\samples\RateLimitingSample\RateLimitingSample.csproj", "{91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalOpenIdConnectSample", "src\Security\Authentication\OpenIdConnect\samples\MinimalOpenIdConnectSample\MinimalOpenIdConnectSample.csproj", "{07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10434,6 +10436,22 @@ Global {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x64.Build.0 = Release|Any CPU {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.ActiveCfg = Release|Any CPU {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9}.Release|x86.Build.0 = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|arm64.ActiveCfg = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|arm64.Build.0 = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|x64.Build.0 = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Debug|x86.Build.0 = Debug|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|Any CPU.Build.0 = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|arm64.ActiveCfg = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|arm64.Build.0 = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x64.ActiveCfg = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x64.Build.0 = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x86.ActiveCfg = Release|Any CPU + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11292,6 +11310,7 @@ Global {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF} {7CA0A9AF-9088-471C-B0B6-EBF43F21D3B9} = {08D53E58-4AAE-40C4-8497-63EC8664F304} {91C3C03E-EA56-4ABA-9E73-A3DA4C2833D9} = {1D865E78-7A66-4CA9-92EE-2B350E45281F} + {07FDBE0D-B7A1-43DE-B120-F699C30E7CEF} = {E19E55A2-1562-47A7-8EA6-B51F2CA0CC4C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json index 6faede201cac..e4cb26668ef1 100644 --- a/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json +++ b/src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json @@ -9,25 +9,25 @@ "DefaultScheme": "ClaimedDetails", "Schemes": { "Bearer": { - "Audiences": [ + "ValidAudiences": [ "https://localhost:7259", "http://localhost:5259" ], - "ClaimsIssuer": "dotnet-user-jwts" + "ValidIssuer": "dotnet-user-jwts" }, "ClaimedDetails": { - "Audiences": [ + "ValidAudiences": [ "https://localhost:7259", "http://localhost:5259" ], - "ClaimsIssuer": "dotnet-user-jwts" + "ValidIssuer": "dotnet-user-jwts" }, "InvalidScheme": { - "Audiences": [ + "ValidAudiences": [ "https://localhost:7259", "http://localhost:5259" ], - "ClaimsIssuer": "invalid-issuer" + "ValidIssuer": "invalid-issuer" } } } diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs index fba8afab011c..04cc76380706 100644 --- a/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs +++ b/src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs @@ -1,8 +1,8 @@ // 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 System.Security.Cryptography; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; @@ -13,6 +13,7 @@ namespace Microsoft.AspNetCore.Authentication; internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions { private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider; + private static readonly Func _invariantTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture); /// /// Initializes a new given the configuration @@ -39,26 +40,56 @@ public void Configure(string? name, JwtBearerOptions options) return; } - var issuer = configSection["ClaimsIssuer"]; - var audiences = configSection.GetSection("Audiences").GetChildren().Select(aud => aud.Value).ToArray(); + var issuer = configSection[nameof(TokenValidationParameters.ValidIssuer)]; + var issuers = configSection.GetSection(nameof(TokenValidationParameters.ValidIssuers)).GetChildren().Select(iss => iss.Value).ToList(); + if (issuer is not null) + { + issuers.Add(issuer); + } + var audience = configSection[nameof(TokenValidationParameters.ValidAudience)]; + var audiences = configSection.GetSection(nameof(TokenValidationParameters.ValidAudiences)).GetChildren().Select(aud => aud.Value).ToList(); + if (audience is not null) + { + audiences.Add(audience); + } + + options.Authority = configSection[nameof(options.Authority)] ?? options.Authority; + options.BackchannelTimeout = StringHelpers.ParseValueOrDefault(configSection[nameof(options.BackchannelTimeout)], _invariantTimeSpanParse, options.BackchannelTimeout); + options.Challenge = configSection[nameof(options.Challenge)] ?? options.Challenge; + options.ForwardAuthenticate = configSection[nameof(options.ForwardAuthenticate)] ?? options.ForwardAuthenticate; + options.ForwardChallenge = configSection[nameof(options.ForwardChallenge)] ?? options.ForwardChallenge; + options.ForwardDefault = configSection[nameof(options.ForwardDefault)] ?? options.ForwardDefault; + options.ForwardForbid = configSection[nameof(options.ForwardForbid)] ?? options.ForwardForbid; + options.ForwardSignIn = configSection[nameof(options.ForwardSignIn)] ?? options.ForwardSignIn; + options.ForwardSignOut = configSection[nameof(options.ForwardSignOut)] ?? options.ForwardSignOut; + options.IncludeErrorDetails = StringHelpers.ParseValueOrDefault(configSection[nameof(options.IncludeErrorDetails)], bool.Parse, options.IncludeErrorDetails); + options.MapInboundClaims = StringHelpers.ParseValueOrDefault( configSection[nameof(options.MapInboundClaims)], bool.Parse, options.MapInboundClaims); + options.MetadataAddress = configSection[nameof(options.MetadataAddress)] ?? options.MetadataAddress; + options.RefreshInterval = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshInterval)], _invariantTimeSpanParse, options.RefreshInterval); + options.RefreshOnIssuerKeyNotFound = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshOnIssuerKeyNotFound)], bool.Parse, options.RefreshOnIssuerKeyNotFound); + options.RequireHttpsMetadata = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RequireHttpsMetadata)], bool.Parse, options.RequireHttpsMetadata); + options.SaveToken = StringHelpers.ParseValueOrDefault(configSection[nameof(options.SaveToken)], bool.Parse, options.SaveToken); options.TokenValidationParameters = new() { - ValidateIssuer = issuer is not null, - ValidIssuers = new[] { issuer }, - ValidateAudience = audiences.Length > 0, + ValidateIssuer = issuers.Count > 0, + ValidIssuers = issuers, + ValidateAudience = audiences.Count > 0, ValidAudiences = audiences, ValidateIssuerSigningKey = true, - IssuerSigningKey = GetIssuerSigningKey(configSection, issuer), + IssuerSigningKeys = GetIssuerSigningKeys(configSection, issuers), }; } - private static SecurityKey GetIssuerSigningKey(IConfiguration configuration, string? issuer) + private static IEnumerable GetIssuerSigningKeys(IConfiguration configuration, List issuers) { - var jwtKeyMaterialSecret = configuration[$"{issuer}:KeyMaterial"]; - var jwtKeyMaterial = !string.IsNullOrEmpty(jwtKeyMaterialSecret) - ? Convert.FromBase64String(jwtKeyMaterialSecret) - : RandomNumberGenerator.GetBytes(32); - return new SymmetricSecurityKey(jwtKeyMaterial); + foreach (var issuer in issuers) + { + var keyFromSecret = configuration[$"{issuer}:KeyMaterial"]; + if (!string.IsNullOrEmpty(keyFromSecret)) + { + yield return new SymmetricSecurityKey(Convert.FromBase64String(keyFromSecret)); + } + } } /// diff --git a/src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj b/src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj index fedcb161248a..45c7f87f58d9 100644 --- a/src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj +++ b/src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/MinimalOpenIdConnectSample.csproj b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/MinimalOpenIdConnectSample.csproj new file mode 100644 index 000000000000..f1b23925c4d1 --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/MinimalOpenIdConnectSample.csproj @@ -0,0 +1,15 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + enable + + + + + + + + + diff --git a/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Program.cs b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Program.cs new file mode 100644 index 000000000000..ac25f6020066 --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Program.cs @@ -0,0 +1,18 @@ +// 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; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services + .AddAuthentication("OpenIdConnect") + .AddCookie() + .AddOpenIdConnect(); +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.MapGet("/protected", (ClaimsPrincipal user) => $"Hello {user.Identity?.Name}!") + .RequireAuthorization(); + +app.Run(); diff --git a/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Properties/launchSettings.json b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Properties/launchSettings.json new file mode 100644 index 000000000000..6c73c375d497 --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:2726", + "sslPort": 44308 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5204", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7282;http://localhost:5204", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.Development.json b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.Development.json new file mode 100644 index 000000000000..0c208ae9181e --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.json b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.json new file mode 100644 index 000000000000..10f68b8c8b4f --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj b/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj index 18f237b0cbfd..68b81587a9a0 100644 --- a/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj +++ b/src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectConfigureOptions.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectConfigureOptions.cs new file mode 100644 index 000000000000..e90f4dab96a7 --- /dev/null +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectConfigureOptions.cs @@ -0,0 +1,128 @@ +// 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.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.OpenIdConnect; + +internal sealed class OpenIdConnectConfigureOptions : IConfigureNamedOptions +{ + private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider; + private static readonly Func _invariantTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture); + private static readonly Func _invariantNullableTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture); + + /// + /// Initializes a new given the configuration + /// provided by the . + /// + /// An instance. + public OpenIdConnectConfigureOptions(IAuthenticationConfigurationProvider configurationProvider) + { + _authenticationConfigurationProvider = configurationProvider; + } + + /// + public void Configure(string? name, OpenIdConnectOptions options) + { + if (string.IsNullOrEmpty(name)) + { + return; + } + + var configSection = _authenticationConfigurationProvider.GetSchemeConfiguration(name); + + if (configSection is null || !configSection.GetChildren().Any()) + { + return; + } + + options.AccessDeniedPath = new PathString(configSection[nameof(options.AccessDeniedPath)] ?? options.AccessDeniedPath.Value); + options.Authority = configSection[nameof(options.Authority)] ?? options.Authority; + options.AutomaticRefreshInterval = StringHelpers.ParseValueOrDefault(configSection[nameof(options.AutomaticRefreshInterval)], _invariantTimeSpanParse, options.AutomaticRefreshInterval); + options.BackchannelTimeout = StringHelpers.ParseValueOrDefault(configSection[nameof(options.BackchannelTimeout)], _invariantTimeSpanParse, options.BackchannelTimeout); + options.CallbackPath = new PathString(configSection[nameof(options.CallbackPath)] ?? options.CallbackPath.Value); + options.ClaimsIssuer = configSection[nameof(options.ClaimsIssuer)] ?? options.ClaimsIssuer; + options.ClientId = configSection[nameof(options.ClientId)] ?? options.ClientId; + options.ClientSecret = configSection[nameof(options.ClientSecret)] ?? options.ClientSecret; + SetCookieFromConfig(configSection.GetSection(nameof(options.CorrelationCookie)), options.CorrelationCookie); + options.DisableTelemetry = StringHelpers.ParseValueOrDefault(configSection[nameof(options.DisableTelemetry)], bool.Parse, options.DisableTelemetry); + options.ForwardAuthenticate = configSection[nameof(options.ForwardAuthenticate)] ?? options.ForwardAuthenticate; + options.ForwardChallenge = configSection[nameof(options.ForwardChallenge)] ?? options.ForwardChallenge; + options.ForwardDefault = configSection[nameof(options.ForwardDefault)] ?? options.ForwardDefault; + options.ForwardForbid = configSection[nameof(options.ForwardForbid)] ?? options.ForwardForbid; + options.ForwardSignIn = configSection[nameof(options.ForwardSignIn)] ?? options.ForwardSignIn; + options.ForwardSignOut = configSection[nameof(options.ForwardSignOut)] ?? options.ForwardSignOut; + options.GetClaimsFromUserInfoEndpoint = StringHelpers.ParseValueOrDefault(configSection[nameof(options.GetClaimsFromUserInfoEndpoint)], bool.Parse, options.GetClaimsFromUserInfoEndpoint); + options.MapInboundClaims = StringHelpers.ParseValueOrDefault(configSection[nameof(options.MapInboundClaims)], bool.Parse, options.MapInboundClaims); + options.MaxAge = StringHelpers.ParseValueOrDefault(configSection[nameof(options.MaxAge)], _invariantNullableTimeSpanParse, options.MaxAge); + options.MetadataAddress = configSection[nameof(options.MetadataAddress)] ?? options.MetadataAddress; + SetCookieFromConfig(configSection.GetSection(nameof(options.NonceCookie)), options.NonceCookie); + options.Prompt = configSection[nameof(options.Prompt)] ?? options.Prompt; + options.RefreshInterval = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshInterval)], _invariantTimeSpanParse, options.RefreshInterval); + options.RefreshOnIssuerKeyNotFound = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RefreshOnIssuerKeyNotFound)], bool.Parse, options.RefreshOnIssuerKeyNotFound); + options.RemoteAuthenticationTimeout = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RemoteAuthenticationTimeout)], _invariantTimeSpanParse, options.RemoteAuthenticationTimeout); + options.RemoteSignOutPath = new PathString(configSection[nameof(options.RemoteSignOutPath)] ?? options.RemoteSignOutPath.Value); + options.RequireHttpsMetadata = StringHelpers.ParseValueOrDefault(configSection[nameof(options.RequireHttpsMetadata)], bool.Parse, options.RequireHttpsMetadata); + options.Resource = configSection[nameof(options.Resource)] ?? options.Resource; + options.ResponseMode = configSection[nameof(options.ResponseMode)] ?? options.ResponseMode; + options.ResponseType = configSection[nameof(options.ResponseType)] ?? options.ResponseType; + options.ReturnUrlParameter = configSection[nameof(options.ReturnUrlParameter)] ?? options.ReturnUrlParameter; + options.SaveTokens = StringHelpers.ParseValueOrDefault(configSection[nameof(options.SaveTokens)], bool.Parse, options.SaveTokens); + ClearAndSetListOption(options.Scope, configSection.GetSection(nameof(options.Scope))); + options.SignedOutCallbackPath = new PathString(configSection[nameof(options.SignedOutCallbackPath)] ?? options.SignedOutCallbackPath.Value); + options.SignedOutRedirectUri = configSection[nameof(options.SignedOutRedirectUri)] ?? options.SignedOutRedirectUri; + options.SignInScheme = configSection[nameof(options.SignInScheme)] ?? options.SignInScheme; + options.SignOutScheme = configSection[nameof(options.SignOutScheme)] ?? options.SignOutScheme; + options.SkipUnrecognizedRequests = StringHelpers.ParseValueOrDefault(configSection[nameof(options.SkipUnrecognizedRequests)], bool.Parse, options.SkipUnrecognizedRequests); + options.UsePkce = StringHelpers.ParseValueOrDefault(configSection[nameof(options.UsePkce)], bool.Parse, options.UsePkce); + options.UseTokenLifetime = StringHelpers.ParseValueOrDefault(configSection[nameof(options.UseTokenLifetime)], bool.Parse, options.UseTokenLifetime); + } + + private static void SetCookieFromConfig(IConfiguration cookieConfigSection, CookieBuilder cookieBuilder) + { + if (cookieConfigSection is null || !cookieConfigSection.GetChildren().Any()) + { + return; + } + + // Override the existing defaults when values are set instead of constructing + // an entirely new CookieBuilder. + cookieBuilder.Domain = cookieConfigSection[nameof(cookieBuilder.Domain)] ?? cookieBuilder.Domain; + cookieBuilder.HttpOnly = StringHelpers.ParseValueOrDefault(cookieConfigSection[nameof(cookieBuilder.HttpOnly)], bool.Parse, cookieBuilder.HttpOnly); + cookieBuilder.IsEssential = StringHelpers.ParseValueOrDefault(cookieConfigSection[nameof(cookieBuilder.IsEssential)], bool.Parse, cookieBuilder.IsEssential); + cookieBuilder.Expiration = StringHelpers.ParseValueOrDefault(cookieConfigSection[nameof(cookieBuilder.Expiration)], _invariantNullableTimeSpanParse, cookieBuilder.Expiration); + cookieBuilder.MaxAge = StringHelpers.ParseValueOrDefault(cookieConfigSection[nameof(cookieBuilder.MaxAge)], _invariantNullableTimeSpanParse, cookieBuilder.MaxAge); + cookieBuilder.Name = cookieConfigSection[nameof(CookieBuilder.Name)] ?? cookieBuilder.Name; + cookieBuilder.Path = cookieConfigSection[nameof(CookieBuilder.Path)] ?? cookieBuilder.Path; + cookieBuilder.SameSite = cookieConfigSection[nameof(CookieBuilder.SameSite)] is string sameSiteMode + ? Enum.Parse(sameSiteMode, ignoreCase: true) + : cookieBuilder.SameSite; + cookieBuilder.SecurePolicy = cookieConfigSection[nameof(CookieBuilder.SecurePolicy)] is string securePolicy + ? Enum.Parse(securePolicy, ignoreCase: true) + : cookieBuilder.SecurePolicy; + ClearAndSetListOption(cookieBuilder.Extensions, cookieConfigSection.GetSection(nameof(cookieBuilder.Extensions))); + } + + private static void ClearAndSetListOption(ICollection listOption, IConfigurationSection listConfigSection) + { + var elementsFromConfig = listConfigSection.GetChildren().Select(element => element.Value).OfType(); + if (elementsFromConfig.Any()) + { + listOption.Clear(); + foreach (var element in elementsFromConfig) + { + listOption.Add(element); + } + } + } + + /// + public void Configure(OpenIdConnectOptions options) + { + Configure(Options.DefaultName, options); + } +} diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs index 1a1a7af1ac41..a457a1fe2945 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs @@ -68,6 +68,7 @@ public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder /// A reference to after the operation has completed. public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action configureOptions) { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdConnectConfigureOptions>()); builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdConnectPostConfigureOptions>()); return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); } diff --git a/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs b/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs index d83f2e4a23f9..03d9f14b7222 100644 --- a/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs +++ b/src/Security/Authentication/test/AuthenticationMiddlewareTests.cs @@ -159,8 +159,8 @@ public async Task WebApplicationBuilder_RegistersAuthenticationAndAuthorizationM var builder = WebApplication.CreateBuilder(); builder.Configuration.AddInMemoryCollection(new[] { - new KeyValuePair("Authentication:Schemes:Bearer:ClaimsIssuer", "SomeIssuer"), - new KeyValuePair("Authentication:Schemes:Bearer:Audiences:0", "https://localhost:5001") + new KeyValuePair("Authentication:Schemes:Bearer:ValidIssuer", "SomeIssuer"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidAudiences:0", "https://localhost:5001") }); builder.Services.AddAuthorization(); builder.Services.AddAuthentication().AddJwtBearer(); diff --git a/src/Security/Authentication/test/JwtBearerTests.cs b/src/Security/Authentication/test/JwtBearerTests.cs index 19ff159cf326..291cdc4b4af3 100755 --- a/src/Security/Authentication/test/JwtBearerTests.cs +++ b/src/Security/Authentication/test/JwtBearerTests.cs @@ -17,6 +17,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Authentication.JwtBearer; @@ -885,6 +886,40 @@ public async Task ExpirationAndIssuedNullWhenMinOrMaxValue() Assert.Equal(JsonValueKind.Null, dom.RootElement.GetProperty("issued").ValueKind); } + [Fact] + public void CanReadJwtBearerOptionsFromConfig() + { + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:Bearer:ValidIssuer", "dotnet-user-jwts"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidAudiences:0", "http://localhost:5000"), + new KeyValuePair("Authentication:Schemes:Bearer:ValidAudiences:1", "https://localhost:5001"), + new KeyValuePair("Authentication:Schemes:Bearer:BackchannelTimeout", "00:01:00"), + new KeyValuePair("Authentication:Schemes:Bearer:RequireHttpsMetadata", "false"), + new KeyValuePair("Authentication:Schemes:Bearer:SaveToken", "True"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(o => + { + o.AddScheme("Bearer", "Bearer"); + }); + builder.AddJwtBearer("Bearer"); + RegisterAuth(builder, _ => { }); + var sp = services.BuildServiceProvider(); + + // Assert + var jwtBearerOptions = sp.GetRequiredService>().Get(JwtBearerDefaults.AuthenticationScheme); + Assert.Equal(jwtBearerOptions.TokenValidationParameters.ValidIssuers, new[] { "dotnet-user-jwts" }); + Assert.Equal(jwtBearerOptions.TokenValidationParameters.ValidAudiences, new[] { "http://localhost:5000", "https://localhost:5001" }); + Assert.Equal(jwtBearerOptions.BackchannelTimeout, TimeSpan.FromSeconds(60)); + Assert.False(jwtBearerOptions.RequireHttpsMetadata); + Assert.True(jwtBearerOptions.SaveToken); + Assert.True(jwtBearerOptions.MapInboundClaims); // Assert default values are respected + } + class InvalidTokenValidator : ISecurityTokenValidator { public InvalidTokenValidator() diff --git a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs index 52a6f4af69b5..fb9cee4d1baf 100644 --- a/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs +++ b/src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs @@ -7,9 +7,14 @@ using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; namespace Microsoft.AspNetCore.Authentication.Test.OpenIdConnect; @@ -408,6 +413,152 @@ public void NonceCookieExpirationTime() Assert.Equal(DateTime.MinValue, GetNonceExpirationTime(utcNow.Ticks.ToString(CultureInfo.InvariantCulture) + nonceDelimiter + utcNow.Ticks.ToString(CultureInfo.InvariantCulture) + nonceDelimiter, TimeSpan.FromHours(1))); } + [Fact] + public void CanReadOpenIdConnectOptionsFromConfig() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", "00:05:00"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:RequireHttpsMetadata", "false"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Domain", "https://localhost:5000"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Name", "CookieName"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(); + builder.AddOpenIdConnect(); + var sp = services.BuildServiceProvider(); + + // Assert + var options = sp.GetRequiredService>().Get(OpenIdConnectDefaults.AuthenticationScheme); + Assert.Equal("https://authority.com", options.Authority); + Assert.Equal(options.BackchannelTimeout, TimeSpan.FromMinutes(5)); + Assert.False(options.RequireHttpsMetadata); + Assert.False(options.GetClaimsFromUserInfoEndpoint); // Assert default values are respected + Assert.Equal(new PathString("/signin-oidc"), options.CallbackPath); // Assert default callback paths are respected + Assert.Equal("https://localhost:5000", options.CorrelationCookie.Domain); // Can set nested properties on cookie + Assert.Equal("CookieName", options.CorrelationCookie.Name); + } + + [Fact] + public void CanCreateOpenIdConnectCookiesFromConfig() + { + // Arrange + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", ""), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Domain", "https://localhost:5000"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:IsEssential", "False"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:SecurePolicy", "always"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(); + builder.AddOpenIdConnect(); + var sp = services.BuildServiceProvider(); + + // Assert + var options = sp.GetRequiredService>().Get(OpenIdConnectDefaults.AuthenticationScheme); + Assert.Equal("https://localhost:5000", options.CorrelationCookie.Domain); + Assert.False(options.CorrelationCookie.IsEssential); + Assert.Equal(CookieSecurePolicy.Always, options.CorrelationCookie.SecurePolicy); + // Default values are respected + Assert.Equal(".AspNetCore.Correlation.", options.CorrelationCookie.Name); + Assert.True(options.CorrelationCookie.HttpOnly); + Assert.Equal(SameSiteMode.None, options.CorrelationCookie.SameSite); + Assert.Equal(OpenIdConnectDefaults.CookieNoncePrefix, options.NonceCookie.Name); + Assert.True(options.NonceCookie.IsEssential); + Assert.True(options.NonceCookie.HttpOnly); + Assert.Equal(CookieSecurePolicy.SameAsRequest, options.NonceCookie.SecurePolicy); + Assert.Equal(TimeSpan.FromMinutes(1), options.BackchannelTimeout); + } + + [Fact] + public void ThrowsExceptionsWhenParsingInvalidOptionsFromConfig() + { + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", "definitelynotatimespan"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:CorrelationCookie:IsEssential", "definitelynotaboolean"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(); + builder.AddOpenIdConnect(); + var sp = services.BuildServiceProvider(); + + Assert.Throws(() => + sp.GetRequiredService>().Get(OpenIdConnectDefaults.AuthenticationScheme)); + } + + [Fact] + public void ScopeOptionsCanBeOverwrittenFromOptions() + { + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Scope:0", "given_name"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Scope:1", "birthdate"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(); + builder.AddOpenIdConnect(); + var sp = services.BuildServiceProvider(); + + var options = sp.GetRequiredService>().Get(OpenIdConnectDefaults.AuthenticationScheme); + Assert.Equal(2, options.Scope.Count); + Assert.DoesNotContain("openid", options.Scope); + Assert.DoesNotContain("profile", options.Scope); + Assert.Contains("given_name", options.Scope); + Assert.Contains("birthdate", options.Scope); + } + + [Fact] + public void OptionsFromConfigCanBeOverwritten() + { + var services = new ServiceCollection().AddLogging(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new[] + { + new KeyValuePair("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"), + new KeyValuePair("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"), + }).Build(); + services.AddSingleton(config); + + // Act + var builder = services.AddAuthentication(); + builder.AddOpenIdConnect(o => + { + o.ClientSecret = "overwritten-client-secret"; + }); + var sp = services.BuildServiceProvider(); + + var options = sp.GetRequiredService>().Get(OpenIdConnectDefaults.AuthenticationScheme); + Assert.Equal("client-id", options.ClientId); + Assert.Equal("overwritten-client-secret", options.ClientSecret); + } + private static DateTime GetNonceExpirationTime(string keyname, TimeSpan nonceLifetime) { DateTime nonceTime = DateTime.MinValue; diff --git a/src/Security/Security.slnf b/src/Security/Security.slnf index e8b8685e04b7..bc74b708689f 100644 --- a/src/Security/Security.slnf +++ b/src/Security/Security.slnf @@ -46,6 +46,7 @@ "src\\Security\\Authentication\\Negotiate\\test\\Negotiate.FunctionalTest\\Microsoft.AspNetCore.Authentication.Negotiate.FunctionalTest.csproj", "src\\Security\\Authentication\\Negotiate\\test\\Negotiate.Test\\Microsoft.AspNetCore.Authentication.Negotiate.Test.csproj", "src\\Security\\Authentication\\OAuth\\src\\Microsoft.AspNetCore.Authentication.OAuth.csproj", + "src\\Security\\Authentication\\OpenIdConnect\\samples\\MinimalOpenIdConnectSample\\MinimalOpenIdConnectSample.csproj", "src\\Security\\Authentication\\OpenIdConnect\\samples\\OpenIdConnectSample\\OpenIdConnectSample.csproj", "src\\Security\\Authentication\\OpenIdConnect\\src\\Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj", "src\\Security\\Authentication\\Twitter\\src\\Microsoft.AspNetCore.Authentication.Twitter.csproj", @@ -70,4 +71,4 @@ "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj" ] } -} \ No newline at end of file +} diff --git a/src/Shared/StringHelpers.cs b/src/Shared/StringHelpers.cs new file mode 100644 index 000000000000..fc61600c0cbe --- /dev/null +++ b/src/Shared/StringHelpers.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System; + +internal static class StringHelpers +{ + public static T ParseValueOrDefault(string? stringValue, Func parser, T defaultValue) + { + if (string.IsNullOrEmpty(stringValue)) + { + return defaultValue; + } + + return parser(stringValue); + } +} diff --git a/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs b/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs index 7a244fd03a32..63f16cb76587 100644 --- a/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs +++ b/src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.IdentityModel.Tokens; namespace Microsoft.AspNetCore.Authentication.JwtBearer.Tools; @@ -25,8 +26,8 @@ public void Save(string filePath) var settingsObject = new JsonObject { - [nameof(Audiences)] = new JsonArray(Audiences.Select(aud => JsonValue.Create(aud)).ToArray()), - [nameof(ClaimsIssuer)] = ClaimsIssuer + [nameof(TokenValidationParameters.ValidAudiences)] = new JsonArray(Audiences.Select(aud => JsonValue.Create(aud)).ToArray()), + [nameof(TokenValidationParameters.ValidIssuer)] = ClaimsIssuer }; if (config[AuthenticationKey] is JsonObject authentication)