diff --git a/src/EntityFramework.Storage/src/Entities/Client.cs b/src/EntityFramework.Storage/src/Entities/Client.cs index a17828f50..97a0ff4d8 100644 --- a/src/EntityFramework.Storage/src/Entities/Client.cs +++ b/src/EntityFramework.Storage/src/Entities/Client.cs @@ -65,11 +65,11 @@ public class Client public string UserCodeType { get; set; } public int DeviceCodeLifetime { get; set; } = 300; public bool NonEditable { get; set; } + public bool? CoordinateLifetimeWithUserSession { get; set; } //Unused Compatibility Properties public int? CibaLifetime { get; set; } public int? PollingInterval { get; set; } - public bool? CoordinateLifetimeWithUserSession { get; set; } public string InitiateLoginUri { get; set; } public TimeSpan DPoPClockSkew { get; set; } public int DPoPValidationMode { get; set; } diff --git a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs index 53edc1201..a109e94e8 100644 --- a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs +++ b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs @@ -101,7 +101,6 @@ public virtual async Task GetAsync(string key) public async Task> GetAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -147,7 +146,6 @@ public virtual async Task RemoveAsync(string key) public async Task RemoveAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -168,41 +166,25 @@ public async Task RemoveAllAsync(PersistedGrantFilter filter) } } - private void AddFilterTags(ITrace trace, PersistedGrantFilter filter) - { - if (trace == null) return; - - if (!String.IsNullOrWhiteSpace(filter.ClientId)) - { - trace.AddTag(TelemetryConstants.TagConstants.Client, filter.ClientId); - } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) - { - trace.AddTag(TelemetryConstants.TagConstants.Subject, filter.SubjectId); - } - if (!String.IsNullOrWhiteSpace(filter.Type)) - { - trace.AddTag(TelemetryConstants.TagConstants.GrantType, filter.Type); - } - } - private IQueryable Filter(IQueryable query, PersistedGrantFilter filter) { - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } return query; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs index 6dd5eec44..71919bc18 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs @@ -187,6 +187,19 @@ public async Task GetAllAsync_Should_Filter(DbContextOptions.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c2", "c3"], + }); + context.PersistedGrants.Count().Should().Be(5); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["t1", "t2"], + }); + context.PersistedGrants.Count().Should().Be(2); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c1", "c3"], + Types = ["t2", "t3"], + }); + context.PersistedGrants.Count().Should().Be(7); + } + return; async Task PopulateDb() diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index fa525a375..aad5d339b 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Open.IdentityServer.Models; +using Open.IdentityServer.Services.Default; namespace Microsoft.Extensions.DependencyInjection; diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7cd2d4007..cf62e4275 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -136,6 +136,8 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddCors(); builder.Services.AddTransientDecorator(); + + builder.Services.AddScoped(); return builder; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs index 0039185c3..848025a8f 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using Microsoft.AspNetCore.Http; @@ -60,4 +60,10 @@ public class AuthenticationOptions /// If set, will require frame-src CSP headers being emitting on the end session callback endpoint which renders iframes to clients for front-channel signout notification. /// public bool RequireCspFrameSrcForSignout { get; set; } = true; + + /// + /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at the client + /// level. + /// + public bool CoordinateClientLifetimesWithUserSession { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 0df4a320f..98b789526 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -2,7 +2,6 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - namespace Open.IdentityServer.Configuration; /// @@ -54,7 +53,7 @@ public class IdentityServerOptions /// /// The endpoints configuration. /// - public EndpointsOptions Endpoints { get; set; } = new EndpointsOptions(); + public EndpointsOptions Endpoints { get; set; } = new(); /// /// Gets or sets the discovery endpoint configuration. @@ -62,7 +61,7 @@ public class IdentityServerOptions /// /// The discovery endpoint configuration. /// - public DiscoveryOptions Discovery { get; set; } = new DiscoveryOptions(); + public DiscoveryOptions Discovery { get; set; } = new(); /// /// Gets or sets the authentication options. @@ -70,7 +69,7 @@ public class IdentityServerOptions /// /// The authentication options. /// - public AuthenticationOptions Authentication { get; set; } = new AuthenticationOptions(); + public AuthenticationOptions Authentication { get; set; } = new(); /// /// Gets or sets the events options. @@ -78,7 +77,7 @@ public class IdentityServerOptions /// /// The events options. /// - public EventsOptions Events { get; set; } = new EventsOptions(); + public EventsOptions Events { get; set; } = new(); /// /// Gets or sets the max input length restrictions. @@ -86,7 +85,7 @@ public class IdentityServerOptions /// /// The length restrictions. /// - public InputLengthRestrictions InputLengthRestrictions { get; set; } = new InputLengthRestrictions(); + public InputLengthRestrictions InputLengthRestrictions { get; set; } = new(); /// /// Gets or sets the options for the user interaction. @@ -94,7 +93,7 @@ public class IdentityServerOptions /// /// The user interaction options. /// - public UserInteractionOptions UserInteraction { get; set; } = new UserInteractionOptions(); + public UserInteractionOptions UserInteraction { get; set; } = new(); /// /// Gets or sets the caching options. @@ -102,7 +101,7 @@ public class IdentityServerOptions /// /// The caching options. /// - public CachingOptions Caching { get; set; } = new CachingOptions(); + public CachingOptions Caching { get; set; } = new(); /// /// Gets or sets the cors options. @@ -110,35 +109,40 @@ public class IdentityServerOptions /// /// The cors options. /// - public CorsOptions Cors { get; set; } = new CorsOptions(); + public CorsOptions Cors { get; set; } = new(); /// /// Gets or sets the Content Security Policy options. /// - public CspOptions Csp { get; set; } = new CspOptions(); + public CspOptions Csp { get; set; } = new(); /// /// Gets or sets the validation options. /// - public ValidationOptions Validation { get; set; } = new ValidationOptions(); + public ValidationOptions Validation { get; set; } = new(); /// /// Gets or sets the device flow options. /// - public DeviceFlowOptions DeviceFlow { get; set; } = new DeviceFlowOptions(); + public DeviceFlowOptions DeviceFlow { get; set; } = new(); /// /// Gets or sets the logging options /// - public LoggingOptions Logging { get; set; } = new LoggingOptions(); + public LoggingOptions Logging { get; set; } = new(); /// /// Gets or sets the mutual TLS options. /// - public MutualTlsOptions MutualTls { get; set; } = new MutualTlsOptions(); + public MutualTlsOptions MutualTls { get; set; } = new(); /// /// Gets or sets the enable authorise response issuer param option /// public bool EnableAuthorizeResponseIssuerParam { get; set; } = false; + + /// + /// Gets or sets the server-side session options + /// + public ServerSideSessionsOptions ServerSideSessions { get; set; } = new(); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs new file mode 100644 index 000000000..e9c0b12e5 --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Configuration; + +/// +/// Server side sessions options. +/// +public class ServerSideSessionsOptions +{ + /// + /// Specifies if session expiry should trigger back channel logout, this will override any other settings that may + /// cause back channel logout such as AuthenticationOptions.CoordinateClientLifetimesWithUserSession or + /// Client.CoordinateLifetimeWithUserSession. + /// + public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 31a180a58..38d4cb4a5 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -2,14 +2,15 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using Open.IdentityServer.Events; using Open.IdentityServer.Extensions; using Open.IdentityServer.Services; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System; +using System.Linq; using System.Threading.Tasks; +using Open.IdentityServer.Models; namespace Open.IdentityServer.Hosting; @@ -40,6 +41,7 @@ public IdentityServerMiddleware(RequestDelegate next, ILoggerThe user session. /// The event service. /// The service used to send back-channel logout notifications to clients when the user signs out. + /// The service for handling user session events /// The telemetry service /// A task that completes when the request has been handled by an IdentityServer endpoint or passed to the next middleware in the pipeline. public async Task Invoke( @@ -47,7 +49,8 @@ public async Task Invoke( IEndpointRouter router, IUserSession session, IEventService events, - IBackChannelLogoutService backChannelLogoutService, + IBackChannelLogoutService backChannelLogoutService, + IUserSessionEventsService userSessionEventsService, ITelemetryService telemetryService) { // this will check the authentication session and from it emit the check session @@ -62,13 +65,22 @@ public async Task Invoke( // this clears our session id cookie so JS clients can detect the user has signed out await session.RemoveSessionIdCookieAsync(); + + // notify other services of logout when required + var user = await session.GetUserAsync(); + var clientIds = await session.GetClientListAsync(); - // back channel logout - var logoutContext = await session.GetLogoutNotificationContext(); - if (logoutContext != null) + if (user == null) { - await backChannelLogoutService.SendLogoutNotificationsAsync(logoutContext); + return; } + + await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext + { + SessionId = await session.GetSessionIdAsync(), + SubjectId = user.GetSubjectId(), + ClientIds = clientIds.ToArray(), + }); } }); diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 7d10fdfdc..6a7dbe287 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -152,6 +152,8 @@ public static class PersistedGrantTypes public const string UserConsent = "user_consent"; public const string DeviceCode = "device_code"; public const string UserCode = "user_code"; + + public static readonly string[] PersistedGrantTokenTypes = [AuthorizationCode, ReferenceToken, RefreshToken]; } public static class UserCodeTypes diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs new file mode 100644 index 000000000..3298d0bce --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Models; + +/// +/// Provides the context for handling user session events +/// +public class UserSessionEventContext +{ + /// + /// Subject identifier of the user session the event has been triggered for + /// + public string SubjectId { get; set; } + + /// + /// Session identifier of the user session the event has been triggered for + /// + public string SessionId { get; set; } + + /// + /// ClientIds of the user session the event has been triggered for + /// + public string[] ClientIds { get; set; } = []; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs new file mode 100644 index 000000000..223070175 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -0,0 +1,120 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services.Default; + +/// +/// Default user session event handler for Open.IdentityServer +/// +/// client store +/// persisted grant store +/// back channel logout service +/// IdentityServer options +/// telemetry service +/// logger +public class DefaultUserSessionEventsService( + IClientStore clientStore, + IPersistedGrantStore persistedGrantStore, + IBackChannelLogoutService backChannelLogoutService, + IdentityServerOptions idsOptions, + ITelemetryService telemetry, + ILogger logger) : IUserSessionEventsService +{ + /// + public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + if (sessionEventContext.ClientIds.Length == 0) + { + logger.LogInformation("no clients linked to session, nothing to be done"); + return; + } + + await EndSessionForClients(sessionEventContext); + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + }); + } + + /// + public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + var clientToNotify = await EndSessionForClients(sessionEventContext); + + var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout + ? sessionEventContext.ClientIds + : clientToNotify ?? []).ToList(); + + if (backChannelClients.Count == 0) + { + logger.LogInformation("no backchannel clients to notify"); + return; + } + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = backChannelClients, + }); + } + + private async Task EndSessionForClients(UserSessionEventContext sessionEventContext) + { + var clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); + + if (clientIds.Length == 0) + { + logger.LogInformation("no clients to remove grants for"); + return null; + } + + await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + Types = IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes + }); + + return clientIds; + } + + private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) + { + foreach (string clientId in sessionEventContext.ClientIds ?? []) + { + var client = await clientStore.FindClientByIdAsync(clientId); + + if (client != null && (client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession)) + { + yield return client.ClientId; + } + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs new file mode 100644 index 000000000..580fb9d2b --- /dev/null +++ b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Services; + +/// +/// Service responsible handling user session events +/// +public interface IUserSessionEventsService +{ + /// + /// Triggered when session logout occurs + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext); + + /// + /// Triggered when session expires + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs index 54da9d40b..767be3cca 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs @@ -77,21 +77,23 @@ private IEnumerable Filter(PersistedGrantFilter filter) from item in _repository select item.Value; - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } var items = query.ToArray().AsEnumerable(); diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 2b86b5a0c..85181f8e5 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -4,8 +4,6 @@ #nullable enable using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Open.IdentityServer.Models; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs index a93d1bf1e..8a788c2d4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs @@ -1,13 +1,20 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +#nullable enable + using System; +using System.Security.Claims; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Moq; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Hosting; +using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Xunit; @@ -25,6 +32,7 @@ public class IdentityServerMiddlewareTests private readonly Mock _userSession; private readonly Mock _eventService; private readonly Mock _backChannelLogoutService; + private readonly IUserSessionEventsService userSessionEventsService; private readonly Mock _telemetryService; private readonly Mock _trace; private readonly DefaultHttpContext _context; @@ -41,6 +49,7 @@ public IdentityServerMiddlewareTests() _userSession = new Mock(); _eventService = new Mock(); _backChannelLogoutService = new Mock(); + userSessionEventsService = Mock.Of(); _telemetryService = new Mock(); _trace = new Mock(); _telemetryService.Setup(t => t.Trace(It.IsAny(), It.IsAny())) @@ -57,7 +66,7 @@ public IdentityServerMiddlewareTests() private async Task InvokeSubjectMiddleware() { await _subject.Invoke(_context, _router.Object, _userSession.Object, - _eventService.Object, _backChannelLogoutService.Object, _telemetryService?.Object); + _eventService.Object, _backChannelLogoutService.Object, userSessionEventsService, _telemetryService?.Object); } [Fact] @@ -208,4 +217,50 @@ public async Task Invoke_WhenRouterLocatesEndpoint_ShouldTrackActiveRequest() Times.Once); activeRequestDisposable.Verify(x => x.Dispose(), Times.Once); } + + [Fact] + public async Task Invoke_WhenSignOutCalled_ShouldCallHandleUserSessionLogout_OnIUserSessionEventsService() + { + string sessionId = "session-id"; + string subjectId = "subject-id"; + string[] clientIds = ["clientId1", "clientId2", "clientId3"]; + ClaimsPrincipal user = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, subjectId, ClaimValueTypes.String, "FakeIssuer"), + ])); + + // Manually invoke the OnStarting callback to test that user session events service is called + var responseFeatureMock = Mock.Of(); + Mock.Get(responseFeatureMock) + .Setup(x => x.OnStarting(It.IsAny>(), It.IsAny())) + .Callback, object>((callback, state) => { callback.Invoke(state); }); + _context.Features[typeof(IHttpResponseFeature)] = responseFeatureMock; + + _context.SetSignOutCalled(); + + _userSession.Setup(x => x.GetClientListAsync()) + .ReturnsAsync(clientIds); + _userSession.Setup(x => x.GetSessionIdAsync()) + .ReturnsAsync(sessionId); + _userSession.Setup(x => x.GetUserAsync()) + .ReturnsAsync(user); + + _userSession.Setup(x => x.RemoveSessionIdCookieAsync()).Returns(Task.CompletedTask); + + UserSessionEventContext? actualUserSessionEventCtx = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionLogout(It.IsAny())) + .Callback((sessionEventContext) => { actualUserSessionEventCtx = sessionEventContext; }); + + await InvokeSubjectMiddleware(); + + _userSession.Verify(x => x.RemoveSessionIdCookieAsync(), Times.Once); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionLogout(It.IsAny()), Times.Once); + + actualUserSessionEventCtx.Should().NotBeNull(); + actualUserSessionEventCtx.SessionId.Should().BeEquivalentTo(sessionId); + actualUserSessionEventCtx.SubjectId.Should().BeEquivalentTo(subjectId); + actualUserSessionEventCtx.ClientIds.Should().BeEquivalentTo(clientIds); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs new file mode 100644 index 000000000..e6d8ec9ca --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -0,0 +1,437 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Common; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultUserSessionEventsServiceTests +{ + private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private readonly IClientStore clientStore = Mock.Of(); + private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private readonly IdentityServerOptions idsOptions = new(); + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly ITrace trace = Mock.Of(); + private readonly ILogger logger = TestLogger.Create(); + + private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, telemetry, logger); + + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionLogout(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = true }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [..clients.Select(x => x.ClientId).ToList(), "fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-non-found")))); + } + + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionExpiry(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelLogoutIsTrue_ShouldTriggerBackchannelLogoutOnAllClientIgnoringCoordinationSetting() + { + idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionLogout(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionLogout")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionExpiry(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionExpiry")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } + + private void SetupClientStore(IEnumerable clients) + { + foreach (var client in clients) + { + Mock.Get(clientStore) + .Setup(x => x.FindClientByIdAsync(client.ClientId)) + .ReturnsAsync(client); + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs index 9ffb4c2ff..d87074e6e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs @@ -38,17 +38,14 @@ public async Task Store_should_persist_value() [Fact] public async Task GetAll_should_filter() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); - - - + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA"}); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + (await _subject.GetAllAsync(new PersistedGrantFilter { SubjectId = "sub1" @@ -194,6 +191,25 @@ public async Task GetAll_should_filter() SessionId = "session5" })) .Select(x => x.Key).Should().BeEmpty(); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client4"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key3", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + Types = ["typeA", "typeC"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key4", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client2", "client3"], + Types = ["typeA", "typeB"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key2", "key4"); } [Fact] @@ -521,17 +537,73 @@ await _subject.RemoveAllAsync(new PersistedGrantFilter (await _subject.GetAsync("key6")).Should().NotBeNull(); (await _subject.GetAsync("key7")).Should().NotBeNull(); } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client2"], + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Type = "typeA", + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["typeB", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client3", "client4"], + Types = ["typeA", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } } private async Task Populate() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeA" }); } } \ No newline at end of file diff --git a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs index 4bbcf5392..85bcf4782 100644 --- a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs +++ b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs @@ -4,6 +4,7 @@ using Open.IdentityServer.Stores; using System; +using System.Linq; namespace Open.IdentityServer.Extensions; @@ -18,12 +19,12 @@ public static class PersistedGrantFilterExtensions /// public static void Validate(this PersistedGrantFilter filter) { - if (filter == null) throw new ArgumentNullException(nameof(filter)); + ArgumentNullException.ThrowIfNull(filter); - if (String.IsNullOrWhiteSpace(filter.ClientId) && - String.IsNullOrWhiteSpace(filter.SessionId) && - String.IsNullOrWhiteSpace(filter.SubjectId) && - String.IsNullOrWhiteSpace(filter.Type)) + if (filter.ClientIds.Any(string.IsNullOrWhiteSpace) && + string.IsNullOrWhiteSpace(filter.SessionId) && + string.IsNullOrWhiteSpace(filter.SubjectId) && + filter.Types.Any(string.IsNullOrWhiteSpace)) { throw new ArgumentException("No filter values set.", nameof(filter)); } diff --git a/src/Storage/src/Models/Client.cs b/src/Storage/src/Models/Client.cs index a9fe06d1f..94d4bdfd5 100644 --- a/src/Storage/src/Models/Client.cs +++ b/src/Storage/src/Models/Client.cs @@ -431,8 +431,17 @@ IEnumerator IEnumerable.GetEnumerator() } } - //Unused Compatibility Properties + /// + /// Used to override the server default value configured with IdentityServerOptions.Authentication.CoordinateClientLifetimesWithUserSession. + /// Specifies if the user session ending should revoke client revocable tokens, and also if token validation should + /// check for a valid user session. + /// + /// + /// true if coordination enabled; false if coordination disabled; otherwise, null. + /// + public bool? CoordinateLifetimeWithUserSession { get; set; } + //Unused Compatibility Properties /// /// Gets or sets CIBA lifetime (Unused, added for compatibility) /// @@ -443,11 +452,6 @@ IEnumerator IEnumerable.GetEnumerator() /// public int? PollingInterval { get; set; } - /// - /// Gets or sets coordinate lifetime with user session (Unused, added for compatibility) - /// - public bool? CoordinateLifetimeWithUserSession { get; set; } - /// /// Gets or sets initiate login URI (Unused, added for compatibility) /// diff --git a/src/Storage/src/Stores/PersistedGrantFilter.cs b/src/Storage/src/Stores/PersistedGrantFilter.cs index 9bbbc51e8..c52d42e00 100644 --- a/src/Storage/src/Stores/PersistedGrantFilter.cs +++ b/src/Storage/src/Stores/PersistedGrantFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System.Linq; + namespace Open.IdentityServer.Stores; /// @@ -19,14 +21,32 @@ public class PersistedGrantFilter /// Session id used for the grant. /// public string SessionId { get; set; } - + /// - /// Client id the grant was issued to. + /// Client id the grant was issued to. For backwards compatibility. /// - public string ClientId { get; set; } - + public string ClientId + { + init => ClientIds = [value]; + get => ClientIds.FirstOrDefault(); + } + + /// + /// Client ids the grant was issued to. Multiple elements in array interpreted as a logic 'OR' for the client id property. + /// + public string[] ClientIds { get; set; } = []; + + /// + /// The type of grant. For backwards compatibility. + /// + public string Type + { + init => Types = [value]; + get => Types.FirstOrDefault(); + } + /// - /// The type of grant. + /// The type of grant. Multiple elements in array interpreted as a logic 'OR' for the type property. /// - public string Type { get; set; } + public string[] Types { get; set; } = []; } \ No newline at end of file