mirror of
https://github.com/DuendeSoftware/products
synced 2026-05-24 09:28:24 +00:00
Merge pull request #2213 from DuendeSoftware/beh/aspnetidentity-session-claims-filter
Use Customizable Filter to Persist Session Claims in ASP.NET Identity
This commit is contained in:
commit
fc7c49cf5d
9 changed files with 170 additions and 13 deletions
|
|
@ -20,7 +20,9 @@
|
|||
This makes the IdentityServer route names appear in OTel traces.
|
||||
- Support for custom parameters in the Authorize Redirect Uri by @bhazen
|
||||
- Adds a new `CustomParameters` property to `AuthorizeResponse` to support adding custom query parameters to the redirect uri. This will typically be used in conjunction with a custom `IAuthorizeResponseGenerator`.
|
||||
|
||||
- Updated ASP.NET Identity package to persist session claims based on an interface @bhazen
|
||||
- The ASP.NET Identity integration package now persists session claims based on `ISessionClaimsFilter.FilterToSessionClaimsAsync` which comes with a default implementation
|
||||
- The new interface can be implemented to customize which session claims are persisted in non-default scenarios.
|
||||
## Bug Fixes
|
||||
- Reject Pushed Authorization Requests with parameters duplicated in a JAR by @wcabus
|
||||
- Emit Telemetry Event for Introspection Requests for Valid Tokens by @bhazen
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Duende.IdentityServer.AspNetIdentity;
|
||||
|
||||
public class ConfigureSecurityStampValidatorOptions(ISessionClaimsFilter sessionClaimsFilter) : IConfigureOptions<SecurityStampValidatorOptions>
|
||||
{
|
||||
public void Configure(SecurityStampValidatorOptions options) => options.OnRefreshingPrincipal = async context => await SecurityStampValidatorCallback.UpdatePrincipal(context, sessionClaimsFilter);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Duende.IdentityServer.AspNetIdentity;
|
||||
|
||||
public class DefaultSessionClaimsFilter : ISessionClaimsFilter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyCollection<Claim>> FilterToSessionClaimsAsync(SecurityStampRefreshingPrincipalContext context)
|
||||
{
|
||||
var newClaimTypes = context.NewPrincipal.Claims.Select(x => x.Type).ToArray();
|
||||
var currentClaimsToKeep = context.CurrentPrincipal.Claims.Where(x => !newClaimTypes.Contains(x.Type)).ToArray();
|
||||
|
||||
var id = context.NewPrincipal.Identities.First();
|
||||
id.AddClaims(currentClaimsToKeep);
|
||||
|
||||
return Task.FromResult<IReadOnlyCollection<Claim>>(currentClaimsToKeep);
|
||||
}
|
||||
}
|
||||
21
identity-server/src/AspNetIdentity/ISessionClaimsFilter.cs
Normal file
21
identity-server/src/AspNetIdentity/ISessionClaimsFilter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Duende.IdentityServer.AspNetIdentity;
|
||||
|
||||
public interface ISessionClaimsFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Filters the claims in the given SecurityStampRefreshingPrincipalContext to those that should be kept for the session.
|
||||
/// These claims are not claims persisted by ASP.NET Identity, but are typically captured and login time and need to be
|
||||
/// persisted across updates to the ClaimsPrincipal in the <see cref="SecurityStampValidatorOptions.OnRefreshingPrincipal"/>
|
||||
/// method.
|
||||
/// </summary>
|
||||
/// <param name="context">The SecurityStampRefreshingPrincipalContext <see cref="SecurityStampRefreshingPrincipalContext.SecurityStampRefreshingPrincipalContext"/>
|
||||
/// in the call to <see cref="SecurityStampValidatorOptions.OnRefreshingPrincipal"/>.</param>
|
||||
/// <returns>The claims of the ClaimsPrincipal which should be persisted for the session.</returns>
|
||||
public Task<IReadOnlyCollection<Claim>> FilterToSessionClaimsAsync(SecurityStampRefreshingPrincipalContext context);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ using Duende.IdentityServer.AspNetIdentity;
|
|||
using Duende.IdentityServer.Configuration;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -20,7 +21,7 @@ namespace Microsoft.Extensions.DependencyInjection;
|
|||
public static class IdentityServerBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures IdentityServer to use the ASP.NET Identity implementations
|
||||
/// Configures IdentityServer to use the ASP.NET Identity implementations
|
||||
/// of IUserClaimsPrincipalFactory, IResourceOwnerPasswordValidator, and IProfileService.
|
||||
/// Also configures some of ASP.NET Identity's options for use with IdentityServer (such as claim types to use
|
||||
/// and authentication cookie settings).
|
||||
|
|
@ -41,10 +42,8 @@ public static class IdentityServerBuilderExtensions
|
|||
options.ClaimsIdentity.EmailClaimType = JwtClaimTypes.Email;
|
||||
});
|
||||
|
||||
builder.Services.Configure<SecurityStampValidatorOptions>(opts =>
|
||||
{
|
||||
opts.OnRefreshingPrincipal = SecurityStampValidatorCallback.UpdatePrincipal;
|
||||
});
|
||||
builder.Services.TryAddTransient<ISessionClaimsFilter, DefaultSessionClaimsFilter>();
|
||||
builder.Services.ConfigureOptions<ConfigureSecurityStampValidatorOptions>();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Duende.IdentityServer.AspNetIdentity;
|
||||
|
|
@ -16,15 +15,20 @@ public static class SecurityStampValidatorCallback
|
|||
/// This is needed to preserve claims such as idp, auth_time, amr.
|
||||
/// </summary>
|
||||
/// <param name="context">The context.</param>
|
||||
/// <param name="sessionClaimsFilter">Instance of session claims filter used to filter the claims from the ClaimsPrincipal to
|
||||
/// those that are session claims which are not persisted by ASP.NET Identity and would otherwise bee lost when the principal
|
||||
/// is updated.</param>
|
||||
/// <returns></returns>
|
||||
public static Task UpdatePrincipal(SecurityStampRefreshingPrincipalContext context)
|
||||
public static async Task UpdatePrincipal(SecurityStampRefreshingPrincipalContext context, ISessionClaimsFilter sessionClaimsFilter)
|
||||
{
|
||||
var newClaimTypes = context.NewPrincipal.Claims.Select(x => x.Type).ToArray();
|
||||
var currentClaimsToKeep = context.CurrentPrincipal.Claims.Where(x => !newClaimTypes.Contains(x.Type)).ToArray();
|
||||
if (context.NewPrincipal == null || !context.NewPrincipal.Identities.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentClaimsToKeep = await sessionClaimsFilter.FilterToSessionClaimsAsync(context);
|
||||
|
||||
var id = context.NewPrincipal.Identities.First();
|
||||
id.AddClaims(currentClaimsToKeep);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
// Copyright (c) Duende Software. All rights reserved.
|
||||
// See LICENSE in the project root for license information.
|
||||
|
||||
using System.Security.Claims;
|
||||
using Duende.IdentityModel;
|
||||
using Duende.IdentityServer.AspNetIdentity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace IdentityServer.UnitTests.AspNetIdentity;
|
||||
|
||||
public class DefaultSessionClaimsFilterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FilterToSessionClaimsAsync_with_session_and_non_session_claims_should_filter_to_only_session_claims()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.AuthenticationMethod, "pwd"),
|
||||
new Claim(JwtClaimTypes.IdentityProvider, "idp"),
|
||||
new Claim(JwtClaimTypes.AuthenticationTime, "123456"),
|
||||
new Claim("custom", "value"),
|
||||
new Claim(ClaimTypes.Name, "bob")
|
||||
};
|
||||
var currentPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity([new Claim("custom", "value"), new Claim(ClaimTypes.Name, "bob")]));
|
||||
var filter = new DefaultSessionClaimsFilter();
|
||||
var context = new SecurityStampRefreshingPrincipalContext() { NewPrincipal = newPrincipal, CurrentPrincipal = currentPrincipal };
|
||||
|
||||
var result = await filter.FilterToSessionClaimsAsync(context);
|
||||
|
||||
var resultTypes = result.Select(c => c.Type).ToList();
|
||||
resultTypes.Count.ShouldBe(3);
|
||||
resultTypes.ShouldContain(JwtClaimTypes.AuthenticationMethod);
|
||||
resultTypes.ShouldContain(JwtClaimTypes.IdentityProvider);
|
||||
resultTypes.ShouldContain(JwtClaimTypes.AuthenticationTime);
|
||||
resultTypes.ShouldNotContain("custom");
|
||||
resultTypes.ShouldNotContain(ClaimTypes.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterToSessionClaimsAsync_with_only_session_claims_should_filter_to_session_claims()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtClaimTypes.AuthenticationMethod, "pwd"),
|
||||
new Claim(JwtClaimTypes.IdentityProvider, "idp"),
|
||||
new Claim(JwtClaimTypes.AuthenticationTime, "123456")
|
||||
};
|
||||
var currentPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var filter = new DefaultSessionClaimsFilter();
|
||||
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = newPrincipal, CurrentPrincipal = currentPrincipal };
|
||||
|
||||
var result = await filter.FilterToSessionClaimsAsync(context);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
string[] expectClaimTypes = [
|
||||
JwtClaimTypes.AuthenticationMethod,
|
||||
JwtClaimTypes.IdentityProvider,
|
||||
JwtClaimTypes.AuthenticationTime
|
||||
];
|
||||
result.ShouldAllBe(c => expectClaimTypes.Contains(c.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterToSessionClaimsAsync_with_no_session_claims_should_return_empty()
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("custom", "value"),
|
||||
new Claim(ClaimTypes.Name, "bob")
|
||||
};
|
||||
var currentPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
|
||||
var filter = new DefaultSessionClaimsFilter();
|
||||
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = newPrincipal, CurrentPrincipal = currentPrincipal };
|
||||
|
||||
var result = await filter.FilterToSessionClaimsAsync(context);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FilterToSessionClaimsAsync_when_principal_has_no_claims_should_return_empty()
|
||||
{
|
||||
var newPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var currentPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
|
||||
var filter = new DefaultSessionClaimsFilter();
|
||||
var context = new SecurityStampRefreshingPrincipalContext { NewPrincipal = newPrincipal, CurrentPrincipal = currentPrincipal };
|
||||
|
||||
var result = await filter.FilterToSessionClaimsAsync(context);
|
||||
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\shared\ShouldlyExtensions\ShouldlyExtensions.csproj" />
|
||||
<ProjectReference Include="..\..\src\AspNetIdentity\Duende.IdentityServer.AspNetIdentity.csproj" />
|
||||
<ProjectReference Include="..\..\src\IdentityServer\Duende.IdentityServer.csproj" />
|
||||
<ProjectReference Include="..\..\src\EntityFramework.Storage\Duende.IdentityServer.EntityFramework.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ public class RegisteredImplementationsDiagnosticEntryTests
|
|||
.SelectMany(assembly => assembly.GetExportedTypes())
|
||||
.Where(type => type.IsInterface && type.IsPublic && type.Namespace != null
|
||||
&& type.Namespace.StartsWith("Duende.IdentityServer")
|
||||
&& !type.Namespace.StartsWith("Duende.IdentityServer.EntityFramework"))
|
||||
&& !type.Namespace.StartsWith("Duende.IdentityServer.EntityFramework")
|
||||
&& !type.Namespace.StartsWith("Duende.IdentityServer.AspNetIdentity"))
|
||||
.Select(type => type);
|
||||
var subject = new RegisteredImplementationsDiagnosticEntry(new ServiceCollectionAccessor(new ServiceCollection()));
|
||||
var typesTrackedField = typeof(RegisteredImplementationsDiagnosticEntry)
|
||||
|
|
|
|||
Loading…
Reference in a new issue