Browse Source

Support loading OIDC options from configuration (#42679)

* Support loading OIDC options from configuration

* Address feedback from review

* Address review feedback

* Support fallbacks for all options and override lists

* Fix up Enum.Parse and default for Authority
Safia Abdalla 3 years ago
parent
commit
2e08d0cfb1
18 changed files with 503 additions and 24 deletions
  1. 19 0
      AspNetCore.sln
  2. 6 6
      src/Security/Authentication/JwtBearer/samples/MinimalJwtBearerSample/appsettings.Development.json
  3. 44 13
      src/Security/Authentication/JwtBearer/src/JwtBearerConfigureOptions.cs
  4. 4 0
      src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj
  5. 15 0
      src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/MinimalOpenIdConnectSample.csproj
  6. 18 0
      src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Program.cs
  7. 37 0
      src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/Properties/launchSettings.json
  8. 8 0
      src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.Development.json
  9. 9 0
      src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.json
  10. 4 0
      src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj
  11. 128 0
      src/Security/Authentication/OpenIdConnect/src/OpenIdConnectConfigureOptions.cs
  12. 1 0
      src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs
  13. 2 2
      src/Security/Authentication/test/AuthenticationMiddlewareTests.cs
  14. 35 0
      src/Security/Authentication/test/JwtBearerTests.cs
  15. 151 0
      src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs
  16. 2 1
      src/Security/Security.slnf
  17. 17 0
      src/Shared/StringHelpers.cs
  18. 3 2
      src/Tools/dotnet-user-jwts/src/Helpers/JwtAuthenticationSchemeSettings.cs

+ 19 - 0
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}

+ 6 - 6
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"
       }
     }
   }

+ 44 - 13
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<JwtBearerOptions>
 {
     private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider;
+    private static readonly Func<string, TimeSpan> _invariantTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture);
 
     /// <summary>
     /// Initializes a new <see cref="JwtBearerConfigureOptions"/> given the configuration
@@ -39,26 +40,56 @@ internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions<JwtBear
             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<SecurityKey> GetIssuerSigningKeys(IConfiguration configuration, List<string?> 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));
+            }
+        }
     }
 
     /// <inheritdoc />

+ 4 - 0
src/Security/Authentication/JwtBearer/src/Microsoft.AspNetCore.Authentication.JwtBearer.csproj

@@ -13,4 +13,8 @@
     <Reference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)StringHelpers.cs" LinkBase="Shared" />
+  </ItemGroup>
+
 </Project>

+ 15 - 0
src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/MinimalOpenIdConnectSample.csproj

@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore" />
+    <Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />
+    <Reference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
+  </ItemGroup>
+
+</Project>

+ 18 - 0
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();

+ 37 - 0
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"
+      }
+    }
+  }
+}

+ 8 - 0
src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.Development.json

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

+ 9 - 0
src/Security/Authentication/OpenIdConnect/samples/MinimalOpenIdConnectSample/appsettings.json

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

+ 4 - 0
src/Security/Authentication/OpenIdConnect/src/Microsoft.AspNetCore.Authentication.OpenIdConnect.csproj

@@ -13,4 +13,8 @@
     <Reference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Compile Include="$(SharedSourceRoot)StringHelpers.cs" LinkBase="Shared" />
+  </ItemGroup>
+
 </Project>

+ 128 - 0
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<OpenIdConnectOptions>
+{
+    private readonly IAuthenticationConfigurationProvider _authenticationConfigurationProvider;
+    private static readonly Func<string, TimeSpan> _invariantTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture);
+    private static readonly Func<string, TimeSpan?> _invariantNullableTimeSpanParse = (string timespanString) => TimeSpan.Parse(timespanString, CultureInfo.InvariantCulture);
+
+    /// <summary>
+    /// Initializes a new <see cref="OpenIdConnectConfigureOptions"/> given the configuration
+    /// provided by the <paramref name="configurationProvider"/>.
+    /// </summary>
+    /// <param name="configurationProvider">An <see cref="IAuthenticationConfigurationProvider"/> instance.</param>
+    public OpenIdConnectConfigureOptions(IAuthenticationConfigurationProvider configurationProvider)
+    {
+        _authenticationConfigurationProvider = configurationProvider;
+    }
+
+    /// <inheritdoc />
+    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<TimeSpan?>(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>(sameSiteMode, ignoreCase: true)
+            : cookieBuilder.SameSite;
+        cookieBuilder.SecurePolicy = cookieConfigSection[nameof(CookieBuilder.SecurePolicy)] is string securePolicy
+            ? Enum.Parse<CookieSecurePolicy>(securePolicy, ignoreCase: true)
+            : cookieBuilder.SecurePolicy;
+        ClearAndSetListOption(cookieBuilder.Extensions, cookieConfigSection.GetSection(nameof(cookieBuilder.Extensions)));
+    }
+
+    private static void ClearAndSetListOption(ICollection<string> listOption, IConfigurationSection listConfigSection)
+    {
+        var elementsFromConfig = listConfigSection.GetChildren().Select(element => element.Value).OfType<string>();
+        if (elementsFromConfig.Any())
+        {
+            listOption.Clear();
+            foreach (var element in elementsFromConfig)
+            {
+                listOption.Add(element);
+            }
+        }
+    }
+
+    /// <inheritdoc />
+    public void Configure(OpenIdConnectOptions options)
+    {
+        Configure(Options.DefaultName, options);
+    }
+}

+ 1 - 0
src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs

@@ -68,6 +68,7 @@ public static class OpenIdConnectExtensions
     /// <returns>A reference to <paramref name="builder"/> after the operation has completed.</returns>
     public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string? displayName, Action<OpenIdConnectOptions> configureOptions)
     {
+        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<OpenIdConnectOptions>, OpenIdConnectConfigureOptions>());
         builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IPostConfigureOptions<OpenIdConnectOptions>, OpenIdConnectPostConfigureOptions>());
         return builder.AddRemoteScheme<OpenIdConnectOptions, OpenIdConnectHandler>(authenticationScheme, displayName, configureOptions);
     }

+ 2 - 2
src/Security/Authentication/test/AuthenticationMiddlewareTests.cs

@@ -159,8 +159,8 @@ public class AuthenticationMiddlewareTests
         var builder = WebApplication.CreateBuilder();
         builder.Configuration.AddInMemoryCollection(new[]
         {
-            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:ClaimsIssuer", "SomeIssuer"),
-            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:Audiences:0", "https://localhost:5001")
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:ValidIssuer", "SomeIssuer"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:ValidAudiences:0", "https://localhost:5001")
         });
         builder.Services.AddAuthorization();
         builder.Services.AddAuthentication().AddJwtBearer();

+ 35 - 0
src/Security/Authentication/test/JwtBearerTests.cs

@@ -17,6 +17,7 @@ using Microsoft.AspNetCore.TestHost;
 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 class JwtBearerTests : SharedAuthenticationTests<JwtBearerOptions>
         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<string, string>("Authentication:Schemes:Bearer:ValidIssuer", "dotnet-user-jwts"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:ValidAudiences:0", "http://localhost:5000"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:ValidAudiences:1", "https://localhost:5001"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:BackchannelTimeout", "00:01:00"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:RequireHttpsMetadata", "false"),
+            new KeyValuePair<string, string>("Authentication:Schemes:Bearer:SaveToken", "True"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication(o =>
+        {
+            o.AddScheme<TestHandler>("Bearer", "Bearer");
+        });
+        builder.AddJwtBearer("Bearer");
+        RegisterAuth(builder, _ => { });
+        var sp = services.BuildServiceProvider();
+
+        // Assert
+        var jwtBearerOptions = sp.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().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()

+ 151 - 0
src/Security/Authentication/test/OpenIdConnect/OpenIdConnectTests.cs

@@ -7,9 +7,14 @@ using System.Net;
 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 class OpenIdConnectTests
         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<string, string>("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", "00:05:00"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:RequireHttpsMetadata", "false"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Domain", "https://localhost:5000"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Name", "CookieName"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication();
+        builder.AddOpenIdConnect();
+        var sp = services.BuildServiceProvider();
+
+        // Assert
+        var options = sp.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().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<string, string>("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", ""),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:Domain", "https://localhost:5000"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:IsEssential", "False"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:SecurePolicy", "always"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication();
+        builder.AddOpenIdConnect();
+        var sp = services.BuildServiceProvider();
+
+        // Assert
+        var options = sp.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().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<string, string>("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:BackchannelTimeout", "definitelynotatimespan"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:CorrelationCookie:IsEssential", "definitelynotaboolean"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication();
+        builder.AddOpenIdConnect();
+        var sp = services.BuildServiceProvider();
+
+        Assert.Throws<FormatException>(() =>
+            sp.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().Get(OpenIdConnectDefaults.AuthenticationScheme));
+    }
+
+    [Fact]
+    public void ScopeOptionsCanBeOverwrittenFromOptions()
+    {
+        var services = new ServiceCollection().AddLogging();
+        var config = new ConfigurationBuilder().AddInMemoryCollection(new[]
+        {
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:Scope:0", "given_name"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:Scope:1", "birthdate"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication();
+        builder.AddOpenIdConnect();
+        var sp = services.BuildServiceProvider();
+
+        var options = sp.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().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<string, string>("Authentication:Schemes:OpenIdConnect:Authority", "https://authority.com"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientId", "client-id"),
+            new KeyValuePair<string, string>("Authentication:Schemes:OpenIdConnect:ClientSecret", "client-secret"),
+        }).Build();
+        services.AddSingleton<IConfiguration>(config);
+
+        // Act
+        var builder = services.AddAuthentication();
+        builder.AddOpenIdConnect(o =>
+        {
+            o.ClientSecret = "overwritten-client-secret";
+        });
+        var sp = services.BuildServiceProvider();
+
+        var options = sp.GetRequiredService<IOptionsMonitor<OpenIdConnectOptions>>().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;

+ 2 - 1
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"
     ]
   }
-}
+}

+ 17 - 0
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<T>(string? stringValue, Func<string, T> parser, T defaultValue)
+    {
+        if (string.IsNullOrEmpty(stringValue))
+        {
+            return defaultValue;
+        }
+
+        return parser(stringValue);
+    }
+}

+ 3 - 2
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 @@ internal sealed record JwtAuthenticationSchemeSettings(string SchemeName, List<s
 
         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)