diff --git a/Directory.Build.props b/Directory.Build.props index af77b9d53..b61c22c56 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.0.0 - 2.0 + 3.0.0 + 3.0 RockSolidKnowledge CS1591;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1591;CS1658;CS1712;CS1734 diff --git a/Open.IdentityServer.sln b/Open.IdentityServer.sln index 701242153..30f776ab3 100644 --- a/Open.IdentityServer.sln +++ b/Open.IdentityServer.sln @@ -72,6 +72,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7A351D3D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Storage.UnitTests", "src\Storage\tests\Open.IdentityServer.Storage.UnitTests\Open.IdentityServer.Storage.UnitTests.csproj", "{FF1944C8-2516-4197-9B63-8BB3581882B3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{36985CF3-7E79-4BF4-91FD-3E9195A0FCEA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Test.Utilities", "src\Open.IdentityServer.Test.Utilities\Open.IdentityServer.Test.Utilities.csproj", "{F0AD47D4-F27B-4265-B1B9-B2346824CBE4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -109,6 +113,7 @@ Global {C459AC33-FD11-402E-8058-F76DD940190D} = {78510D3C-6BE1-4017-8AD4-CC905D58D788} {7A351D3D-4AC7-4662-8BF3-8F42D6097F87} = {7C740022-8283-4021-8E72-3F2847949309} {FF1944C8-2516-4197-9B63-8BB3581882B3} = {7A351D3D-4AC7-4662-8BF3-8F42D6097F87} + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4} = {36985CF3-7E79-4BF4-91FD-3E9195A0FCEA} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A2D626B8-1532-4E35-B9F2-4246C838D192}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -183,5 +188,9 @@ Global {FF1944C8-2516-4197-9B63-8BB3581882B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF1944C8-2516-4197-9B63-8BB3581882B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF1944C8-2516-4197-9B63-8BB3581882B3}.Release|Any CPU.Build.0 = Release|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/build.ps1 b/build.ps1 index 899313559..94fdec65e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -4,6 +4,10 @@ New-Item -ItemType Directory -Force -Path ./nuget dotnet tool restore +pushd ./src/Open.IdentityServer.Test.Utilities +Invoke-Expression "dotnet build" +popd + pushd ./src/Storage Invoke-Expression "./build.ps1 $args" popd diff --git a/build.sh b/build.sh index a566c62a8..4712e94a7 100755 --- a/build.sh +++ b/build.sh @@ -6,6 +6,10 @@ mkdir nuget dotnet tool restore +pushd ./src/Open.IdentityServer.Test.Utilities +dotnet build +popd + pushd ./src/Storage ./build.sh "$@" popd diff --git a/docs/index.rst b/docs/index.rst index 8f4351093..db216413c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -80,6 +80,7 @@ Open.IdentityServer enables the following features in your applications: topics/compatibility topics/resources topics/clients + topics/server_side_sessions topics/signin topics/signin_external_providers topics/windows diff --git a/docs/migrating/from_duende.rst b/docs/migrating/from_duende.rst index 6acd0a46f..547065191 100644 --- a/docs/migrating/from_duende.rst +++ b/docs/migrating/from_duende.rst @@ -71,8 +71,6 @@ Migration Steps - Remove all references. Not yet supported in Open.IdentityServer. * - Automatic Key Management - Remove all references. You will need to configure signing keys manually, or :ref:`configure read-only key store `. - * - Server Side Sessions - - Remove all references. Not yet supported in Open.IdentityServer. * - Pushed Authorisation Requests (PAR) - Remove all references. Not yet supported in Open.IdentityServer. * - CIBA (Client Initiated Backchannel Authentication) diff --git a/docs/reference/options.rst b/docs/reference/options.rst index e727bba4a..6706477a1 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -173,3 +173,24 @@ Mutual TLS Specifies whether a cnf claim gets emitted for access tokens if a client certificate was present. Normally the cnf claims only gets emitted if the client used the client certificate for authentication, setting this to true, will set the claim regardless of the authentication method. (defaults to false). + +Server-Side Sessions +^^^^^^^^^^^^^^^^^^^^ + +* ``ExpiredSessionsTriggerBackchannelLogout`` + 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. + +* ``RemoveExpiredSessions`` + Specifies if expired sessions should be cleaned up automatically by Open.IdentityServer. The default value is true. + +* ``RemoveExpiredSessionsFrequency`` + Specifies the frequency with which expired sessions are looked for and removed. The default value is a TimeSpan of 10 minutes. + +* ``FuzzExpiredSessionsFrequency`` + Specifies if the start time of the hosted service should be randomised. This avoids the scenario where multiple running instances + of identityserver run cleanup jobs simultaneously. The default value is true. + +* ``RemoveExpiredSessionsBatchSize`` + Specifies how many expired sessions should be removed in a single pass. The default value is 100. diff --git a/docs/topics/server_side_sessions.rst b/docs/topics/server_side_sessions.rst new file mode 100644 index 000000000..bca7b8914 --- /dev/null +++ b/docs/topics/server_side_sessions.rst @@ -0,0 +1,225 @@ +.. _refServerSideSessions: + +Server-Side Sessions +==================== + +Overview +-------- +When users authenticate with Open.IdentityServer, a session is created to track the logged in user. By default this will be done by storing this state in cookies in the user browser. This approach of storing session state in a cookie can work well for many scenarios but does have some drawbacks. + +* **No Tracking Active Sessions** - There is no way to track active sessions, and how many users are currently logged in. +* **No Immediate Revocation** - There will be no process of Immediate session revocation on the server side-session cookie will be valid till it expires, or they log out. +* **No Sign-Out Coordination** - Coordinating sign-outs from Open.IdentityServer with connected clients is less relable without server tracking of active sessions. + +Open.IdentityServer Server-Side Sessions solves these issues by storing the contents of this cookie in a server side data store. This gives Open.IdentityServer the ability to: + +* Provide APIs for managing and querying active user sessions +* Support for explicit session revocation regardless of the cookie state in the browser +* Storing session data server side, so browser cookie only contains an ID for the session nothing more + +Getting Started +^^^^^^^^^^^^^^^ + +1. **Database schema** + + Ensure your database is in the correct state. If you are coming from Duende IdentityServer, there is nothing further to do - the schema is already compatible. If you are migrating from IdentityServer4 and have not yet updated your schema to match the Open.IdentityServer schema, you will need to do this first. See :ref:`migration from IdentityServer4 ` for details. + +2. **Enable server-side sessions** + + Call ``.AddServerSideSessions()`` when configuring Open.IdentityServer: + + .. code-block:: csharp + + builder.Services.AddIdentityServer() + .AddServerSideSessions(); + +3. **Configure a session store** + + ``AddServerSideSessions`` requires an implementation of ``IServerSideSessionStore`` to persist session data. If you are using Entity Framework Core, this is provided automatically when you configure the operational store: + + .. code-block:: csharp + + builder.Services.AddIdentityServer() + .AddServerSideSessions() + .AddOperationalStore(options => + { + options.ConfigureDbContext = b => + b.UseSqlServer("ConnectionString"); + }); + + If you are not using the built-in EF Core store, you will need to provide your own ``IServerSideSessionStore`` implementation. + +5. **(Optional) Configure additional session options** + + You can customize behavior via ``ServerSideSessionOptions``, such as how often sessions are checked for expiration in the background, or coordinating this with your sign-in cookie expiration: + + .. code-block:: csharp + + builder.Services.AddIdentityServer(options => + { + // Other options... + options.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + options.ServerSideSessions.RemoveExpiredSessions = true; + options.ServerSideSessions.RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(10); + options.ServerSideSessions.FuzzExpiredSessionsFrequency = true; + options.ServerSideSessions.RemoveExpiredSessionsBatchSize = 100; + }); + +Management Interface +^^^^^^^^^^^^^^^^^^^^ + +Open.IdentityServer has a built-in session management interface, ``ISessionManagementService``, that allows you to query existing sessions and remove sessions. When you configure Open.IdentityServer to use server-side sessions, a default implementation is registered for this interface. + +The interface provides two methods: + +- ``Task> ISessionManagementService.QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default)`` + + Filters sessions using the ``SessionQuery`` object. The filter is optional; default values are used when it is not provided. + +- ``Task ISessionManagementService.RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default)`` + + Removes sessions using the ``RemoveSessionsContext`` object. The context allows you to control which sessions are removed and what actions are taken as part of the removal. + +Data Types +^^^^^^^^^^ + +The following types are used by ``ISessionManagementService`` to query and manage sessions. + +SessionQuery +"""""""""""" + +``SessionQuery`` is used to filter results when calling ``QuerySessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``ResultsToken`` + - ``string?`` + - Optional selector for current page location, contains identifier for the first element and last element in page of results. e.g. '0,24' + * - ``RequestPriorResults`` + - ``bool`` + - Specifies if instead of getting next page should get the previous page that ResultsToken identifies. Only valid if ResultsToken is specified. + * - ``CountRequested`` + - ``int`` + - Specifies the count requested per page, defaults to 25 + * - ``SubjectId`` + - ``string?`` + - Filters sessions with a specific subject id + * - ``SessionId`` + - ``string?`` + - Filters sessions with a specific session id + * - ``DisplayName`` + - ``string?`` + - Filters sessions with a specific display name + +QueryResult +"""""""""""""" + +``QueryResult`` wraps paged results returned from ``QuerySessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``ResultsToken`` + - ``string?`` + - Token identifying the current page of results. Contains the first element and last element in page of results. e.g. '0,24' + * - ``HasPrevResults`` + - ``bool`` + - Specifies if there is a page of results before this page + * - ``HasNextResults`` + - ``bool`` + - Specifies if there is a page of results after this page + * - ``TotalCount`` + - ``int?`` + - Total sessions accross all pages of results + * - ``TotalPages`` + - ``int?`` + - Total number of pages for query result + * - ``CurrentPage`` + - ``int?`` + - Page numer of the current result collection + * - ``Results`` + - ``IReadOnlyCollection`` + - The collection of results for the current page + +UserSession +""""""""""" + +``UserSession`` represents a single server-side session record. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``SubjectId`` + - ``string`` + - The subject (user) identifier associated with the session. + * - ``SessionId`` + - ``string`` + - The unique identifier for the session + * - ``DisplayName`` + - ``string?`` + - An optional display name for the user + * - ``Created`` + - ``DateTime`` + - The date and time the session was created. + * - ``Renewed`` + - ``DateTime`` + - The date and time the session was last renewed + * - ``Expires`` + - ``DateTime?`` + - The date and time the session expires, if applicable + * - ``Issuer`` + - ``string?`` + - The issuer of the authentication ticket + * - ``Clients`` + - ``IEnumerable`` + - The clients associated with the session + * - ``AuthenticationTicket`` + - ``AuthenticationTicket?`` + - The user sessions suthentication ticket object + +RemoveSessionsContext +"""""""""""""""""""""" + +``RemoveSessionsContext`` controls which sessions are removed and what side effects occur when calling ``RemoveSessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``SubjectId`` + - ``string?`` + - Optional if SessionId has value, limits removal to a specific subject id + * - ``SessionId`` + - ``string?`` + - Optional if SubjectId has value, limits removal to a specific session id + * - ``ClientIds`` + - ``IEnumerable?`` + - Optionally limits removal to sessions for specific clients + * - ``RemoveServerSideSession`` + - ``bool`` + - Whether to trigger session entity removal from the database + * - ``SendBackchannelLogoutNotification`` + - ``bool`` + - Whether to trigger backchannel logout notifications for removed sessions + * - ``RevokeTokens`` + - ``bool`` + - Whether to trigger session token removal + * - ``RevokeConsents`` + - ``bool`` + - Whether to trigger session consent removal \ No newline at end of file diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 3b2ba7598..54c7e957a 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -5,7 +5,7 @@ 10.0.12 10.0.12 - 2.0.1-* + 3.0.0-* 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/Mappers/IdentityServerServerSideSessionsExtensions.cs b/src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs new file mode 100644 index 000000000..31e2ea4ae --- /dev/null +++ b/src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs @@ -0,0 +1,83 @@ +// 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 + +namespace Open.IdentityServer.EntityFramework.Mappers; + +/// +/// Mapping extension methods for IdentityServerServerSideSessions objects +/// +public static class IdentityServerServerSideSessionsExtensions +{ + /// + /// Mapping extension methods for + /// + /// The entity. + extension(Entities.IdentityServerServerSideSessions sessionEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public Models.IdentityServerServerSideSessions ToModel() + { + return new Models.IdentityServerServerSideSessions + { + Key = sessionEntity.Key, + Scheme = sessionEntity.Scheme, + SubjectId = sessionEntity.SubjectId, + SessionId = sessionEntity.SessionId, + DisplayName = sessionEntity.DisplayName, + Created = sessionEntity.Created, + Renewed = sessionEntity.Renewed, + Expires = sessionEntity.Expires, + Data = sessionEntity.Data + }; + } + } + + /// + /// Mapping extension methods for + /// + /// The model. + extension(Models.IdentityServerServerSideSessions sessionModel) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public Entities.IdentityServerServerSideSessions ToEntity() + { + return new Entities.IdentityServerServerSideSessions + { + Key = sessionModel.Key, + Scheme = sessionModel.Scheme, + SubjectId = sessionModel.SubjectId, + SessionId = sessionModel.SessionId, + DisplayName = sessionModel.DisplayName, + Created = sessionModel.Created, + Renewed = sessionModel.Renewed, + Expires = sessionModel.Expires, + Data = sessionModel.Data + }; + } + + /// + /// Updates with instance of + /// + /// The entity. + public void UpdateEntity(Entities.IdentityServerServerSideSessions existingEntity) + { + existingEntity.Key = sessionModel.Key; + existingEntity.Scheme = sessionModel.Scheme; + existingEntity.SubjectId = sessionModel.SubjectId; + existingEntity.SessionId = sessionModel.SessionId; + existingEntity.DisplayName = sessionModel.DisplayName; + existingEntity.Created = sessionModel.Created; + existingEntity.Renewed = sessionModel.Renewed; + existingEntity.Expires = sessionModel.Expires; + existingEntity.Data = sessionModel.Data; + } + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs b/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs index dafc5c456..59bd5095b 100644 --- a/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs +++ b/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs @@ -25,6 +25,14 @@ public class OperationalStoreOptions: StoreOptions /// public TableConfiguration DeviceFlowCodes { get; set; } = new TableConfiguration("DeviceCodes"); + /// + /// Gets or sets the server-side sessions table configuration. + /// + /// + /// The server-side sessions' config. + /// + public TableConfiguration ServerSideSessions { get; set; } = new("ServerSideSessions"); + /// /// Gets or sets a value indicating whether stale entries will be automatically cleaned up from the database. /// This is implemented by periodically connecting to the database (according to the TokenCleanupInterval) from the hosting application. @@ -61,14 +69,6 @@ public class OperationalStoreOptions: StoreOptions /// public TableConfiguration Keys { get; set; } = new("Keys"); - /// - /// Gets or sets the server-side sessions table configuration. - /// - /// - /// The server-side sessions' config. - /// - public TableConfiguration ServerSideSessions { get; set; } = new("ServerSideSessions"); - /// /// Gets or sets the pushed authorization requests table configuration. /// diff --git a/src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs new file mode 100644 index 000000000..560c1af71 --- /dev/null +++ b/src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs @@ -0,0 +1,280 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using IdentityServerServerSideSessions = Open.IdentityServer.Models.IdentityServerServerSideSessions; + +namespace Open.IdentityServer.EntityFramework.Stores; + +/// +/// Storage and retrieval of server-side sessions using entity framework core +/// +public class IdentityServerServerSideSessionStore( + IPersistedGrantDbContext dbContext, + ITelemetryService telemetry, + TimeProvider timeProvider, + ILogger logger) : IIdentityServerServerSideSessionStore +{ + /// + public async Task GetSession(string key) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + Entities.IdentityServerServerSideSessions? session = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == key); + + return session?.ToModel(); + } + + /// + public async Task CreateSession(IdentityServerServerSideSessions session) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing != null) + { + logger.LogError("failed storing '{SessionKey}' session in database, session with key already exists", + session.Key); + return; + } + + Entities.IdentityServerServerSideSessions sessionEntity = session.ToEntity(); + + await dbContext.ServerSideSessions.AddAsync(sessionEntity); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception storing '{SessionKey}' session in database", session.Key); + } + } + + /// + public async Task UpdateSession(IdentityServerServerSideSessions session) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing == null) + { + logger.LogError("failed updating '{SessionKey}' session in database, session not found", session.Key); + return; + } + + session.UpdateEntity(existing); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception updating '{SessionKey}' session in database", session.Key); + } + } + + /// + public async Task DeleteSession(string key) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == key); + + if (existing == null) + { + logger.LogError("failed deleting '{SessionKey}' session in database, session not found", key); + return; + } + + dbContext.ServerSideSessions.Remove(existing); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception deleting '{SessionKey}' session in database", key); + } + } + + /// + public async Task DeleteSessions(string? subjectId, string? sessionId) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException($"{nameof(subjectId)} or {nameof(sessionId)} must have a non null or empty value"); + } + + IQueryable filteredResults = ApplyFilter(new SessionQuery + { + SessionId = sessionId, SubjectId = subjectId, + }, dbContext.ServerSideSessions.AsQueryable()); + + dbContext.ServerSideSessions.RemoveRange(filteredResults); + await dbContext.SaveChangesAsync(); + } + + /// + public async Task> FilterSessions(string? subjectId, string? sessionId) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + IQueryable filteredResults = ApplyFilter(new SessionQuery + { + SessionId = sessionId, SubjectId = subjectId, + }, dbContext.ServerSideSessions.AsQueryable()); + + return (await filteredResults.ToListAsync()) + .Select(x => x.ToModel()); + } + + /// + public async Task> FilterSessions(SessionQuery? inputQuery, CancellationToken ct = default) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + SessionQuery query = inputQuery ?? new SessionQuery(); + + IQueryable filteredResults = ApplyFilter(query, dbContext.ServerSideSessions.AsQueryable()); + + int count = await filteredResults.CountAsync(cancellationToken: ct); + + if (count < 1) + { + return new QueryResult + { + TotalCount = count, TotalPages = 0, CurrentPage = 0, HasPrevResults = false, HasNextResults = false, + Results = [], + }; + } + + int totalPages = (count / query.CountRequested) + (count % query.CountRequested != 0 ? 1 : 0); + int currentPage = 1; + + if (!string.IsNullOrWhiteSpace(query.ResultsToken)) + { + (long tokenFirst, long tokenLast) = ParseResultsToken(query); + int elementsBeforeToken = await filteredResults.CountAsync(x => x.Id <= tokenFirst, cancellationToken: ct); + currentPage = 1 + (elementsBeforeToken / query.CountRequested); + + if (query.RequestPriorResults) + { + filteredResults = filteredResults + .Where(x => x.Id >= tokenFirst).Take(query.CountRequested); + } + else + { + currentPage++; + filteredResults = filteredResults + .Where(x => x.Id > tokenLast).Take(query.CountRequested); + } + } + else + { + filteredResults = filteredResults.Take(query.CountRequested); + } + + var results = filteredResults.ToList(); + + return new QueryResult + { + TotalCount = count, + TotalPages = totalPages, + CurrentPage = currentPage, + HasPrevResults = currentPage > 1, + HasNextResults = currentPage < totalPages, + ResultsToken = $"{results.First().Id},{results.Last().Id}", + Results = results.Select(x => x.ToModel()).ToList(), + }; + } + + private (long, long) ParseResultsToken(SessionQuery query) + { + long tokenFirst = 0; + long tokenLast = 0; + + if (query.ResultsToken != null) + { + var split = query.ResultsToken.Split(",", StringSplitOptions.RemoveEmptyEntries); + if (!long.TryParse(split.First(), out tokenFirst) || !long.TryParse(split.Last(), out tokenLast)) + { + logger.LogError("Error occured parsing result token"); + } + } + + return new ValueTuple(tokenFirst, tokenLast); + } + + private IQueryable ApplyFilter(SessionQuery query, + IQueryable input) + { + if (!string.IsNullOrWhiteSpace(query.SubjectId)) + { + input = input + .Where(x => x.SubjectId.Contains(query.SubjectId)); + } + + if (!string.IsNullOrWhiteSpace(query.SessionId)) + { + input = input.Where(x => x.SessionId != null && x.SessionId.Contains(query.SessionId)); + } + + if (!string.IsNullOrWhiteSpace(query.DisplayName)) + { + input = input.Where(x => x.DisplayName != null && x.DisplayName.Contains(query.DisplayName)); + } + + return input.OrderBy(x => x.Id); + } + + /// + public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + var sessions = await dbContext.ServerSideSessions + .Where(x => x.Expires < timeProvider.GetUtcNow().UtcDateTime) + .OrderBy(x => x.Expires) + .Take(batchSize) + .ToArrayAsync(); + + dbContext.ServerSideSessions.RemoveRange(sessions); + await dbContext.SaveChangesAsync(); + + return sessions.Select(x => x.ToModel()); + + } +} \ No newline at end of file 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/src/TokenCleanup/TokenCleanupService.cs b/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs index b44a8d8dc..3eb3b7ddf 100644 --- a/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs +++ b/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.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. - using System; using System.Collections.Generic; using System.Linq; @@ -15,7 +14,7 @@ namespace Open.IdentityServer.EntityFramework; /// -/// Helper to cleanup stale persisted grants and device codes. +/// Helper to clean up stale persisted grants and device codes. /// public class TokenCleanupService { diff --git a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs new file mode 100644 index 000000000..748c82b26 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs @@ -0,0 +1,52 @@ +// 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 Microsoft.Extensions.Logging; +using Moq; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests; + +public class MockLogger : ILogger +{ + private readonly ILogger _mock = Mock.Of>(); + + public static MockLogger Create() => new(); + + public IDisposable? BeginScope(TState state) where TState : notnull + => _mock.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) + => _mock.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _mock.Log(logLevel, eventId, state, exception, formatter); + + public void VerifyLog(LogLevel level, string message, Times? times = null) + { + Mock.Get(_mock) + .Verify( + x => x.Log( + level, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains(message)), + It.IsAny(), + It.IsAny>()), + times ?? Times.Once()); + } + + public void VerifyLog(LogLevel level, Times? times = null) + { + Mock.Get(_mock) + .Verify( + x => x.Log( + level, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + times ?? Times.Once()); + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj index 03be6b865..554498317 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj +++ b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -14,6 +14,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs new file mode 100644 index 000000000..d7b41db70 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -0,0 +1,1024 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using Moq; +using Open.IdentityServer.EntityFramework.DbContexts; +using Open.IdentityServer.EntityFramework.Options; +using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Test.Utilities; +using Xunit; +using IdentityServerServerSideSessions = Open.IdentityServer.EntityFramework.Entities.IdentityServerServerSideSessions; +using Range = System.Range; +using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibility; + +public class IdentityServerServerSideSessionStoreTests: IntegrationTest +{ + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly FakeTimeProvider timeProvider = new(); + private readonly MockLogger fakeLogger = new(); + + private static readonly DateTime FakeNow = new(2025, 02, 27, 12, 00, 00, DateTimeKind.Utc); + + public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture fixture) : base(fixture) + { + foreach (TheoryDataRow> row in TestDatabaseProviders) + { + using PersistedGrantDbContext context = new PersistedGrantDbContext(row.Data, StoreOptions); + context.Database.EnsureCreated(); + } + + timeProvider.SetUtcNow(FakeNow); + } + + private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext dbContext) => + new(dbContext, telemetry, timeProvider, fakeLogger); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + Func act = async () => await sut.GetSession(key); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetSession_WhenDoesntExist_ShouldReturnNull(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel result = await sut.GetSession("missing-key"); + + result.Should().BeNull(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetSession_WhenExist_ShouldReturnValue(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + string key = "session-key-1"; + IdentityServerServerSideSessions seeded = new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-1", + SessionId = "sid-1", + DisplayName = "display-1", + Created = FakeNow.AddMinutes(-10), + Renewed = FakeNow.AddMinutes(-5), + Expires = FakeNow.AddMinutes(30), + Data = "{\"foo\":\"bar\"}" + }; + + context.ServerSideSessions.Add(seeded); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel result = await sut.GetSession(key); + + result.Should().NotBeNull(); + result!.Key.Should().Be(seeded.Key); + result.Scheme.Should().Be(seeded.Scheme); + result.SubjectId.Should().Be(seeded.SubjectId); + result.SessionId.Should().Be(seeded.SessionId); + result.DisplayName.Should().Be(seeded.DisplayName); + result.Created.Should().Be(seeded.Created); + result.Renewed.Should().Be(seeded.Renewed); + result.Expires.Should().Be(seeded.Expires); + result.Data.Should().Be(seeded.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + Func act = async () => await sut.CreateSession(newSession); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task CreateSession_WhenSessionAlreadyExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + string key = "duplicate-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-existing", + SessionId = "sid-existing", + DisplayName = "existing", + Created = FakeNow.AddMinutes(-20), + Renewed = FakeNow.AddMinutes(-10), + Expires = FakeNow.AddMinutes(20), + Data = "{\"state\":\"existing\"}" + }); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + SessionModel newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + await sut.CreateSession(newSession); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task CreateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSessionInDatabase(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + string key = "new-key"; + SessionModel session = BuildSessionModel(key, "sub-123", "sid-123", "display-123"); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.CreateSession(session); + + IdentityServerServerSideSessions stored = await context.ServerSideSessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Should().NotBeNull(); + stored!.Key.Should().Be(session.Key); + stored.Scheme.Should().Be(session.Scheme); + stored.SubjectId.Should().Be(session.SubjectId); + stored.SessionId.Should().Be(session.SessionId); + stored.DisplayName.Should().Be(session.DisplayName); + stored.Created.Should().Be(session.Created); + stored.Renewed.Should().Be(session.Renewed); + stored.Expires.Should().Be(session.Expires); + stored.Data.Should().Be(session.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task UpdateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel session = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + Func act = async () => await sut.UpdateSession(session); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel session = BuildSessionModel("missing-update-key", "sub", "sid", "display"); + + await sut.UpdateSession(session); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSession(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + string key = "update-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "old-scheme", + SubjectId = "old-sub", + SessionId = "old-sid", + DisplayName = "old-display", + Created = FakeNow.AddHours(-2), + Renewed = FakeNow.AddHours(-1), + Expires = FakeNow.AddMinutes(5), + Data = "{\"version\":1}" + }); + await context.SaveChangesAsync(); + + SessionModel updated = BuildSessionModel(key, "new-sub", "new-sid", "new-display"); + updated.Scheme = "new-scheme"; + updated.Data = "{\"version\":2}"; + updated.Created = FakeNow.AddHours(-3); + updated.Renewed = FakeNow.AddMinutes(-1); + updated.Expires = FakeNow.AddHours(2); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.UpdateSession(updated); + + IdentityServerServerSideSessions stored = await context.ServerSideSessions + .AsNoTracking() + .SingleAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Key.Should().Be(updated.Key); + stored.Scheme.Should().Be(updated.Scheme); + stored.SubjectId.Should().Be(updated.SubjectId); + stored.SessionId.Should().Be(updated.SessionId); + stored.DisplayName.Should().Be(updated.DisplayName); + stored.Created.Should().Be(updated.Created); + stored.Renewed.Should().Be(updated.Renewed); + stored.Expires.Should().Be(updated.Expires); + stored.Data.Should().Be(updated.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + Func act = async () => await sut.DeleteSession(key); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSession("missing-delete-key"); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSession(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + string key = "delete-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-delete", + SessionId = "sid-delete", + DisplayName = "delete me", + Created = FakeNow.AddMinutes(-30), + Renewed = FakeNow.AddMinutes(-15), + Expires = FakeNow.AddMinutes(30), + Data = "{\"delete\":true}" + }); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSession(key); + + IdentityServerServerSideSessions stored = await context.ServerSideSessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Should().BeNull(); + } + + [Theory] + [InlineData(null, null)] + [InlineData(null, "")] + [InlineData(null, " ")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task DeleteSessions_WhenFiltersNullOrEmpty_ShouldThrowArgumentException(string? subjectId, string? sessionId) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + Func act = async () => await sut.DeleteSessions(subjectId, sessionId); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSubjectIdProvided_ShouldDeleteSessionsWithSubjectId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions("bob",null); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(3); + currentSessions.Should().Contain(x => x.Key == "key-1"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSessionIdProvided_ShouldDeleteSessionsWithSessionId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions(null, "session-1"); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(5); + currentSessions.Should().Contain(x => x.Key == "key-0"); + currentSessions.Should().Contain(x => x.Key == "key-2"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-4"); + currentSessions.Should().Contain(x => x.Key == "key-5"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSubjectIdAndSessionIdProvided_ShouldDeleteSessionsWithSubjectIdAndSessionId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions("bob", "session-2"); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(5); + currentSessions.Should().Contain(x => x.Key == "key-0"); + currentSessions.Should().Contain(x => x.Key == "key-1"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-4"); + currentSessions.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSubjectIdsSessionIsNull_ShouldReturnAll(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions(null, null)).ToList(); + + actual.Should().HaveCount(7); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-2"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-4"); + actual.Should().Contain(x => x.Key == "key-5"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionIdNull_ShouldReturnMatchingSubjectIds(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions("alice", null)).ToList(); + + actual.Should().HaveCount(3); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSubjectIdNull_ShouldReturnMatchingSessionIds(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions(null,"session-0")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-4"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions("alice", "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenNoExpiredSessionsExist_ShouldRemoveNothingAndReturnEmptyCollection(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions validSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession1 = FakeSessionSession("456", "session2"); + context.ServerSideSessions.Add(validSession0); + context.ServerSideSessions.Add(validSession1); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndUnderBatchSize_ShouldDeleteExpiredSessionsAndReturnACollectionContainingRemovedSessions(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(expiredSession1); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndExceedBatchSize_ShouldDeleteAndReturnExpiredSessions_WithACountOfBatchSize(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + IdentityServerServerSideSessions expiredSession2 = FakeSessionSession("789", "session3", true); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(expiredSession1); + context.ServerSideSessions.Add(expiredSession2); + context.ServerSideSessions.Add(validSession0); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + } + + //TODO: Finish implementing test + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenUnspecifiedTimezoneInDbEntities_ShouldBeTreatedAsUtc(DbContextOptions options) + { + //Ensuring timezone info is the same across environments + using var mockedTimezone = new LocalTimeZoneInfoMocker(TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")); + + DateTime nowUnspecified = new(2025, 02, 27, 12, 12, 11, DateTimeKind.Unspecified); + + var testExpired = nowUnspecified.AddDays(-1); + var testWithin8HoursToExpiry = nowUnspecified.AddHours(2); + + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + + expiredSession0.Expires = testExpired; + validSession0.Expires = testWithin8HoursToExpiry; + + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(validSession0); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(1); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNoResults_ShouldEmptyResultsSet(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.TotalPages.Should().Be(0); + actual.ResultsToken.Should().BeNullOrWhiteSpace(); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNullQuery_ShouldUseDefaultValues(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(7); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNoTokenInQuery_ShouldGetFirstPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousFalse_ShouldGetNextPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(4).Take(2).ToList(); + var testToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = false, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(6).Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(4); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(1); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousTrue_ShouldGetNextPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(4).Take(2).ToList(); + var testToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = true, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(3); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(testToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenSessionIdProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SessionId = "session-0", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.SessionId == "session-0") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(2); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenSubjectIdProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SubjectId = "bob", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.SubjectId == "bob") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(4); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenDisplayNameProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + DisplayName = "Laura", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.DisplayName == "Laura") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(3); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) + { + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.CreateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "CreateSession"), + (store => store.GetSession("FAKE_SESSION_KEY"), "GetSession"), + (store => store.UpdateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "UpdateSession"), + (store => store.DeleteSession("FAKE_SESSION_KEY"), "DeleteSession"), + (store => store.DeleteSessions("FAKE_SESSION_KEY", "FAKE_SUBJECT_KEY"), "DeleteSessions"), + (store => store.FilterSessions("FAKE_SUBJECT_KEY", "FAKE_SESSION_KEY"), "FilterSessions"), + (store => store.FilterSessions(new SessionQuery()), "FilterSessions"), + (store => store.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), + ]; + + foreach ((Func actMethod, string traceMethodName) method in methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await using PersistedGrantDbContext context = new PersistedGrantDbContext(options, StoreOptions); + + IdentityServerServerSideSessionStore store = CreateSut(context); + + await method.actMethod(store); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Stores, store, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(IdentityServerServerSideSessionStore).GetMethods() + .Where(m => m.IsPublic && !m.IsStatic && !m.IsSpecialName) + .Where(m => m.DeclaringType == typeof(IdentityServerServerSideSessionStore)) + .Select(m => m.Name) + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + + private async Task CreateCleanContext(DbContextOptions options) + { + PersistedGrantDbContext context = new PersistedGrantDbContext(options, StoreOptions); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + return context; + } + + private static SessionModel BuildSessionModel(string key, string subjectId, string sessionId, string displayName) + { + return new SessionModel + { + Key = key, + Scheme = "cookie", + SubjectId = subjectId, + SessionId = sessionId, + DisplayName = displayName, + Created = FakeNow.AddMinutes(-10), + Renewed = FakeNow.AddMinutes(-5), + Expires = FakeNow.AddHours(1), + Data = "{\"payload\":\"value\"}" + }; + } + + private static IdentityServerServerSideSessions FakeSessionSession(string subject, string sessionId, bool expired = false) + { + IdentityServerServerSideSessions session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = FakeNow.AddDays(-3), + Renewed = FakeNow.AddDays(-3), + Expires = FakeNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = FakeNow.AddDays(-5); + session.Renewed = FakeNow.AddDays(-4); + session.Expires = FakeNow.AddDays(-3); + } + + return session; + } +} \ No newline at end of file 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/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs index cfcc3bb6f..12a40db55 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs @@ -13,7 +13,6 @@ using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; using Open.IdentityServer.Stores; -using Open.IdentityServer.Test; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -44,15 +43,15 @@ public async Task RemoveExpiredGrantsAsync_WhenExpiredGrantsExist_ExpectExpiredG Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.Add(expiredGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.FirstOrDefault(x => x.Key == expiredGrant.Key).Should().BeNull(); } @@ -71,15 +70,15 @@ public async Task RemoveExpiredGrantsAsync_WhenValidGrantsExist_ExpectValidGrant Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.Add(validGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.FirstOrDefault(x => x.Key == validGrant.Key).Should().NotBeNull(); } @@ -99,15 +98,15 @@ public async Task RemoveExpiredGrantsAsync_WhenExpiredDeviceGrantsExist_ExpectEx Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.Add(expiredGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.FirstOrDefault(x => x.DeviceCode == expiredGrant.DeviceCode).Should().BeNull(); } @@ -127,15 +126,15 @@ public async Task RemoveExpiredGrantsAsync_WhenValidDeviceGrantsExist_ExpectVali Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.Add(validGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.FirstOrDefault(x => x.DeviceCode == validGrant.DeviceCode).Should().NotBeNull(); } @@ -243,11 +242,11 @@ public async Task RemoveExpiredGrantsAsync_WhenValidPARRequestsExist_ExpectValid } } - private EntityFramework.TokenCleanupService CreateSut(DbContextOptions options) + private TokenCleanupService CreateSut(DbContextOptions options) { IServiceCollection services = new ServiceCollection(); services.AddIdentityServer() - .AddTestUsers(new List()) + .AddTestUsers([]) .AddInMemoryClients(new List()) .AddInMemoryIdentityResources(new List()) .AddInMemoryApiResources(new List()); @@ -257,13 +256,9 @@ private EntityFramework.TokenCleanupService CreateSut(DbContextOptions(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddSingleton(StoreOptions); - return services.BuildServiceProvider().GetRequiredService(); - //return new EntityFramework.TokenCleanupService( - // services.BuildServiceProvider(), - // new NullLogger(), - // StoreOptions); + return services.BuildServiceProvider().GetRequiredService(); } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs new file mode 100644 index 000000000..71e464a48 --- /dev/null +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs @@ -0,0 +1,56 @@ +// Copyright (c) 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 AwesomeAssertions; +using Open.IdentityServer.EntityFramework.Entities; +using Open.IdentityServer.EntityFramework.Mappers; +using Xunit; + +namespace Open.IdentityServer.EntityFramework.UnitTests.Mappers; + +public class IdentityServerServerSideSessionsExtensionsTests +{ + [Fact] + public void EntityIdentityServerServerSideSessions_ToModel_ProducesModelWithCorrectFields() + { + IdentityServerServerSideSessions entity = new IdentityServerServerSideSessions + { + Id = 1, + Key = Guid.NewGuid().ToString(), + Scheme = "FakeScheme", + SubjectId = "fake-subject", + SessionId = "fake-session", + DisplayName = "Fake Session for User", + Created = new DateTime(2020, 01, 01, 12, 20, 0, DateTimeKind.Utc), + Renewed = new DateTime(2020, 02, 01, 12, 20, 0, DateTimeKind.Utc), + Expires = new DateTime(2020, 03, 01, 12, 20, 0, DateTimeKind.Utc), + Data = "FAKEPROTECTEDDATA" + }; + + Models.IdentityServerServerSideSessions actual = entity.ToModel(); + + entity.Should().BeEquivalentTo(actual); + } + + [Fact] + public void ModelIdentityServerServerSideSessions_ToEntity_ProducesEntityWithCorrectFields() + { + Models.IdentityServerServerSideSessions model = new Models.IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = "FakeScheme", + SubjectId = "fake-subject", + SessionId = "fake-session", + DisplayName = "Fake Session for User", + Created = new DateTime(2020, 01, 01, 12, 20, 0, DateTimeKind.Utc), + Renewed = new DateTime(2020, 02, 01, 12, 20, 0, DateTimeKind.Utc), + Expires = new DateTime(2020, 03, 01, 12, 20, 0, DateTimeKind.Utc), + Data = "FAKEPROTECTEDDATA" + }; + + IdentityServerServerSideSessions actual = model.ToEntity(); + + model.Should().BeEquivalentTo(actual, cnf => cnf.Excluding(x => x.Id)); + } +} \ No newline at end of file diff --git a/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs b/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs index ebe2d6a75..fa6efefdb 100644 --- a/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs +++ b/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs @@ -103,6 +103,7 @@ public static IIdentityServerBuilder AddOperationalStore( builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/src/EntityFramework/src/TokenCleanupHost.cs b/src/EntityFramework/src/TokenCleanupHost.cs index 736511d5a..ab89203f9 100644 --- a/src/EntityFramework/src/TokenCleanupHost.cs +++ b/src/EntityFramework/src/TokenCleanupHost.cs @@ -1,7 +1,6 @@ // 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 Microsoft.Extensions.Hosting; using System.Threading; using System.Threading.Tasks; @@ -51,7 +50,7 @@ public Task StartAsync(CancellationToken cancellationToken) _source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task.Factory.StartNew(() => StartInternalAsync(_source.Token)); + Task.Factory.StartNew(() => StartInternalAsync(_source.Token), cancellationToken); } return Task.CompletedTask; @@ -81,7 +80,7 @@ private async Task StartInternalAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { - _logger.LogDebug("CancellationRequested. Exiting."); + _logger.LogDebug("CancellationRequested. Exiting"); break; } @@ -89,20 +88,20 @@ private async Task StartInternalAsync(CancellationToken cancellationToken) { await Task.Delay(CleanupInterval, cancellationToken); } - catch (TaskCanceledException) + catch (TaskCanceledException ex) { - _logger.LogDebug("TaskCanceledException. Exiting."); + _logger.LogDebug(ex, "TaskCanceledException. Exiting"); break; } catch (Exception ex) { - _logger.LogError("Task.Delay exception: {0}. Exiting.", ex.Message); + _logger.LogError(ex, "Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); break; } if (cancellationToken.IsCancellationRequested) { - _logger.LogDebug("CancellationRequested. Exiting."); + _logger.LogDebug("CancellationRequested. Exiting"); break; } @@ -114,15 +113,13 @@ async Task RemoveExpiredGrantsAsync() { try { - using (var serviceScope = _serviceProvider.GetRequiredService().CreateScope()) - { - var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); - await tokenCleanupService.RemoveExpiredGrantsAsync(); - } + using var serviceScope = _serviceProvider.GetRequiredService().CreateScope(); + var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); + await tokenCleanupService.RemoveExpiredGrantsAsync(); } catch (Exception ex) { - _logger.LogError("Exception removing expired grants: {exception}", ex.Message); + _logger.LogError(ex, "Exception removing expired grants: {ExceptionMsg}", ex.Message); } } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs b/src/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs similarity index 91% rename from src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs rename to src/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs index b881e3cb9..e2865a037 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs +++ b/src/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs @@ -1,7 +1,6 @@ -using System; using System.Reflection; -namespace Open.IdentityServer.UnitTests; +namespace Open.IdentityServer.Test.Utilities; public class LocalTimeZoneInfoMocker: IDisposable { diff --git a/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj new file mode 100644 index 000000000..237d66167 --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Open.IdentityServer.Test.Utilities/build.cmd b/src/Open.IdentityServer.Test.Utilities/build.cmd new file mode 100644 index 000000000..e4cf78f4a --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.cmd @@ -0,0 +1,2 @@ +@echo off +dotnet run --project build -- %* \ No newline at end of file diff --git a/src/Open.IdentityServer.Test.Utilities/build.ps1 b/src/Open.IdentityServer.Test.Utilities/build.ps1 new file mode 100644 index 000000000..8c950bdae --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.ps1 @@ -0,0 +1,2 @@ +$ErrorActionPreference = "Stop"; +dotnet run --project build -- $args \ No newline at end of file diff --git a/src/Open.IdentityServer.Test.Utilities/build.sh b/src/Open.IdentityServer.Test.Utilities/build.sh new file mode 100644 index 000000000..bb8ef5b20 --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +dotnet run --project build -- "$@" \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 425504456..673c8732a 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -10,9 +10,14 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using System; using System.Net.Http; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Hosting; using Open.IdentityServer; using Open.IdentityServer.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services.Default; namespace Microsoft.Extensions.DependencyInjection; @@ -460,4 +465,31 @@ public static IIdentityServerBuilder AddUserSession(this IIdentityServerBuild return builder; } + + /// + /// Adds server-side session storage to Open.IdentityServer + /// + /// The builder + /// The same instance so that additional calls can be chained + public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerBuilder builder) + { + builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); + builder.Services.AddScoped(); + + // Token Validators + builder.Services.AddTransientDecorator(); + builder.Services.AddTransientDecorator(); + + // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) + builder.Services.TryAddSingleton(); + + //Clean-up Service + builder.Services.AddTransient(); + builder.Services.AddSingleton(); + + //Add Management Service + builder.Services.TryAddScoped(); + + return builder; + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 9d46b7631..bf2d76f93 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/ConfigureInternalCookieOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs index a631df740..8d229501f 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs @@ -1,4 +1,5 @@ // 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. @@ -6,18 +7,13 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using CookieAuthenticationEvents = Open.IdentityServer.Events.CookieAuthenticationEvents; namespace Open.IdentityServer.Configuration; -internal class ConfigureInternalCookieOptions : IConfigureNamedOptions +internal class ConfigureInternalCookieOptions(IdentityServerOptions idsrv) + : IConfigureNamedOptions { - private readonly IdentityServerOptions _idsrv; - - public ConfigureInternalCookieOptions(IdentityServerOptions idsrv) - { - _idsrv = idsrv; - } - public void Configure(CookieAuthenticationOptions options) { } @@ -26,18 +22,22 @@ public void Configure(string name, CookieAuthenticationOptions options) { if (name == IdentityServerConstants.DefaultCookieAuthenticationScheme) { - options.SlidingExpiration = _idsrv.Authentication.CookieSlidingExpiration; - options.ExpireTimeSpan = _idsrv.Authentication.CookieLifetime; + options.SlidingExpiration = idsrv.Authentication.CookieSlidingExpiration; + options.ExpireTimeSpan = idsrv.Authentication.CookieLifetime; options.Cookie.Name = IdentityServerConstants.DefaultCookieAuthenticationScheme; options.Cookie.IsEssential = true; - options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode; + options.Cookie.SameSite = idsrv.Authentication.CookieSameSiteMode; - options.LoginPath = ExtractLocalUrl(_idsrv.UserInteraction.LoginUrl); - options.LogoutPath = ExtractLocalUrl(_idsrv.UserInteraction.LogoutUrl); - if (_idsrv.UserInteraction.LoginReturnUrlParameter != null) + options.LoginPath = ExtractLocalUrl(idsrv.UserInteraction.LoginUrl); + options.LogoutPath = ExtractLocalUrl(idsrv.UserInteraction.LogoutUrl); + if (idsrv.UserInteraction.LoginReturnUrlParameter != null) { - options.ReturnUrlParameter = _idsrv.UserInteraction.LoginReturnUrlParameter; + options.ReturnUrlParameter = idsrv.UserInteraction.LoginReturnUrlParameter; } + + var originalOnCheckSlidingExpiration = options.Events.OnCheckSlidingExpiration; + options.Events.OnCheckSlidingExpiration = context => CookieAuthenticationEvents + .ServerSessionOnCheckSlidingExpiration(context, originalOnCheckSlidingExpiration); } if (name == IdentityServerConstants.ExternalCookieAuthenticationScheme) @@ -50,7 +50,7 @@ public void Configure(string name, CookieAuthenticationOptions options) // so we need to make those cookies issued without same-site, thus the browser will // hold onto them and send on the next redirect to the callback page. // see: https://brockallen.com/2019/01/11/same-site-cookies-asp-net-core-and-external-authentication-providers/ - options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode; + options.Cookie.SameSite = idsrv.Authentication.CookieSameSiteMode; } } 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 df4cffa4a..b1a3693f5 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,37 +109,42 @@ 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(); /// /// PAR authorization options 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..bcbf595af --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -0,0 +1,63 @@ +// 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; + +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; } + + /// + /// Specifies if expired sessions should be cleaned up automatically by Open.IdentityServer + /// + /// + /// The default value is true + /// + public bool RemoveExpiredSessions { get; set; } = true; + + /// + /// Specifies the frequency with which expired sessions are looked for and removed + /// + /// + /// The default value is a TimeSpan of 10 minutes + /// + public TimeSpan RemoveExpiredSessionsFrequency { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Specifies if the start time of the hosted service should be randomised to avoid limiting the occurrences of jobs + /// running simultaneously in scenarios with multiple instances of Open.IdentityServer are running. + /// + /// + /// The default value is true + /// + public bool FuzzExpiredSessionsFrequency { get; set; } = true; + + /// + /// Specifies how many expired sessions should be removed in a single pass + /// + /// + /// The default value is 100 + /// + public int RemoveExpiredSessionsBatchSize { get; set; } = 100; + + /// + /// The claim used to set a session's display name value + /// + /// + /// The default value is null + /// + public string? UserDisplayNameClaimType { get; set; } = null; + +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs new file mode 100644 index 000000000..b8301b091 --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Configuration; + +/// +/// IPostConfigureOptions implementation for . Registers the +/// implementation to use for storing auth tickets. +/// +/// instance of ITicketStore to use +/// Open.IdentityServer options +/// Authentication options +public class PostConfigureSessionStoreCookieAuthOptions( + IServerSessionTicketStore ticketStore, + IdentityServerOptions idsOptions, + IOptions authOptions): IPostConfigureOptions +{ + /// + /// Implementation of post configure setting SessionStore parameter if + /// name provided matches scheme + /// + /// name of the scheme + /// cookie authentication options + public void PostConfigure(string name, CookieAuthenticationOptions options) + { + var scheme = idsOptions.Authentication.CookieAuthenticationScheme ?? + authOptions.Value.DefaultAuthenticateScheme ?? + authOptions.Value.DefaultScheme; + + if (scheme == name) + { + options.SessionStore = ticketStore; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs index 38b30e15d..c31a65ae9 100644 --- a/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs @@ -24,9 +24,7 @@ public class DataProtectedGrantData public bool DataProtected { get; set; } /// - /// when has been protected via - /// IDataProtector.Protect and must be unprotected before deserialization; - /// when the payload is the raw serialized grant. + /// Payload of the grant data, either raw JSON or protected string /// public string Payload { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs new file mode 100644 index 000000000..33be7ce66 --- /dev/null +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs @@ -0,0 +1,20 @@ +namespace Open.IdentityServer.DataProtection; + +/// +/// Envelope used by +/// to wrap a serialized server session payload together with the metadata needed +/// to determine the version of the envelope. +/// +public class DataProtectedSessionData +{ + /// + /// Schema version of this envelope. + /// Incremented when the shape of the payload changes. + /// + public int Version { get; set; } = 1; + + /// + /// Payload of the grant data, either raw JSON or protected string + /// + public string Payload { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/DataProtectionConstants.cs b/src/Open.IdentityServer/src/DataProtectionConstants.cs index 9e0485dcf..01496c851 100644 --- a/src/Open.IdentityServer/src/DataProtectionConstants.cs +++ b/src/Open.IdentityServer/src/DataProtectionConstants.cs @@ -10,4 +10,7 @@ public static class DataProtectionConstants { /// Purpose used when creating key material data protector. public const string KeyProtectorPurpose = "DataProtectionKeyProtector"; + + /// Purpose used when creating server side ticket store data protector. + public const string ServerSideTicketStorePurpose = "Duende.SessionManagement.ServerSideTicketStore"; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs b/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs new file mode 100644 index 000000000..327523552 --- /dev/null +++ b/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs @@ -0,0 +1,33 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Open.IdentityServer.Events; + +/// +/// A class containing cookie authentication handler events +/// +public static class CookieAuthenticationEvents +{ + /// + /// Server-side sessions cookie on check sliding expiration + /// + public static readonly Func, Task> ServerSessionOnCheckSlidingExpiration = (ctx, original) => + { + original.Invoke(ctx); + + if (ctx.Properties.GetString(IdentityServerConstants.ForceCookieRefresh) != null && + (ctx.Properties.ExpiresUtc == null || TimeProvider.System.GetUtcNow() < ctx.Properties.ExpiresUtc)) + { + ctx.ShouldRenew = true; + ctx.Properties.SetString(IdentityServerConstants.ForceCookieRefresh, null); + } + + return Task.CompletedTask; + }; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs new file mode 100644 index 000000000..6e358d6bb --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs @@ -0,0 +1,49 @@ +// Copyright (c) 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 System.Linq; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Extensions; + +internal static class AuthenticationTicketExtensions +{ + extension(AuthenticationTicket authenticationTicket) + { + public SerializedAuthenticationTicket ToSerializableObj() + { + return new SerializedAuthenticationTicket + { + Scheme = authenticationTicket.AuthenticationScheme, + User = authenticationTicket.Principal.ToSerializableObj(), + Items = authenticationTicket.Properties.Items, + }; + } + } + + extension(SerializedAuthenticationTicket serializationAuthTicket) + { + public AuthenticationTicket ToAuthTicket(IdentityServerServerSideSessions? session) + { + var authTicket = new AuthenticationTicket( + serializationAuthTicket.User.ToClaimsPrincipal(), + new AuthenticationProperties(serializationAuthTicket.Items), + serializationAuthTicket.Scheme); + + if (session != null) + { + authTicket.Properties.IssuedUtc = new DateTimeOffset(session.Renewed, TimeSpan.Zero); + if (session.Expires.HasValue) + { + authTicket.Properties.ExpiresUtc = new DateTimeOffset(session.Expires.Value, TimeSpan.Zero); + } + } + + return authTicket; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs index 23604f338..cb0e08c5f 100644 --- a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs @@ -1,12 +1,13 @@ // 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 System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json; +using Open.IdentityServer.Stores.Serialization; namespace Open.IdentityServer.Extensions; @@ -79,11 +80,24 @@ private static object GetValue(Claim claim) { try { - return System.Text.Json.JsonSerializer.Deserialize(claim.Value); + return JsonSerializer.Deserialize(claim.Value); } catch { } } return claim.Value; } + + public static ClaimLite[] ToSerializableObj(this IEnumerable claims) + { + return claims.Select(x => new ClaimLite + { + Type = x.Type, Value = x.Value, ValueType = x.ValueType, Issuer = x.Issuer, + }).ToArray(); + } + + public static Claim[] ToClaims(this ClaimLite[] claims) + { + return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer)).ToArray(); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs new file mode 100644 index 000000000..514db0d41 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs @@ -0,0 +1,36 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Security.Claims; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Extensions; + +internal static class ClaimsPrincipleExtension +{ + extension(ClaimsPrincipal claimsPrincipal) + { + public ClaimsPrincipalLite ToSerializableObj() + { + return new ClaimsPrincipalLite + { + AuthenticationType = claimsPrincipal.Identity!.AuthenticationType!, + Claims = claimsPrincipal.Claims.ToSerializableObj(), + }; + } + } + + extension(ClaimsPrincipalLite claimsPrincipalLite) + { + public ClaimsPrincipal ToClaimsPrincipal() + { + ClaimsIdentity identity = new ClaimsIdentity( + claimsPrincipalLite.Claims.ToClaims(), + claimsPrincipalLite.AuthenticationType, + JwtClaimTypes.Name, + JwtClaimTypes.Role); + + return new ClaimsPrincipal(identity); + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs b/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs new file mode 100644 index 000000000..d9c05e059 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Extensions.Mapping; + +/// +/// mapping extension methods +/// +public static class AuthenticationTicketFilterResultMappingExtensions +{ + extension(AuthenticationTicketFilterResult result) + { + /// + /// Maps object to an instance of the model + /// + /// new object + public UserSession ToUserSession() + { + string? issuer = null; + + result.AuthTicket?.Properties.Items.TryGetValue(JwtClaimTypes.Issuer, out issuer); + + return new UserSession + { + SubjectId = result.Session.SubjectId, + SessionId = result.Session.SessionId, + DisplayName = result.Session.DisplayName, + Created = result.Session.Created, + Renewed = result.Session.Renewed, + Expires = result.Session.Expires, + Issuer = issuer, + ClientIds = result.AuthTicket?.Properties.GetClientList().ToList() ?? [], + AuthenticationTicket = result.AuthTicket, + }; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs new file mode 100644 index 000000000..49c5f274a --- /dev/null +++ b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer; + +/// +/// A service for running server-side session clean-up periodically +/// +public class SessionCleanupHostedService( + IServiceProvider serviceProvider, + IdentityServerOptions options, + ILogger logger): IHostedService +{ + private CancellationTokenSource source; + + /// + /// Starts the hosted service if configured to be enabled + /// + /// + /// + public Task StartAsync(CancellationToken cancellationToken) + { + if (source != null) throw new InvalidOperationException("Already started. Call Stop first."); + + logger.LogDebug("Starting expired session removal"); + + source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task.Factory.StartNew(() => StartInternalAsync(source.Token), cancellationToken); + + return Task.CompletedTask; + } + + /// + /// Stops the hosted service if started + /// + /// + /// + public Task StopAsync(CancellationToken cancellationToken) + { + if (source == null) throw new InvalidOperationException("Not started. Call Start first."); + + logger.LogDebug("Stopping expired session removal"); + + source.Cancel(); + source = null; + + return Task.CompletedTask; + } + + private async Task StartInternalAsync(CancellationToken cancellationToken) + { + if (options.ServerSideSessions.FuzzExpiredSessionsFrequency) + { + Random rnd = new Random(); + int seconds = rnd.Next(1, 120); + logger.LogDebug("Fuzzing session cleanup service start time by {Seconds}", seconds); + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + } + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + logger.LogDebug("CancellationRequested. Exiting"); + break; + } + + try + { + await Task.Delay(options.ServerSideSessions.RemoveExpiredSessionsFrequency, cancellationToken); + } + catch (TaskCanceledException ex) + { + logger.LogDebug(ex, "TaskCanceledException. Exiting"); + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); + break; + } + + if (cancellationToken.IsCancellationRequested) + { + logger.LogDebug("CancellationRequested. Exiting"); + break; + } + + if (options.ServerSideSessions.RemoveExpiredSessions) + { + await RemoveExpiredSessions(); + } + else + { + logger.LogDebug("Expired session removal disabled"); + } + } + } + + private async Task RemoveExpiredSessions() + { + try + { + using var serviceScope = serviceProvider.GetRequiredService().CreateScope(); + var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); + await tokenCleanupService.RemoveExpiredServerSideSessionsAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception removing expired sessions: {ExceptionMsg}", ex.Message); + } + } +} \ 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..776e54b83 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 EndUserSessionEventContext + { + 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 3bbb6dd05..ca7ede7ec 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -1,4 +1,5 @@ // 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. #pragma warning disable 1591 @@ -16,6 +17,7 @@ public static class IdentityServerConstants public const string ExternalCookieAuthenticationScheme = "idsrv.external"; public const string DefaultCheckSessionCookieName = "idsrv.session"; public const string AccessTokenAudience = "{0}resources"; + public const string ForceCookieRefresh = "Open.IdentityServer.ForceCookieRefresh"; public static class PushedAuthorizationRequest { @@ -160,6 +162,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/AuthenticationTicketFilterResult.cs b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs new file mode 100644 index 000000000..1a16be0c1 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs @@ -0,0 +1,27 @@ +// 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.Linq; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Models; + +/// +/// Container for the session model and deserialized auth ticket from the FilterServerAuthenticationTickets method +/// +public class AuthenticationTicketFilterResult +{ + /// + /// Session model returned from filtering + /// + public IdentityServerServerSideSessions Session { get; set; } = null!; + + /// + /// AuthenticationTicket deserialized from the data property on the session entity + /// + public AuthenticationTicket? AuthTicket { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs new file mode 100644 index 000000000..94261f03c --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs @@ -0,0 +1,12 @@ +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to handle end of user session events +/// +public class EndUserSessionEventContext: UserSessionEventContext +{ + /// + /// Collection of ClientIds active within the session. + /// + public string[] ClientIds { get; set; } = []; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs new file mode 100644 index 000000000..2ab72957b --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs @@ -0,0 +1,54 @@ +// 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.Collections.Generic; + +namespace Open.IdentityServer.Models; + +/// +/// Remove sessions context +/// +public class RemoveSessionsContext +{ + /// + /// Optional subject ID of sessions that should be removed + /// + public string? SubjectId { get; init; } + + /// + /// Optional session ID of the sessions that should be removed + /// + public string? SessionId { get; init; } + + /// + /// Specifies which clients should have their consents and tokens revoked. If null or empty, all clients will have + /// consents and tokens revoked + /// + public IReadOnlyCollection? ClientIds { get; set; } + + /// + /// Specifies if the server-side session should be removed + /// + /// default value is true + public bool RemoveServerSideSession { get; set; } = true; + + /// + /// Specifies if back-channel logout notifications should be sent + /// + /// default value is true + public bool SendBackchannelLogoutNotification { get; set; } = true; + + /// + /// Specifies if tokens should be revoked for a client + /// + /// default value is true + public bool RevokeTokens { get; set; } = true; + + /// + /// Specifies if consents should be revoked for a client + /// + /// default value is true + public bool RevokeConsents { get; set; } = true; +} \ No newline at end of file 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..276395175 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -0,0 +1,20 @@ +// 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 of the session for which the event has been triggered. + /// + public string SubjectId { get; set; } + + /// + /// Session identifier for the event that has been triggered. + /// + public string SessionId { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs new file mode 100644 index 000000000..53c323c22 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs @@ -0,0 +1,12 @@ +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to validate user session events +/// +public class ValidateUserSessionEventContext: UserSessionEventContext +{ + /// + /// Client of the user session the event has been triggered for + /// + public Client Client { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/UserSession.cs b/src/Open.IdentityServer/src/Models/UserSession.cs new file mode 100644 index 000000000..a5cdf14dd --- /dev/null +++ b/src/Open.IdentityServer/src/Models/UserSession.cs @@ -0,0 +1,61 @@ +// 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 Microsoft.AspNetCore.Authentication; + +namespace Open.IdentityServer.Models; + +/// +/// User session model +/// +public class UserSession +{ + /// + /// Subject ID for the user session + /// + public string SubjectId { get; set; } = null!; + + /// + /// Session ID for the user session + /// + public string SessionId { get; set; } = null!; + + /// + /// Display name for the user session + /// + public string? DisplayName { get; set; } + + /// + /// Date and time the session was created + /// + public DateTime Created { get; set; } + + /// + /// Date and time the session was renewed + /// + public DateTime Renewed { get; set; } + + /// + /// Date and time the session expires, null if no expiry + /// + public DateTime? Expires { get; set; } + + /// + /// Issuer of the auth ticket + /// + public string? Issuer { get; set; } + + /// + /// Client IDs of clients with active grants and tokens from the session + /// + public IReadOnlyCollection ClientIds { get; set; } = null!; + + /// + /// Authentication ticket object for the user session + /// + public AuthenticationTicket? AuthenticationTicket { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs new file mode 100644 index 000000000..c47c2f1b4 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -0,0 +1,97 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Extensions.Mapping; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services; + +/// +/// Default Session management service, has methods for querying sessions and removing them. +/// +/// persisted grant store +/// back channel logout service +/// auth ticket store +/// server session store +/// telemetry service +public class DefaultSessionManagementService( + IPersistedGrantStore persistedGrantStore, + IBackChannelLogoutService backChannelLogoutService, + IServerSessionTicketStore serverSessionTicketStore, + IIdentityServerServerSideSessionStore serverSessionStore, + ITelemetryService telemetry): ISessionManagementService +{ + /// + public async Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + QueryResult results = await serverSessionTicketStore.FilterServerAuthenticationTickets(filter, ct); + + return results.MapTo(x => x.ToUserSession()); + } + + /// + public async Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + if (context.SendBackchannelLogoutNotification) + { + var sessions = await serverSessionTicketStore.FilterServerAuthenticationTickets(context.SubjectId, context.SessionId); + foreach (var session in sessions) + { + List? sessionClientList = session.AuthTicket?.Properties.GetClientList().ToList(); + string[] clientIds = []; + + if (!sessionClientList.IsNullOrEmpty() && !context.ClientIds.IsNullOrEmpty()) + { + clientIds = sessionClientList!.Where(x => context.ClientIds!.Contains(x)).ToArray(); + } + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = session.Session.SubjectId, + SessionId = session.Session.SessionId, + ClientIds = clientIds, + }); + } + } + + if (context.RevokeTokens || context.RevokeConsents) + { + List typeFilter = []; + + if (context.RevokeTokens) + { + typeFilter.AddRange(IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes); + } + + if (context.RevokeConsents) + { + typeFilter.Add(IdentityServerConstants.PersistedGrantTypes.UserConsent); + } + + await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter + { + SubjectId = context.SubjectId, + SessionId = context.SessionId, + ClientIds = context.ClientIds?.ToArray() ?? [], + Types = typeFilter.ToArray(), + }); + } + + if (context.RemoveServerSideSession) + { + await serverSessionStore.DeleteSessions(context.SubjectId, context.SessionId); + } + } +} \ 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..d5fe669e5 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -0,0 +1,179 @@ +// 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.DependencyInjection; +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 +/// service provider +/// IdentityServer options +/// telemetry service +/// time provider +/// logger +public class DefaultUserSessionEventsService( + IClientStore clientStore, + IPersistedGrantStore persistedGrantStore, + IBackChannelLogoutService backChannelLogoutService, + IServiceProvider serviceProvider, + IdentityServerOptions idsOptions, + ITelemetryService telemetry, + TimeProvider timeProvider, + ILogger logger) : IUserSessionEventsService +{ + /// + public async Task HandleUserSessionLogout(EndUserSessionEventContext sessionEventContext) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + 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(EndUserSessionEventContext sessionEventContext) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + string[]? clientToNotify = await EndSessionForClients(sessionEventContext); + + List 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, + }); + } + + /// + public async Task ValidateSession(ValidateUserSessionEventContext sessionEventContext) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + IServerSessionTicketStore? serverSessionTicketStore = serviceProvider.GetService(); + IIdentityServerServerSideSessionStore? identityServerServerSideSessionStore = serviceProvider.GetService(); + + // Checking if server-side sessions have been configured by the presence of registered server-side session stores + // also checking if client lifetime coordination has been enabled. If any of these conditions are not met, then + // the validate session methods should exit early without validating. + if (serverSessionTicketStore == null || identityServerServerSideSessionStore == null || + !ShouldCoordinateLifetimes(sessionEventContext.Client)) + { + return true; + } + + List sessions = + (await serverSessionTicketStore.FilterServerAuthenticationTickets(sessionEventContext.SubjectId, + sessionEventContext.SessionId)) + .ToList(); + + if (sessions.Count == 0 || sessions.All(x => x.Session.Expires.HasValue && + x.Session.Expires < timeProvider.GetUtcNow())) + { + logger.LogDebug(""); + return false; + } + + foreach (AuthenticationTicketFilterResult session in sessions) + { + TimeSpan? diff = session.Session.Expires - session.Session.Renewed; + session.Session.Renewed = timeProvider.GetUtcNow().UtcDateTime; + session.Session.Expires = session.Session.Renewed + diff; + + if (idsOptions.Authentication.CookieSlidingExpiration && + session.AuthTicket?.Properties is { IsPersistent: true, AllowRefresh: true or null }) + { + session.AuthTicket.Properties.IssuedUtc = session.Session.Renewed; + session.AuthTicket.Properties.ExpiresUtc = session.Session.Expires; + session.AuthTicket.Properties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + await serverSessionTicketStore.RenewAsync(session.Session.Key, session.AuthTicket); + } + else + { + await identityServerServerSideSessionStore.UpdateSession(session.Session); + } + } + + return true; + } + + private async Task EndSessionForClients(EndUserSessionEventContext sessionEventContext) + { + string[] 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(EndUserSessionEventContext sessionEventContext) + { + foreach (string clientId in sessionEventContext.ClientIds ?? []) + { + Client? client = await clientStore.FindClientByIdAsync(clientId); + + if (ShouldCoordinateLifetimes(client)) + { + yield return client.ClientId; + } + } + } + + private bool ShouldCoordinateLifetimes(Client? client) => client != null && (client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs new file mode 100644 index 000000000..ad655cbaa --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -0,0 +1,78 @@ +// Copyright (c) 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 System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services; + +/// +/// Service to clean up expired server-side sessions +/// +/// IdentityServer options +/// server side sessions store +/// user session events service +/// telemetry service +/// logger +public class SessionCleanupService( + IdentityServerOptions options, + IServerSessionTicketStore serverSideSessionStore, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry, + ILogger logger): ISessionCleanupService +{ + /// + /// Method to clear expired server-side sessions. + /// + /// A that completes once all expired grants and device codes have been removed. + public async Task RemoveExpiredServerSideSessionsAsync() + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + try + { + logger.LogTrace("Querying for expired sessions to remove"); + + await RemoveServerSideSessionsAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Exception removing expired sessions: {Exception}", ex.Message); + } + } + + /// + /// Removes the expired sessions. + /// + /// A that completes once all batches of expired sessions have been deleted. + private async Task RemoveServerSideSessionsAsync() + { + int found; + + do + { + var expiredSessions = (await serverSideSessionStore + .GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ToList(); + + found = expiredSessions.Count; + logger.LogInformation("Removed {ExpiredSessionsCount} expired server side sessions", found); + + foreach (var expiredSession in expiredSessions) + { + await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventContext() + { + SubjectId = expiredSession.Session.SubjectId, + SessionId = expiredSession.Session.SessionId, + ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], + }); + } + } while (found >= options.ServerSideSessions.RemoveExpiredSessionsBatchSize); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs b/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs new file mode 100644 index 000000000..9fbfa3711 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace Open.IdentityServer.Services; + +/// +/// Service to clean up expired server-side sessions +/// +public interface ISessionCleanupService +{ + /// + /// Method to clear expired server-side sessions. + /// + /// A that completes once all expired grants and device codes have been removed. + public Task RemoveExpiredServerSideSessionsAsync(); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/ISessionManagementService.cs b/src/Open.IdentityServer/src/Services/ISessionManagementService.cs new file mode 100644 index 000000000..30a5ffb5c --- /dev/null +++ b/src/Open.IdentityServer/src/Services/ISessionManagementService.cs @@ -0,0 +1,32 @@ +// 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; +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Services; + +/// +/// Session management interface, defines methods for querying sessions and removing them. +/// +public interface ISessionManagementService +{ + /// + /// Method for querying sessions + /// + /// filter to be used + /// cancellation token + /// paginated query result of user sessions + Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default); + + /// + /// Method for removing sessions + /// + /// remove session context + /// cancellation token + /// void + Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default); +} \ 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..81f7fe278 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs @@ -0,0 +1,36 @@ +// 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 for handling user session events +/// +public interface IUserSessionEventsService +{ + /// + /// Triggered when the session logout occurs + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionLogout(EndUserSessionEventContext sessionEventContext); + + /// + /// Triggered when the session expires + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionExpiry(EndUserSessionEventContext sessionEventContext); + + /// + /// Checks for a valid session using the provided context + /// + /// context needed for handling session validation + /// boolean value to indicate if valid session exists + public Task ValidateSession(ValidateUserSessionEventContext sessionEventContext); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs new file mode 100644 index 000000000..d5ed3660a --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -0,0 +1,249 @@ +// 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.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Stores; + +/// +/// Implementation of for storing for the server side sessions +/// implementation in Open.IdentityServer +/// +/// +/// data protection provider +/// time provider +/// identity server options +/// http context accessor +/// telemetry service +/// the logger +public class ServerSessionTicketStore( + IIdentityServerServerSideSessionStore serverServerSideSessionStore, + IDataProtectionProvider dataProtectionProvider, + TimeProvider timeProvider, + IdentityServerOptions options, + IHttpContextAccessor httpContextAccessor, + ITelemetryService telemetry, + ILogger logger): IServerSessionTicketStore +{ + private readonly IDataProtector dataProtector = + dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + + /// + /// to be used for storing server side sessions + /// + public static readonly JsonSerializerOptions JsonSettings = new() + { + IncludeFields = true, + }; + + /// + public async Task StoreAsync(AuthenticationTicket ticket) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + string key = Guid.NewGuid().ToString(); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + + IdentityServerServerSideSessions session = await StoreNewSession(key, ticket); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, session.SubjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, session.SessionId); + + return session.Key; + } + + /// + public async Task RenewAsync(string key, AuthenticationTicket ticket) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogWarning("failed renewing '{SessionKey}' session in database, session with key doesn't exist", key); + await StoreNewSession(key, ticket); + return; + } + + existingSession.Scheme = ticket.AuthenticationScheme; + existingSession.SubjectId = ticket.Principal.GetSubjectId(); + existingSession.SessionId = ticket.Properties.GetSessionId(); + existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); + existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; + existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; + existingSession.Data = ToProtectedDataString(ticket); + + await serverServerSideSessionStore.UpdateSession(existingSession); + } + + /// + public async Task RetrieveAsync(string key) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogWarning("session with key '{SessionKey}' doesn't exist", key); + return null; + } + + try + { + return DeserializeAuthTicket(existingSession); + } + catch (Exception ex) + { + logger.LogError(ex, "failed retrieving '{SessionKey}' session in database", key); + return null; + } + } + + /// + public Task RemoveAsync(string key) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + serverServerSideSessionStore.DeleteSession(key); + return Task.CompletedTask; + } + + /// + public async Task> FilterServerAuthenticationTickets(string subjectId, string sessionId) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + IEnumerable sessions = await serverServerSideSessionStore.FilterSessions(subjectId, sessionId); + + return sessions.Select(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }).Where(x => x.AuthTicket != null); + } + + /// + public async Task> FilterServerAuthenticationTickets(SessionQuery? query, CancellationToken ct = default) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + QueryResult sessions = await serverServerSideSessionStore.FilterSessions(query, ct); + + return sessions.MapTo(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }); + } + + /// + public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + var sessionRemoved = await serverServerSideSessionStore.GetAndRemoveExpiredSessions(batchSize); + + return sessionRemoved.Select(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }); + } + + private async Task StoreNewSession(string key, AuthenticationTicket ticket) + { + var issuerUri = httpContextAccessor.HttpContext.GetIdentityServerIssuerUri(); + + if (issuerUri != null) + { + ticket.Properties.Items[JwtClaimTypes.Issuer] = issuerUri; + } + + string? displayName = string.IsNullOrWhiteSpace(options.ServerSideSessions.UserDisplayNameClaimType) + ? null + : ticket.Principal.FindFirstValue(options.ServerSideSessions.UserDisplayNameClaimType); + + IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions + { + Key = key, + Scheme = ticket.AuthenticationScheme, + SubjectId = ticket.Principal.GetSubjectId(), + SessionId = ticket.Properties.GetSessionId(), + DisplayName = displayName, + Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Expires = ticket.Properties.ExpiresUtc?.UtcDateTime, + Data = ToProtectedDataString(ticket), + }; + + await serverServerSideSessionStore.CreateSession(serverSideSession); + + return serverSideSession; + } + + private string ToProtectedDataString(AuthenticationTicket ticket) + { + string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + + return JsonSerializer.Serialize(new DataProtectedSessionData + { + Payload = dataProtector.Protect(serializedTicket), + }, JsonSettings); + } + + private AuthenticationTicket? DeserializeAuthTicket(IdentityServerServerSideSessions existingSession) + { + DataProtectedSessionData? dataProtectedSessionData; + + try + { + dataProtectedSessionData = JsonSerializer.Deserialize(existingSession.Data, JsonSettings); + } + catch (JsonException exception) + { + logger.LogError(exception, "failed deserialising auth ticket data '{SessionKey}'", existingSession.Key); + return null; + } + + if (dataProtectedSessionData is not { Version: 1 }) + { + logger.LogError("failed retrieving '{SessionKey}', deserialisation failed, incorrect version '{VersionOrNull}'", existingSession.Key, dataProtectedSessionData?.Version); + return null; + } + + string unprotectedData = dataProtector.Unprotect(dataProtectedSessionData.Payload); + + SerializedAuthenticationTicket? serializedAuthTicket = + JsonSerializer.Deserialize(unprotectedData); + + return serializedAuthTicket?.ToAuthTicket(existingSession); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs new file mode 100644 index 000000000..ff9562820 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -0,0 +1,42 @@ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// Extension to to add more methods of retrival +/// +public interface IServerSessionTicketStore: ITicketStore +{ + /// + /// Filters auth tickets stored server-side using the provided filters + /// + /// subject id filter to apply + /// session id filter to apply + /// collection of auth ticket matching filter + Task> FilterServerAuthenticationTickets(string? subjectId, string? sessionId); + + /// + /// Filters auth tickets stored server-side using the provided session query object + /// + /// query to applied to server auth tickets + /// cancellation token + /// QueryResult produced using provided query + Task> FilterServerAuthenticationTickets(SessionQuery? query, CancellationToken ct = default); + + /// + /// Removes expired auth tickets and returns a collection of these auth tokens and the session objects they come from + /// + /// optional batch size value, defaults to 100 + /// removed expired sessions + public Task> GetAndRemoveExpiredSessions(int batchSize = 100); +} \ 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 new file mode 100644 index 000000000..a5341bcf8 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -0,0 +1,195 @@ +// 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// In-memory server-side session store +/// +public class InMemorySessionStore(): IIdentityServerServerSideSessionStore +{ + private readonly ConcurrentDictionary repo = new(); + + /// + public Task GetSession(string key) + { + repo.TryGetValue(key, out IdentityServerServerSideSessions? value); + return Task.FromResult(value); + } + + /// + public Task CreateSession(IdentityServerServerSideSessions session) + { + repo[session.Key] = session; + return Task.CompletedTask; + } + + /// + public Task UpdateSession(IdentityServerServerSideSessions session) + { + repo[session.Key] = session; + return Task.CompletedTask; + } + + /// + public Task DeleteSession(string key) + { + repo.TryRemove(key, out IdentityServerServerSideSessions? value); + return Task.CompletedTask; + } + + /// + public Task DeleteSessions(string? subjectId, string? sessionId) + { + if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException($"{nameof(subjectId)} or {nameof(sessionId)} must have a non null or empty value"); + } + + IEnumerable filteredResults = ApplyFilter(new SessionQuery + { + SubjectId = subjectId, SessionId = sessionId, + }, repo.Values); + + foreach (var filteredResult in filteredResults) + { + repo.TryRemove(filteredResult.Key, out _); + } + + return Task.CompletedTask; + } + + /// + public Task> FilterSessions(string? subjectId, string? sessionId) + { + IEnumerable filteredResults = ApplyFilter(new SessionQuery + { + SubjectId = subjectId, SessionId = sessionId, + }, repo.Values); + + return Task.FromResult(filteredResults); + } + + /// + public async Task> FilterSessions(SessionQuery? inputQuery, CancellationToken ct = default) + { + SessionQuery query = inputQuery ?? new SessionQuery(); + + IEnumerable filteredResults = ApplyFilter(query, repo.Values).ToList(); + + int count = filteredResults.Count(); + + if (count < 1) + { + return new QueryResult + { + TotalCount = count, TotalPages = 0, CurrentPage = 0, HasPrevResults = false, HasNextResults = false, + Results = [], + }; + } + + int totalPages = (count / query.CountRequested) + (count % query.CountRequested != 0 ? 1 : 0); + int currentPage = 1; + + if (!string.IsNullOrWhiteSpace(query.ResultsToken)) + { + (string tokenFirst, string tokenLast) = ParseResultsToken(query); + int elementsBeforeToken = filteredResults.Count(x => string.CompareOrdinal(x.Key, tokenFirst) <= 0); + currentPage = 1 + (elementsBeforeToken / query.CountRequested); + + if (query.RequestPriorResults) + { + filteredResults = filteredResults + .Where(x => string.CompareOrdinal(x.Key, tokenFirst) >= 0).Take(query.CountRequested); + } + else + { + currentPage++; + filteredResults = filteredResults + .Where(x => string.CompareOrdinal(x.Key, tokenLast) > 0).Take(query.CountRequested); + } + } + else + { + filteredResults = filteredResults.Take(query.CountRequested); + } + + var results = filteredResults.ToList(); + + return new QueryResult + { + TotalCount = count, + TotalPages = totalPages, + CurrentPage = currentPage, + HasPrevResults = currentPage > 1, + HasNextResults = currentPage < totalPages, + ResultsToken = $"{results.First().Key},{results.Last().Key}", + Results = results.ToList(), + }; + } + + private (string, string) ParseResultsToken(SessionQuery query) + { + string tokenFirst = string.Empty; + string tokenLast = string.Empty; + + if (query.ResultsToken != null) + { + var split = query.ResultsToken.Split(",", StringSplitOptions.RemoveEmptyEntries); + tokenFirst = split.First(); + tokenLast = split.Last(); + } + + return new ValueTuple(tokenFirst, tokenLast); + } + + private IEnumerable ApplyFilter(SessionQuery query, + IEnumerable input) + { + if (!string.IsNullOrWhiteSpace(query.SubjectId)) + { + input = input + .Where(x => x.SubjectId.Contains(query.SubjectId)); + } + + if (!string.IsNullOrWhiteSpace(query.SessionId)) + { + input = input.Where(x => x.SessionId != null && x.SessionId.Contains(query.SessionId)); + } + + if (!string.IsNullOrWhiteSpace(query.DisplayName)) + { + input = input.Where(x => x.DisplayName != null && x.DisplayName.Contains(query.DisplayName)); + } + + return input.OrderBy(x => x.Key); + } + + /// + public Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + IEnumerable sessions = repo + .Select(x => x.Value) + .Where(x => x.Expires < DateTime.UtcNow) + .OrderBy(x => x.Expires) + .Take(batchSize) + .ToList(); + + foreach (var session in sessions) + { + repo.TryRemove(session.Key, out _); + } + + return Task.FromResult(sessions); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs new file mode 100644 index 000000000..d5ae28e29 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs @@ -0,0 +1,71 @@ +// 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.Threading.Tasks; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Validation; + +/// +/// Decorator for , adds server side sessions functionality to refresh token validation +/// +/// existing implementation of to be decorated +/// user session event service +/// telemetry service +internal class DefaultServerSideSessionRefreshTokenService( + Decorator decorator, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry): IRefreshTokenService +{ + private IRefreshTokenService decoratedService = decorator.Instance ?? throw new ArgumentNullException(nameof(decorator)); + + /// + /// Validates refresh token as normal using the decorated service, then if successfull validates that there are valid + /// sessions associated with the refresh token. + /// + /// refresh token to be validated + /// client to validate refresh token against + /// A task that resolves to a indicating whether the refresh token is valid for the specified client. + public async Task ValidateRefreshTokenAsync(string token, Client client) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Validation, this); + + TokenValidationResult? validatedRefreshToken = await decoratedService.ValidateRefreshTokenAsync(token, client); + + if (validatedRefreshToken?.IsError ?? true) + { + return validatedRefreshToken; + } + + bool sessionValid = await userSessionEventsService.ValidateSession(new ValidateUserSessionEventContext + { + SessionId = validatedRefreshToken.RefreshToken.SessionId, + SubjectId = validatedRefreshToken.RefreshToken.SubjectId, + Client = validatedRefreshToken.Client, + }); + + if (!sessionValid) + { + return new TokenValidationResult + { + IsError = true, + Error = OidcConstants.ProtectedResourceErrors.InvalidToken, + }; + } + + return validatedRefreshToken; + } + + /// + public Task CreateRefreshTokenAsync(RefreshTokenCreationRequest request) => + decoratedService.CreateRefreshTokenAsync(request); + + /// + public Task UpdateRefreshTokenAsync(string handle, RefreshToken refreshToken, Client client) => + decoratedService.UpdateRefreshTokenAsync(handle, refreshToken, client); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs new file mode 100644 index 000000000..3fa6ed0a2 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs @@ -0,0 +1,71 @@ +// 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.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Validation; + +/// +/// +/// +/// +/// +/// +internal class DefaultServerSideSessionTokenValidator( + Decorator decorator, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry): ITokenValidator +{ + private ITokenValidator decoratedService = decorator?.Instance ?? throw new ArgumentNullException(nameof(decorator)); + + /// + /// + /// + /// + /// + /// + public async Task ValidateAccessTokenAsync(string token, string? expectedScope = null) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Validation, this); + + TokenValidationResult? validatedAccessToken = await decoratedService.ValidateAccessTokenAsync(token, expectedScope); + + Claim? sid = validatedAccessToken?.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); + Claim? sub = validatedAccessToken?.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject); + + if (validatedAccessToken == null || sid == null || sub == null) + { + return validatedAccessToken; + } + + bool sessionValid = await userSessionEventsService.ValidateSession(new ValidateUserSessionEventContext + { + SessionId = sid.Value, + SubjectId = sub.Value, + Client = validatedAccessToken.Client, + }); + + if (!sessionValid) + { + return new TokenValidationResult + { + IsError = true, + Error = OidcConstants.ProtectedResourceErrors.InvalidToken, + }; + } + + return validatedAccessToken; + } + + /// + public Task ValidateIdentityTokenAsync(string token, string? clientId = null, bool validateLifetime = true) => + decoratedService.ValidateIdentityTokenAsync(token, clientId, validateLifetime); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 401989404..cf86bce2d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -28,6 +28,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; namespace IdentityServer.IntegrationTests.Common; @@ -56,8 +58,11 @@ public class IdentityServerPipeline public const string FederatedSignOutPath = "/signout-oidc"; public const string FederatedSignOutUrl = BaseUrl + FederatedSignOutPath; - + + public const string AuthCookieSessionIdClaimType = "Microsoft.AspNetCore.Authentication.Cookies-SessionId"; + public IdentityServerOptions? Options { get; set; } + public List Clients { get; set; } = new List(); public List IdentityScopes { get; set; } = new List(); public List ApiResources { get; set; } = new List(); @@ -80,6 +85,11 @@ public class IdentityServerPipeline public Func>? OnFederatedSignout; + public AuthenticationProperties? AuthenticationProperties { get; set; } = new(); + + // Enableable Features + public bool EnableServerSideSessions { get; set; } + public void Initialize(string? basePath = null, bool enableLogging = false) { Initialize(_ => { }, basePath, enableLogging); @@ -144,10 +154,15 @@ public void ConfigureServices(IServiceCollection services) return handler; }); - services.AddIdentityServer(options => + var idsBuilder = services.AddIdentityServer(options => { Options = options; + if (EnableServerSideSessions) + { + options.Authentication.CookieSlidingExpiration = true; + } + options.Events = new EventsOptions { RaiseErrorEvents = true, @@ -163,6 +178,11 @@ public void ConfigureServices(IServiceCollection services) .AddTestUsers(Users) .AddDeveloperSigningCredential(persistKey: false); + if (EnableServerSideSessions) + { + idsBuilder.AddServerSideSessions(); + } + services.AddHttpClient(IdentityServerConstants.HttpClients.BackChannelLogoutHttpClient) .AddHttpMessageHandler(() => BackChannelMessageHandler); @@ -227,7 +247,7 @@ private async Task IssueLoginCookie(HttpContext ctx) { if (Subject != null) { - var props = new AuthenticationProperties(); + var props = AuthenticationProperties ?? new AuthenticationProperties(); await ctx.SignInAsync(Subject, props); Subject = null; var url = ctx.Request.Query[Options!.UserInteraction.LoginReturnUrlParameter].FirstOrDefault(); @@ -325,7 +345,7 @@ public async Task LoginAsync(ClaimsPrincipal subject) Subject = subject; await BrowserClient.GetAsync(LoginPage); - + BrowserClient.AllowAutoRedirect = old; } @@ -347,6 +367,32 @@ public Cookie GetSessionCookie() return BrowserClient!.GetCookie(BaseUrl, IdentityServerConstants.DefaultCheckSessionCookieName); } + public Cookie GetLoginCookie() + { + return BrowserClient!.GetCookie(BaseUrl, IdentityServerConstants.DefaultCookieAuthenticationScheme); + } + + public string? GetTicketStoreKeyFromAuthCookie() + { + var authCookie = GetLoginCookie(); + if (authCookie == null || string.IsNullOrWhiteSpace(authCookie.Value)) + { + return null; + } + + var optionsMonitor = Server!.Services.GetRequiredService>(); + var cookieOptions = optionsMonitor.Get(IdentityServerConstants.DefaultCookieAuthenticationScheme); + + var ticket = cookieOptions.TicketDataFormat.Unprotect(authCookie.Value); + return ticket?.Principal?.FindFirst(AuthCookieSessionIdClaimType)?.Value; + } + public ITicketStore GetTicketStore() + { + var optionsMonitor = Server!.Services.GetRequiredService>(); + var cookieOptions = optionsMonitor.Get(IdentityServerConstants.DefaultCookieAuthenticationScheme); + return cookieOptions.SessionStore!; + } + public string CreateAuthorizeUrl( string clientId = null!, string responseType = null!, diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs new file mode 100644 index 000000000..3d060a636 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs @@ -0,0 +1,93 @@ +// 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.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Authorize; + +public class AuthoriseServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(AuthoriseServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task AuthorizeEndpoint_WhenCalled_ShouldNotRenewSessionLifetime() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + // Auth code grant + await AuthCodeRequest("client1", "openid profile api1 offline_access", "https://client1/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + } + + [Fact] + [Trait("Category", Category)] + public async Task AuthorizeEndpoint_WhenCalledWithMultipleClients_ShouldUpdateSessionInServerStore() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Auth code grant + await AuthCodeRequest("client1", "openid profile api1 offline_access", "https://client1/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + var clientList = ticket.Properties.GetClientList().ToList(); + clientList.Should().NotBeEmpty(); + clientList.Should().Contain("client1"); + + await AuthCodeRequest("client2", "openid profile", "https://client2/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + clientList = ticket.Properties.GetClientList().ToList(); + clientList.Should().NotBeEmpty(); + clientList.Should().Contain("client1"); + clientList.Should().Contain("client2"); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs index 7a6ccde87..27fe22abb 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs @@ -80,6 +80,7 @@ public SessionIdTests() } [Fact] + [Trait("Category", Category)] public async Task session_id_should_be_reissued_if_session_cookie_absent() { await _mockPipeline.LoginAsync("bob"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs index 7ec2bb001..f97adef6f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs @@ -1,7 +1,6 @@ // 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.Net; using System.Threading.Tasks; using AwesomeAssertions; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs new file mode 100644 index 000000000..d06d79fa6 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs @@ -0,0 +1,95 @@ +// 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.Net; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.EndSession; + +public class EndSessionServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(EndSessionServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task EndSession_ShouldRemoveSession() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie sessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var storedSessionPreEndSession = await sessionStore.GetSession(authKey); + storedSessionPreEndSession.Should().NotBeNull(); + storedSessionPreEndSession.SessionId.Should().Be(sessionCookie.Value); + storedSessionPreEndSession.SubjectId.Should().Be("bob"); + + await _mockPipeline.BrowserClient!.GetAsync(IdentityServerPipeline.EndSessionEndpoint, + TestContext.Current.CancellationToken); + + _mockPipeline.LogoutWasCalled.Should().BeTrue(); + _mockPipeline.LogoutRequest.Should().NotBeNull(); + + var storedSessionPostEndSession = await sessionStore.GetSession(authKey); + storedSessionPostEndSession.Should().BeNull(); + } + + [Fact] + [Trait("Category", Category)] + public async Task EndSession_WhenMultipleClients_ShouldRenderFrontChannelSignoutIframes() + { + ticketStore = _mockPipeline.GetTicketStore(); + + await _mockPipeline.LoginAsync("bob"); + var sid = _mockPipeline.GetSessionCookie().Value; + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + // Perform Client Authorizations + var (_, client1TokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + await AuthCodeAndTokenRequest( + "client2", + "openid profile", + "https://client2/callback"); + + var endSessionUrl = IdentityServerPipeline.EndSessionEndpoint + + "?id_token_hint=" + Uri.EscapeDataString(client1TokenResponse.IdentityToken!); + + // Validate End Session Endpoint Behaviour + await _mockPipeline.BrowserClient.GetAsync(endSessionUrl, TestContext.Current.CancellationToken); + + _mockPipeline.LogoutWasCalled.Should().BeTrue(); + _mockPipeline.LogoutRequest.Should().NotBeNull(); + _mockPipeline.LogoutRequest.SignOutIFrameUrl.Should().NotBeNull(); + + var signoutFrameResponse = await _mockPipeline.BrowserClient.GetAsync( + _mockPipeline.LogoutRequest.SignOutIFrameUrl, + TestContext.Current.CancellationToken); + + signoutFrameResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var html = await signoutFrameResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client1/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client2/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs index 8227c257a..5d0fa7f18 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs @@ -1,7 +1,6 @@ // 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; using System.Collections.Generic; using System.Linq; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs new file mode 100644 index 000000000..81485de88 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs @@ -0,0 +1,81 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.IntegrationTests; +using Xunit; + +namespace IdentityServer.IntegrationTests.Endpoints.Introspection; + +public class IntrospectionServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(IntrospectionServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task Introspection_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket; + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + // Expired is not updated on code exchange + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Use reference token with introspection to update the session + var introspectionResponse = await _mockPipeline.BackChannelClient! + .IntrospectTokenAsync(new TokenIntrospectionRequest() + { + Address = IdentityServerPipeline.IntrospectionEndpoint, + ClientId = "api", + ClientSecret = "secret", + Token = tokenResponse.AccessToken!, + }, TestContext.Current.CancellationToken); + + introspectionResponse.IsError.Should().BeFalse(); + introspectionResponse.IsActive.Should().BeTrue(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs new file mode 100644 index 000000000..aba231fba --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -0,0 +1,85 @@ +// 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.Net; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Microsoft.AspNetCore.Authentication; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; + +public class LoginServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(LoginServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task Login_ShouldCreateSessionInServerStore() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie sessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var storedSession = await sessionStore.GetSession(authKey); + storedSession.Should().NotBeNull(); + storedSession.SessionId.Should().Be(sessionCookie.Value); + storedSession.SubjectId.Should().Be("bob"); + } + + [Fact] + [Trait("Category", Category)] + public async Task Login_WhenUserChanges_ShouldUpdateSessionInServerStore() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie originalSessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var originalSession = await sessionStore.GetSession(authKey); + originalSession.Should().NotBeNull(); + originalSession.SessionId.Should().Be(originalSessionCookie.Value); + originalSession.SubjectId.Should().Be("bob"); + + await _mockPipeline.LoginAsync("alice"); + + Cookie newSessionCookie = _mockPipeline.GetSessionCookie(); + + var updatedSession = await sessionStore.GetSession(authKey); + updatedSession.Should().NotBeNull(); + updatedSession.SessionId.Should().Be(newSessionCookie.Value); + updatedSession.SubjectId.Should().Be("alice"); + } + + [Fact] + [Trait("Category", Category)] + public async Task Login_ShouldStoreAuthTicketWithIssuerAttached() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Properties.Items[JwtClaimTypes.Issuer].Should().Be(IdentityServerPipeline.BaseUrl); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs new file mode 100644 index 000000000..79ece0693 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs @@ -0,0 +1,83 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Token; + +public class TokenServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(TokenServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task TokenEndpoint_WhenRefreshTokenExchanged_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session lifetime hasn't changed + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Exchange refresh token + var refreshTokenResponse = await _mockPipeline.BackChannelClient! + .RequestRefreshTokenAsync(new RefreshTokenRequest() + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + RefreshToken = tokenResponse.RefreshToken + }, TestContext.Current.CancellationToken); + + refreshTokenResponse.IsError.Should().BeFalse(); + refreshTokenResponse.AccessToken.Should().NotBeNull(); + refreshTokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify session lifetime had been updated + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs new file mode 100644 index 000000000..f705943b3 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs @@ -0,0 +1,78 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.UserInfo; + +public class UserInfoTests: ServerSideSessionTests +{ + private const string Category = nameof(UserInfoTests); + + [Fact] + [Trait("Category", Category)] + public async Task UserInfo_WhenCalled_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Auth code grant + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.AccessToken.Should().NotBeNull(); + + // Verify that the session lifetime hasn't changed + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Perform user info requests + var userInfoResponse = await _mockPipeline.BackChannelClient! + .GetUserInfoAsync(new UserInfoRequest() + { + Address = IdentityServerPipeline.UserInfoEndpoint, + Token = tokenResponse.AccessToken + }, TestContext.Current.CancellationToken); + + userInfoResponse.IsError.Should().BeFalse(); + + //Verify that session lifetime has been updated + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj index f280fc5d8..eb39b44fc 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj @@ -24,6 +24,8 @@ + + diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs new file mode 100644 index 000000000..8f166b52c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs @@ -0,0 +1,188 @@ +// 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.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Test; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests; + +public abstract class ServerSideSessionTests +{ + protected IdentityServerPipeline _mockPipeline = new(); + protected FakeTimeProvider fakeTimeProvider = new(); + protected ITicketStore? ticketStore; + protected IIdentityServerServerSideSessionStore? sessionStore; + + protected ServerSideSessionTests() + { + _mockPipeline.EnableServerSideSessions = true; + + fakeTimeProvider.SetUtcNow( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + _mockPipeline.Clients.AddRange([ + new Client + { + ClientId = "client1", + AllowedGrantTypes = GrantTypes.Code, + AccessTokenType = AccessTokenType.Reference, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile", "api1" }, + RedirectUris = new List { "https://client1/callback" }, + FrontChannelLogoutUri = "https://client1/signout", + AllowOfflineAccess = true, + RequirePkce = false, + RequireClientSecret = false, + CoordinateLifetimeWithUserSession = true, + }, + new Client + { + ClientId = "client2", + AllowedGrantTypes = GrantTypes.Code, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile", "api1", "api2" }, + RedirectUris = new List { "https://client2/callback" }, + FrontChannelLogoutUri = "https://client2/signout", + RequirePkce = false, + RequireClientSecret = false, + }, + ]); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "bob", + Username = "bob", + Claims = + [ + new Claim("name", "Bob Loblaw"), + new Claim("email", "bob@loblaw.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "alice", + Username = "alice", + Claims = + [ + new Claim("name", "Alice Smith"), + new Claim("alice", "alice@smith.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.IdentityScopes.AddRange([ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email() + ]); + + _mockPipeline.ApiResources.AddRange([ + new ApiResource + { + Name = "api", + ApiSecrets = [ + new Secret("secret".Sha256()) + ], + Scopes = ["api1", "api2"] + }, + ]); + _mockPipeline.ApiScopes.AddRange([ + new ApiScope + { + Name = "api1" + }, + new ApiScope + { + Name = "api2" + } + ]); + + _mockPipeline.OnPreConfigure += app => + { + sessionStore = app.ApplicationServices.GetRequiredService(); + }; + + _mockPipeline.OnPostConfigureServices += services => + { + services.Configure(options => + { + //Session Expirey is only update if more than half of the cookie lifetime has passed, + //so we set the cookie lifetime to 6 minutes, then update the TimeProvider by 5 minutes each step. + options.Authentication.CookieLifetime = TimeSpan.FromMinutes(6); + options.Authentication.CookieSlidingExpiration = true; + }); + + services.AddSingleton(fakeTimeProvider); + + services.PostConfigure( + IdentityServerConstants.DefaultCookieAuthenticationScheme, + options => { options.TimeProvider = fakeTimeProvider; }); + }; + + _mockPipeline.Initialize(); + } + + protected async Task<(AuthorizeResponse, TokenResponse)> AuthCodeAndTokenRequest(string clientId, string scope, string redirectUri) + { + var codeResponse = await AuthCodeRequest(clientId, scope, redirectUri); + var tokenResponse = await AuthCodeTokenRequest(clientId, redirectUri, codeResponse.Code!); + return (codeResponse, tokenResponse); + } + + protected async Task AuthCodeRequest(string clientId, string scope, string redirectUri) + { + var authResponse = await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: clientId, + responseType: "code", + scope: scope, + redirectUri: redirectUri, + state: $"state-{clientId}", + nonce: $"nonce-{clientId}"); + + authResponse.IsError.Should().BeFalse(); + authResponse.IdentityToken.Should().BeNull(); + authResponse.State.Should().Be($"state-{clientId}"); + authResponse.Code.Should().NotBeNull(); + + return authResponse; + } + + protected async Task AuthCodeTokenRequest(string clientId, string redirectUri, string code) + { + var tokenClient1 = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = clientId, + }); + + var tokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( + code: code, + redirectUri: redirectUri, + cancellationToken: TestContext.Current.CancellationToken); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + + return tokenResponse; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs index 7bfb5511d..526a4bb1d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs @@ -45,6 +45,8 @@ public static async Task IntrospectTokenAsync(this H return ProtocolResponse.FromException(ex); } + var content = await response.Content.ReadAsStringAsync(); + return await ProtocolResponse.FromHttpResponseAsync(response).ConfigureAwait(); } } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs new file mode 100644 index 000000000..5d6ca0b62 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.EntityFramework; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration.DependencyInjection; + +public class AdditionalTests +{ + private IServiceCollection serviceCollection = new ServiceCollection(); + + [Fact] + public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSideSessionServicesWithInMemoryStore() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.AddServerSideSessions(); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IPostConfigureOptions) && + d.ImplementationType == typeof(PostConfigureSessionStoreCookieAuthOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IServerSessionTicketStore) && + d.ImplementationType == typeof(ServerSessionTicketStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITokenValidator) && + d.ImplementationType == typeof(DefaultServerSideSessionTokenValidator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IRefreshTokenService) && + d.ImplementationType == typeof(DefaultServerSideSessionRefreshTokenService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && + d.ImplementationType == typeof(InMemorySessionStore) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(SessionCleanupService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(SessionCleanupHostedService) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ISessionManagementService) && + d.ImplementationType == typeof(DefaultSessionManagementService) && + d.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideSessionServicesWithoutInMemoryStore() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddSingleton(); + + builder.AddServerSideSessions(); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IPostConfigureOptions) && + d.ImplementationType == typeof(PostConfigureSessionStoreCookieAuthOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IServerSessionTicketStore) && + d.ImplementationType == typeof(ServerSessionTicketStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITokenValidator) && + d.ImplementationType == typeof(DefaultServerSideSessionTokenValidator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IRefreshTokenService) && + d.ImplementationType == typeof(DefaultServerSideSessionRefreshTokenService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().NotContain(d => + d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && + d.ImplementationType == typeof(InMemorySessionStore) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(SessionCleanupService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(SessionCleanupHostedService) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ISessionManagementService) && + d.ImplementationType == typeof(DefaultSessionManagementService) && + d.Lifetime == ServiceLifetime.Scoped); + } +} + +public class FakeIdentityServerServerSideSessionStore: IIdentityServerServerSideSessionStore +{ + public Task GetSession(string key) + { + throw new System.NotImplementedException(); + } + + public Task CreateSession(IdentityServerServerSideSessions session) + { + throw new System.NotImplementedException(); + } + + public Task UpdateSession(IdentityServerServerSideSessions session) + { + throw new System.NotImplementedException(); + } + + public Task DeleteSession(string key) + { + throw new System.NotImplementedException(); + } + + public Task DeleteSessions(string subjectId, string sessionId) + { + throw new System.NotImplementedException(); + } + + public Task> FilterSessions(string subjectId, string sessionId) + { + throw new System.NotImplementedException(); + } + + public Task> FilterSessions(SessionQuery query, CancellationToken ct = default) + { + throw new System.NotImplementedException(); + } + + public Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs new file mode 100644 index 000000000..75032da19 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Stores; +using Xunit; +using AuthenticationOptions = Microsoft.AspNetCore.Authentication.AuthenticationOptions; + +namespace Open.IdentityServer.UnitTests.Configuration; + +public class PostConfigureSessionStoreCookieAuthOptionsTests +{ + private IServerSessionTicketStore ticketStore = Mock.Of(); + private IdentityServerOptions idsOptions = new(); + private IOptions authOptions = + Mock.Of>(); + + private AuthenticationOptions authenticationOptions = new(); + + public PostConfigureSessionStoreCookieAuthOptionsTests() + { + Mock.Get(authOptions) + .Setup(x => x.Value) + .Returns(authenticationOptions); + } + + private PostConfigureSessionStoreCookieAuthOptions CreateSut() => new(ticketStore, idsOptions, authOptions); + + [Fact] + public void PostConfigure_WhenIdentityServerOptionsCookieAuthenticationSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + idsOptions.Authentication.CookieAuthenticationScheme = "CookieAuthenticationScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("CookieAuthenticationScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenIdentityServerOptionsCookieAuthenticationSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + idsOptions.Authentication.CookieAuthenticationScheme = "CookieAuthenticationScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultAuthenticateSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + authenticationOptions.DefaultAuthenticateScheme = "DefaultAuthenticateScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("DefaultAuthenticateScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultAuthenticateSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + authenticationOptions.DefaultAuthenticateScheme = "DefaultAuthenticateScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + authenticationOptions.DefaultScheme = "DefaultScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("DefaultScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + authenticationOptions.DefaultScheme = "DefaultScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs new file mode 100644 index 000000000..3a24b02fa --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs @@ -0,0 +1,128 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.UnitTests.Common; +using Xunit; +using CookieAuthenticationEvents = Open.IdentityServer.Events.CookieAuthenticationEvents; + +namespace Open.IdentityServer.UnitTests.Events; + +public class CookieAuthenticationEventsTests +{ + private string fakeAuthScheme = "FakeScheme"; + private string fakeSubjectId = "subject"; + private string fakeSessionId = "session"; + private readonly CookieSlidingExpirationContext fakeContext; + AuthenticationProperties authProperties = new(); + + public CookieAuthenticationEventsTests() + { + IdentityServerUser user = new(fakeSubjectId); + + authProperties.SetSessionId(fakeSessionId); + + user.DisplayName = "John Smith"; + + fakeContext = new CookieSlidingExpirationContext( + new MockHttpContextAccessor().HttpContext!, + new AuthenticationScheme(fakeAuthScheme, null, typeof(MockAuthenticationHandler)), + new CookieAuthenticationOptions(), + new AuthenticationTicket(user.CreatePrincipal(), authProperties, fakeAuthScheme), + TimeSpan.FromDays(1), + TimeSpan.FromDays(1)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OnCheckSlidingExpiration_WhenNoFlagSet_ShouldCallOriginal_AndNotChangeShouldRenew(bool shouldRenew) + { + fakeContext.ShouldRenew = shouldRenew; + bool originalShouldRenew = fakeContext.ShouldRenew; + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().Be(originalShouldRenew); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OnCheckSlidingExpiration_WhenFlagSet_AndExpired_ShouldCallOriginal_AndNotChangeShouldRenew(bool shouldRenew) + { + fakeContext.ShouldRenew = shouldRenew; + bool originalShouldRenew = fakeContext.ShouldRenew; + + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-8); + authProperties.ExpiresUtc = TimeProvider.System.GetUtcNow().AddHours(-2); + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().Be(originalShouldRenew); + } + + [Fact] + public void OnCheckSlidingExpiration_WhenFlagSet_AndNotExpired_ShouldCallOriginal_AndShouldRenewShouldBeTrue() + { + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-3); + authProperties.ExpiresUtc = TimeProvider.System.GetUtcNow().AddHours(3); + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().BeTrue(); + } + + [Fact] + public void OnCheckSlidingExpiration_WhenFlagSet_AndHasNoExpiry_ShouldCallOriginal_AndShouldRenewShouldBeTrue() + { + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-3); + authProperties.ExpiresUtc = null; + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().BeTrue(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs new file mode 100644 index 000000000..5c71be71c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs @@ -0,0 +1,133 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Stores.Serialization; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions; + +public class ClaimExtensionsTests +{ + [Fact] + public void ToClaimsDictionary_WhenDuplicates_ShouldHandleAndProduceCorrectDictionary() + { + var jsonObj = new { Data = "SOMEDATA", Veriosn = 1 }; + JsonElement jsonElement = JsonSerializer.SerializeToElement(jsonObj); + + IEnumerable testClaims = [ + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + ]; + + var actual = testClaims.ToClaimsDictionary(); + + IDictionary expected = new Dictionary + { + ["Integer"] = (int) 1, + ["Integer32"] = (int) 150, + ["Integer64"] = (long) 522337203485477580, + ["Boolean"] = (bool) true, + ["Json"] = (JsonElement) jsonElement, + }; + + actual.Keys.Should().HaveCount(5); + actual["Integer"].Should().BeEquivalentTo(expected["Integer"]); + actual["Integer32"].Should().BeEquivalentTo(expected["Integer32"]); + actual["Integer64"].Should().BeEquivalentTo(expected["Integer64"]); + actual["Boolean"].Should().BeEquivalentTo(expected["Boolean"]); + actual["Json"].ToString().Should().BeEquivalentTo(expected["Json"].ToString()); + } + + [Fact] + public void ToClaimsDictionary_WhenAllClaimValueTypesProvided_ShouldAllBeConvertedToCorrectObjectInDictionary() + { + var jsonObj = new { Data = "SOMEDATA", Veriosn = 1 }; + JsonElement jsonElement = JsonSerializer.SerializeToElement(jsonObj); + + IEnumerable testClaims = [ + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + ]; + + var actual = testClaims.ToClaimsDictionary(); + + IDictionary expected = new Dictionary + { + ["Integer"] = (int) 1, + ["Integer32"] = (int) 150, + ["Integer64"] = (long) 522337203485477580, + ["Boolean"] = (bool) true, + ["Json"] = (JsonElement) jsonElement, + }; + + actual["Integer"].Should().BeEquivalentTo(expected["Integer"]); + actual["Integer32"].Should().BeEquivalentTo(expected["Integer32"]); + actual["Integer64"].Should().BeEquivalentTo(expected["Integer64"]); + actual["Boolean"].Should().BeEquivalentTo(expected["Boolean"]); + actual["Json"].ToString().Should().BeEquivalentTo(expected["Json"].ToString()); + } + + [Fact] + public void ToSerializableObj_WhenEnumerableOfClaimProvided_ShouldReturnArrayOfClaimLite() + { + IEnumerable testClaims = + [ + new("Type1", "Value1", ClaimValueTypes.String, "FakeIssuer1"), + new("Type2", "Value2", ClaimValueTypes.String, "FakeIssuer2"), + new("Type3", "Value3", ClaimValueTypes.String, "FakeIssuer3"), + new("Type4", "Value4", ClaimValueTypes.String, "FakeIssuer4"), + ]; + + var actual = testClaims.ToSerializableObj(); + + ClaimLite[] expected = + [ + new() { Type = "Type1", Value = "Value1", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer1" }, + new() { Type = "Type2", Value = "Value2", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer2" }, + new() { Type = "Type3", Value = "Value3", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer3" }, + new() { Type = "Type4", Value = "Value4", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer4" }, + ]; + + actual.Should().BeEquivalentTo(expected); + } + + [Fact] + public void ToClaims_WhenArrayOfClaimLiteProvided_ShouldReturnEnumerableOfClaim() + { + ClaimLite[] testClaims = + [ + new() { Type = "Type1", Value = "Value1", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer1" }, + new() { Type = "Type2", Value = "Value2", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer2" }, + new() { Type = "Type3", Value = "Value3", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer3" }, + new() { Type = "Type4", Value = "Value4", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer4" }, + ]; + + var actual = testClaims.ToClaims(); + + IEnumerable expected = + [ + new("Type1", "Value1", ClaimValueTypes.String, "FakeIssuer1"), + new("Type2", "Value2", ClaimValueTypes.String, "FakeIssuer2"), + new("Type3", "Value3", ClaimValueTypes.String, "FakeIssuer3"), + new("Type4", "Value4", ClaimValueTypes.String, "FakeIssuer4"), + ]; + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs new file mode 100644 index 000000000..88c64e1d0 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using System.Linq; +using System.Security.Claims; +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Stores.Serialization; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions; + +public class ClaimsPrincipleExtensionTests +{ + [Fact] + public void ToSerializableObj_CalledOnClaimsPrincipal_Should() + { + const string authenticationType = "test-auth-type"; + Claim[] claims = + [ + new(JwtClaimTypes.Name, "alice"), + new(JwtClaimTypes.Role, "admin"), + new("custom", "value") + ]; + + ClaimsPrincipal principal = new(new ClaimsIdentity(claims, authenticationType)); + + ClaimsPrincipalLite actual = principal.ToSerializableObj(); + + actual.AuthenticationType.Should().Be(authenticationType); + actual.Claims.Should().BeEquivalentTo(claims.ToSerializableObj()); + } + + [Fact] + public void ToClaimsPrincipal_CalledOnClaimsPrincipalLite_Should() + { + const string authenticationType = "lite-auth-type"; + Claim[] claims = + [ + new(JwtClaimTypes.Name, "bob"), + new(JwtClaimTypes.Role, "reader"), + new("tenant", "acme") + ]; + + ClaimsPrincipalLite lite = new() + { + AuthenticationType = authenticationType, + Claims = claims.ToSerializableObj() + }; + + ClaimsPrincipal actual = lite.ToClaimsPrincipal(); + + actual.Identity.Should().NotBeNull(); + actual.Identity!.AuthenticationType.Should().Be(authenticationType); + + ClaimsIdentity identity = (ClaimsIdentity)actual.Identity; + identity.NameClaimType.Should().Be(JwtClaimTypes.Name); + identity.RoleClaimType.Should().Be(JwtClaimTypes.Role); + + actual.Claims + .Select(c => new { c.Type, c.Value, c.ValueType, c.Issuer, c.OriginalIssuer }) + .Should() + .BeEquivalentTo( + claims.Select(c => new { c.Type, c.Value, c.ValueType, c.Issuer, c.OriginalIssuer }), + options => options.WithoutStrictOrdering()); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs new file mode 100644 index 000000000..b1e0818ad --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs @@ -0,0 +1,69 @@ +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Extensions.Mapping; +using Open.IdentityServer.UnitTests.Utilities.Generators; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions.Mapping; + +public class AuthenticationTicketFilterResultMappingExtensionsTests +{ + [Fact] + public void ToUserSession_ShouldMapAuthTicketResultToUserSession() + { + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert"); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.Issuer.Should().BeNullOrWhiteSpace(); + actualSession.ClientIds.Should().BeNullOrEmpty(); + } + + [Fact] + public void ToUserSession_WhenClientIdsSet_ShouldExtractClientIdsInResult() + { + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert", clientIds: ["clientA"]); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.Issuer.Should().BeNullOrWhiteSpace(); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(sut.AuthTicket!.Properties.GetClientList()); + } + + [Fact] + public void ToUserSession_WhenIssuerSet_ShouldExtractIssuerInResult() + { + string fakeIssuer = "https://fakeissuer.com"; + + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert", clientIds: ["clientA"], issuer: fakeIssuer); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.Issuer.Should().Be(fakeIssuer); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(sut.AuthTicket!.Properties.GetClientList()); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs new file mode 100644 index 000000000..93288f81f --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Services; +using Xunit; +using Range = Moq.Range; + +namespace Open.IdentityServer.UnitTests.HostedServices; + +public class SessionCleanupHostedServiceTests +{ + private IServiceProvider serviceProvider = Mock.Of(); + private IdentityServerOptions options = new() + { + ServerSideSessions = new ServerSideSessionsOptions + { + RemoveExpiredSessions = true, + RemoveExpiredSessionsBatchSize = 100, + RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(0.25), + FuzzExpiredSessionsFrequency = false, + }, + }; + private ILogger logger = Mock.Of>(); + + private IServiceScopeFactory scopeFactory = Mock.Of(); + private IServiceScope serviceScope = Mock.Of(); + private ISessionCleanupService sessionCleanupService = Mock.Of(); + + public SessionCleanupHostedServiceTests() + { + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServiceScopeFactory))) + .Returns(scopeFactory); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(ISessionCleanupService))) + .Returns(sessionCleanupService); + + Mock.Get(scopeFactory) + .Setup(x => x.CreateScope()) + .Returns(serviceScope); + + Mock.Get(serviceScope) + .Setup(x => x.ServiceProvider) + .Returns(serviceProvider); + } + + private SessionCleanupHostedService CreateSut() => new(serviceProvider, options, logger); + + [Fact] + public async Task SessionCleanupHostedService_WhenCleanupDisabled_ShouldNeverTrigger() + { + options.ServerSideSessions.RemoveExpiredSessions = false; + + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.Never); + } + + [Fact] + public async Task SessionCleanupHostedService_WhenFuzzDisabled_ShouldTriggerAtLeast3Time() + { + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.Between(3, 5, Range.Inclusive)); + } + + [Fact] + public async Task SessionCleanupHostedService_WhenFuzzEnabled_ShouldTriggerAtMost2Times() + { + options.ServerSideSessions.FuzzExpiredSessionsFrequency = true; + + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.AtMost(2)); + } +} \ No newline at end of file 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..29be631a0 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); + + EndUserSessionEventContext? 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/MockDataProtector.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs new file mode 100644 index 000000000..c1b21e908 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs @@ -0,0 +1,41 @@ +using System.Text; +using AwesomeAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Moq; + +namespace Open.IdentityServer.UnitTests; + +public class MockDataProtector: IDataProtector +{ + public IDataProtector dataProtector = Mock.Of(); + public static readonly UTF8Encoding UTF8Encoding = new(false, true); + public const string ProtectedPrefix = "PROTECTED--"; + + public MockDataProtector() + { + Mock.Get(dataProtector) + .Setup(x => x.Protect(It.IsAny())) + .Returns((plaintext) => [..UTF8Encoding.GetBytes(ProtectedPrefix), ..plaintext]); + + Mock.Get(dataProtector) + .Setup(x => x.Unprotect(It.IsAny())) + .Returns((protectedData) => UTF8Encoding.GetBytes(UTF8Encoding.GetString(protectedData).Replace(ProtectedPrefix, string.Empty))); + } + + public IDataProtector CreateProtector(string purpose) => dataProtector; + + public byte[] Protect(byte[] plaintext) => dataProtector.Protect(plaintext); + + public byte[] Unprotect(byte[] protectedData) => dataProtector.Unprotect(protectedData); + + public void ValidateProtectedData(string protectedData, string originalString) + { + var unencodedProtectedData = UTF8Encoding.GetString(WebEncoders.Base64UrlDecode(protectedData)); + unencodedProtectedData = unencodedProtectedData.Replace(ProtectedPrefix, string.Empty); + unencodedProtectedData.Should().BeEquivalentTo(originalString); + } + + public string GenerateFakeProtectedData(string data) => + WebEncoders.Base64UrlEncode(UTF8Encoding.GetBytes(ProtectedPrefix + data)); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj index 50baae4c4..08f43281e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -36,6 +36,17 @@ - + + + + + + + MockLogger.cs + + + + + diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs index a6d179057..5f9f8f391 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs @@ -7,13 +7,12 @@ using AwesomeAssertions; using Microsoft.IdentityModel.Tokens; using Moq; -using Open.IdentityServer; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Xunit; -namespace IdentityServer.UnitTests.Services.Default; +namespace Open.IdentityServer.UnitTests.Services.Default; public class TestSigningCredentialStore(SigningCredentials signingCredentials): ISigningCredentialStore { public Task GetSigningCredentialsAsync() => Task.FromResult(signingCredentials); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs new file mode 100644 index 000000000..e1c7f88fa --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs @@ -0,0 +1,449 @@ +// 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; +using System.Threading.Tasks; +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Utilities.Generators; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultSessionManagementServiceTests +{ + private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private readonly IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly IIdentityServerServerSideSessionStore serverSessionStore = Mock.Of(); + private readonly ITelemetryService telemetry = Mock.Of(); + + private static readonly QueryResult FakeResult = QueryResult.Empty(); + + public DefaultSessionManagementServiceTests() + { + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny(), It.IsAny())) + .ReturnsAsync(QueryResult.Empty()); + } + + private DefaultSessionManagementService CreateSut() => new(persistedGrantStore, backChannelLogoutService, serverSessionTicketStore, serverSessionStore, telemetry); + + [Fact] + public async Task QuerySessionsAsync_WhenNullFilterProvided_ShouldUseDefaultValues() + { + DefaultSessionManagementService sut = CreateSut(); + + QueryResult actual = await sut.QuerySessionsAsync(null, TestContext.Current.CancellationToken); + + actual.Should().BeEquivalentTo(FakeResult, cnf => cnf.Excluding(x => x.Results)); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(null, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task QuerySessionsAsync_WhenFilterProvided_ShouldUseDefaultValues() + { + SessionQuery fakeQuery = new SessionQuery(); + DefaultSessionManagementService sut = CreateSut(); + + QueryResult actual = await sut.QuerySessionsAsync(fakeQuery, TestContext.Current.CancellationToken); + + actual.Should().BeEquivalentTo(FakeResult, cnf => cnf.Excluding(x => x.Results)); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task QuerySessionsAsync_WhenResultsReturned_ShouldMapToUserSessionCorrectly() + { + QueryResult fakeResultWithData = new() + { + ResultsToken = "sess1,sess4", + HasPrevResults = false, + HasNextResults = false, + TotalCount = 4, + TotalPages = 1, + CurrentPage = 1, + Results = [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1","SchemeA", "bob", "session-0001", "Robert", clientIds: ["clientA"]), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess2","SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess3","SchemeB", "bob", "session-0003", "Robert"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess4","SchemeB", "sam", "session-0004", "Samantha", clientIds: ["clientA", "clientB"]), + ] + }; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny(), TestContext.Current.CancellationToken)) + .ReturnsAsync(fakeResultWithData); + + SessionQuery fakeQuery = new SessionQuery(); + DefaultSessionManagementService sut = CreateSut(); + + QueryResult actual = await sut.QuerySessionsAsync(fakeQuery, TestContext.Current.CancellationToken); + + actual.Should().BeEquivalentTo(fakeResultWithData, cnf => cnf.Excluding(x => x.Results)); + + actual.Results.Should().NotBeNullOrEmpty(); + actual.Results.Should().HaveCount(fakeResultWithData.Results!.Count); + + foreach (var (expected, actualSession) in fakeResultWithData.Results.Zip(actual.Results, (e, a) => (e, a))) + { + actualSession.SubjectId.Should().Be(expected.Session.SubjectId); + actualSession.SessionId.Should().Be(expected.Session.SessionId); + actualSession.DisplayName.Should().Be(expected.Session.DisplayName); + actualSession.Created.Should().Be(expected.Session.Created); + actualSession.Renewed.Should().Be(expected.Session.Renewed); + actualSession.Expires.Should().Be(expected.Session.Expires); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(expected.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(expected.AuthTicket!.Properties.GetClientList()); + } + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); + } + + private static readonly string[] AllGrantTypes = [..IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes, IdentityServerConstants.PersistedGrantTypes.UserConsent]; + private static readonly string[] TokenGrantTypes = [..IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes]; + private static readonly string[] ConsentGrantTypes = [IdentityServerConstants.PersistedGrantTypes.UserConsent]; + + [Theory] + [InlineData(null, "session-002")] + [InlineData("alice", null)] + [InlineData("alice", "session-002")] + public async Task RemoveSessionsAsync_WhenFilterSpecified_ShouldRemoveAllSessionsWithUsingFilter(string? testSubjectIdFilter, string? testSessionIdFilter) + { + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(testSubjectIdFilter, testSessionIdFilter)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = testSubjectIdFilter, SessionId = testSessionIdFilter, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds == fakeSession.AuthTicket.Properties.GetClientList()))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds == (fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBackchannelNotificationsAndRevocationsForThoseClients() + { + string[] fakeClientIds = ["client-a", "client-b", "client-c", "client-d"]; + var fakeSession = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice", clientIds: fakeClientIds); + List fakeSessions = [fakeSession]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets("alice", null)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = "alice", SessionId = null, ClientIds = ["client-b", "client-d", "client-f"], + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + string[] expectedClientIds = ["client-b", "client-d"]; + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + ctx.ClientIds.ToHashSet().SetEquals(expectedClientIds)))); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RemoveServerSideSession = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId), Times.Never); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenSendBackchannelFalse_ShouldNotSendBackchannelNotification() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, SendBackchannelLogoutNotification = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeTokensFalse_ShouldNotRevokeTokens() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeTokens = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(ConsentGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeConsentsFalse_ShouldNotRevokeConsents() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeConsents = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(TokenGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeTokensAndConsentsFalse_ShouldNotRevokeAnyGrants() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeTokens = false, RevokeConsents = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + RemoveSessionsContext fakeRemovalContext = new RemoveSessionsContext(); + + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.QuerySessionsAsync(null), "QuerySessionsAsync"), + (store => store.RemoveSessionsAsync(fakeRemovalContext), "RemoveSessionsAsync"), + ]; + + DefaultSessionManagementService sut = CreateSut(); + + foreach ((Func actMethod, string traceMethodName) method in methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, method.traceMethodName)); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(DefaultSessionManagementService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(DefaultSessionManagementService)) + .Select(m => m.Name) + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } +} \ 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..10585279f --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -0,0 +1,780 @@ +// 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.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Extensions; +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 IServiceProvider serviceProvider = Mock.Of(); + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly ITrace trace = Mock.Of(); + private readonly FakeTimeProvider timeProvider = new(); + private readonly ILogger logger = TestLogger.Create(); + + // Server-Sessions services + private readonly IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly IIdentityServerServerSideSessionStore identityServerServerSideSessionStore = Mock.Of(); + + private readonly DateTime fakeNow = new(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); + + public DefaultUserSessionEventsServiceTests() + { + timeProvider.SetUtcNow(fakeNow); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServerSessionTicketStore))) + .Returns(serverSessionTicketStore); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IIdentityServerServerSideSessionStore))) + .Returns(identityServerServerSideSessionStore); + } + + private DefaultUserSessionEventsService CreateSut() => new( + clientStore, + persistedGrantStore, + backChannelLogoutService, + serviceProvider, + idsOptions, + telemetry, + timeProvider, + 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) + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionLogout(endUserSessionCtx); + + 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() + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(endUserSessionCtx); + + 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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [..clients.Select(x => x.ClientId).ToList(), "fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.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) + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionExpiry(endUserSessionCtx); + + 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() + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + 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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.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); + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_ShouldReturnTrue() + { + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServerSessionTicketStore))) + .Returns(null!); + + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = true + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Fact] + private async Task ValidateRefreshTokenAsync_WhenNoSessionStoreRegistered_ShouldReturnTrue() + { + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IIdentityServerServerSideSessionStore))) + .Returns(null!); + + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = true + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, false)] + private async Task ValidateRefreshTokenAsync_CoordinationDisabled_ShouldCallDecoratedAndReturnResponse(bool authOpt, bool? clientVal) + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = authOpt; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = clientVal + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Theory] + [InlineData(true, null)] + [InlineData(false, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithoutValidSessions_ShouldReturnFalse(bool authOpt, bool? clientVal) + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = authOpt; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = clientVal + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeFalse(); + } + + [Theory] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(true, false)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndIsNonPersistantOrDoesntAllowRefresh_ShouldUpdateSession(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + IdentityServerServerSideSessions? updatedSession = null; + Mock.Get(identityServerServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback(x => updatedSession = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedSession.Should().BeEquivalentTo(fakeSession, opt => opt + .Excluding(y => y.Renewed) + .Excluding(y => y.Expires)); + + updatedSession.Renewed.Should().Be(fakeNow); + updatedSession.Expires.Should().Be(fakeNow.AddDays(29)); + + Mock.Get(identityServerServerSideSessionStore) + .Verify(x => x.UpdateSession(It.IsAny())); + } + + [Theory] + [InlineData(true, null)] + [InlineData(true, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndNoSlidingExpiration_AndIsPersistantAndAllowRefresh_ShouldUpdateSession(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CookieSlidingExpiration = false; + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + IdentityServerServerSideSessions? updatedSession = null; + Mock.Get(identityServerServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback(x => updatedSession = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedSession.Should().BeEquivalentTo(fakeSession, opt => opt + .Excluding(y => y.Renewed) + .Excluding(y => y.Expires)); + + updatedSession.Renewed.Should().Be(fakeNow); + updatedSession.Expires.Should().Be(fakeNow.AddDays(29)); + + Mock.Get(identityServerServerSideSessionStore) + .Verify(x => x.UpdateSession(It.IsAny())); + } + + [Theory] + [InlineData(true, null)] + [InlineData(true, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndSlidingExpiration_AndIsPersistantAndAllowRefresh_ShouldRenewTicketAndTriggerCookieRefresh(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CookieSlidingExpiration = true; + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + AuthenticationTicket? updatedTicket = null; + Mock.Get(serverSessionTicketStore) + .Setup(x => x.RenewAsync(It.IsAny(), It.IsAny())) + .Callback((_, x) => updatedTicket = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedTicket.Should().BeEquivalentTo(fakeAuthTicket); + updatedTicket.Properties.IssuedUtc.Should().Be(fakeNow); + updatedTicket.Properties.ExpiresUtc.Should().Be(fakeNow.AddDays(29)); + updatedTicket.Properties.GetString(IdentityServerConstants.ForceCookieRefresh).Should().BeEmpty(); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.RenewAsync(It.IsAny(), It.IsAny())); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + EndUserSessionEventContext endCtx = new EndUserSessionEventContext + { + SessionId = "sessionId", SubjectId = "subjectId", ClientIds = [], + }; + ValidateUserSessionEventContext validateCtx = new ValidateUserSessionEventContext + { + SessionId = "sessionId", SubjectId = "subjectId", Client = new Client(), + }; + + List<(Func actMethod, string traceMethodName)> methods = + [ + (store => store.HandleUserSessionLogout(endCtx), "HandleUserSessionLogout"), + (store => store.HandleUserSessionExpiry(endCtx), "HandleUserSessionExpiry"), + (store => store.ValidateSession(validateCtx), "ValidateSession"), + ]; + + DefaultUserSessionEventsService sut = CreateSut(); + + foreach ((Func actMethod, string traceMethodName) method in + methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(DefaultUserSessionEventsService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(DefaultUserSessionEventsService)) + .Select(m => m.Name) + .Distinct() + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + + private void SetupClientStore(IEnumerable clients) + { + foreach (Client client in clients) + { + Mock.Get(clientStore) + .Setup(x => x.FindClientByIdAsync(client.ClientId)) + .ReturnsAsync(client); + } + } + + private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, + string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null, + bool isPersistent = false, bool? allowRefresh = false) + { + IdentityServerUser user = new(subjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(sessionId); + + user.DisplayName = displayName; + + properties.IssuedUtc = issuedUtc; + properties.ExpiresUtc = expiresUtc; + properties.IsPersistent = isPersistent; + properties.AllowRefresh = allowRefresh; + + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); + } + + private IdentityServerServerSideSessions FakeSession( + string key, + string scheme, + string sessionId, + string subjectId, + string displayName, + string? data = null, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null) + { + return new IdentityServerServerSideSessions + { + Key = key, Scheme = scheme, SessionId = sessionId, SubjectId = subjectId, DisplayName = displayName, Data = data ?? string.Empty, + Created = created ?? new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = renewed ?? new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = expires ?? new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + }; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs new file mode 100644 index 000000000..685b7d555 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs @@ -0,0 +1,227 @@ +// 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.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.EntityFramework; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services; + +public class SessionCleanupServiceTests +{ + private IdentityServerOptions options = new(); + + private IServerSessionTicketStore serverSideSessionStore = + Mock.Of(); + + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + private ILogger logger = Mock.Of>(); + + private SessionCleanupService CreateSut() => + new(options, serverSideSessionStore, userSessionEventsService, telemetry, logger); + + [Fact] + public async Task + RemoveExpiredServerSideSessionsAsync_WhenExpiredServerSideSessionExist_ExpectExpiredDeviceGrantsRemoved() + { + var expiredSession = FakeSessionSession("123", "sesh1", true); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Once); + } + + [Fact] + public async Task RemoveExpiredServerSideSessionsAsync_WhenValidServerSideSessionExist_ExpectValidDeviceGrantsInDb() + { + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + RemoveExpiredServerSideSessionsAsync_WhenMultipleExpiredServerSideSessionExist_ExpectExpiredDeviceGrantsRemoved() + { + options.ServerSideSessions.RemoveExpiredSessionsBatchSize = 2; + + var expiredSession0 = FakeSessionSession("123", "sesh1", true); + var expiredSession1 = FakeSessionSession("456", "sesh2", true); + var expiredSession2 = FakeSessionSession("789", "sesh3", true); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession0, expiredSession1]) + .ReturnsAsync([expiredSession2]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize), + Times.Exactly(2)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async Task RemoveExpiredServerSideSessionsAsync_WhenExpiredSessionContainsClientList_ExpectExpiredDeviceGrantsRemoved() + { + string[] fakeClientIds = ["client1", "client2"]; + var expiredSession0 = FakeSessionSession("123", "sesh1", true, fakeClientIds); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession0]); + + EndUserSessionEventContext? context = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionExpiry(It.IsAny())) + .Callback(x => context = x); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize), + Times.Once); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Once); + + context.Should().NotBeNull(); + context.SessionId.Should().Be(expiredSession0.Session.SessionId); + context.SubjectId.Should().Be(expiredSession0.Session.SubjectId); + context.ClientIds.Should().BeEquivalentTo(fakeClientIds); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + List<(Func actMethod, string traceMethodName)> methods = + [ + (store => store.RemoveExpiredServerSideSessionsAsync(), "RemoveExpiredServerSideSessionsAsync") + ]; + + foreach ((Func actMethod, string traceMethodName) method in methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + SessionCleanupService store = CreateSut(); + + await method.actMethod(store); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, store, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(SessionCleanupService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(SessionCleanupService)) + .Select(m => m.Name) + .Distinct() + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + + private static AuthenticationTicketFilterResult FakeSessionSession(string subject, string sessionId, + bool expired = false, string[]? clientsIds = null) + { + var session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = DateTime.UtcNow.AddDays(-3), + Renewed = DateTime.UtcNow.AddDays(-3), + Expires = DateTime.UtcNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = DateTime.UtcNow.AddDays(-5); + session.Renewed = DateTime.UtcNow.AddDays(-4); + session.Expires = DateTime.UtcNow.AddDays(-3); + } + + return new AuthenticationTicketFilterResult + { + Session = session, + AuthTicket = GenerateAuthenticationTicket(session, clientsIds), + }; + } + + private static AuthenticationTicket GenerateAuthenticationTicket(IdentityServerServerSideSessions session, string[]? clientIds = null) + { + IdentityServerUser user = new(session.SubjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(session.SessionId); + + user.DisplayName = session.DisplayName; + properties.IssuedUtc = session.Renewed; + properties.ExpiresUtc = session.Expires; + + if (clientIds != null) + { + foreach (var clientId in clientIds) + { + properties.AddClientId(clientId); + } + } + + return new AuthenticationTicket(user.CreatePrincipal(), properties, session.Scheme); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs index b43068f67..0b4f392b5 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs @@ -17,7 +17,7 @@ using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; -using Open.IdentityServer.UnitTests; +using Open.IdentityServer.Test.Utilities; using Xunit; namespace Open.IdentityServer.UnitTests.Stores.Compatibility; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs index af18f1543..cd29d3468 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs @@ -15,7 +15,7 @@ using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; -using Open.IdentityServer.UnitTests; +using Open.IdentityServer.Test.Utilities; using Xunit; namespace Open.IdentityServer.UnitTests.Stores.Compatibility; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs index a7f5d1055..8801f0016 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs @@ -1,16 +1,16 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System.Collections.Generic; +using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.UnitTests.Common; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores.Default; -using System.Collections.Generic; -using System.Threading.Tasks; using Xunit; -namespace IdentityServer.UnitTests.Stores.Default; +namespace Open.IdentityServer.UnitTests.Stores.Default; public class DistributedCacheAuthorizationParametersMessageStoreTests { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs new file mode 100644 index 000000000..7a0273457 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -0,0 +1,707 @@ +// 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.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Time.Testing; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.DataProtection; +using Open.IdentityServer.EntityFramework.IntegrationTests; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Stores.Serialization; +using Open.IdentityServer.UnitTests.Utilities.Generators; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Stores.Default; + +public class ServerSessionTicketStoreTests +{ + private readonly IIdentityServerServerSideSessionStore serverServerSideSessionStore = + Mock.Of(); + + private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); + private readonly MockDataProtector dataProtector = new(); + private readonly FakeTimeProvider fakeTimeProvider = new(); + private readonly IHttpContextAccessor httpContextAccessor = Mock.Of(); + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly MockLogger logger = new(); + + private readonly IdentityServerOptions fakeOptions = new(); + private readonly IServiceProvider fakeserviceProvider = Mock.Of(); + private readonly HttpContext fakeHttpContext = new DefaultHttpContext(); + + private static readonly DateTime FakeNow = new(2026, 01, 01, 12, 0, 0, DateTimeKind.Utc); + private static readonly string FakeIssuer = "https://fake.issuer.com"; + + public ServerSessionTicketStoreTests() + { + fakeTimeProvider.SetUtcNow(FakeNow); + fakeOptions.IssuerUri = FakeIssuer; + fakeHttpContext.RequestServices = fakeserviceProvider; + + Mock.Get(dataProtectionProvider) + .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) + .Returns(dataProtector); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(It.IsAny())) + .ReturnsAsync(QueryResult.Empty); + + Mock.Get(fakeserviceProvider) + .Setup(x => x.GetService(typeof(IdentityServerOptions))) + .Returns(fakeOptions); + + Mock.Get(httpContextAccessor) + .Setup(x => x.HttpContext) + .Returns(fakeHttpContext); + } + + private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, + fakeTimeProvider, fakeOptions, httpContextAccessor, telemetry, logger); + + [Fact] + public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + string actualKey = await sut.StoreAsync(authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(actualKey); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(FakeNow); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Fact] + public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + const string displayName = "Fake User"; + DateTime issuedUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + + AuthenticationTicket authenticationTicket = + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + string actualKey = await sut.StoreAsync(authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(actualKey); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(issuedUtc); + createdSessionModel.Renewed.Should().Be(issuedUtc); + createdSessionModel.Expires.Should().Be(expiresUtc); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Theory] + [InlineData(JwtClaimTypes.Name, "Fake User")] + [InlineData(JwtClaimTypes.Email, null)] + public async Task StoreAsync_WhenDisplayNameClaimSet_ShouldUseClaimValueIfSet(string testType, string? expectedDisplayNameValue) + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + const string displayName = "Fake User"; + DateTime issuedUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + + fakeOptions.ServerSideSessions.UserDisplayNameClaimType = testType; + + AuthenticationTicket authenticationTicket = + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + string actualKey = await sut.StoreAsync(authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(actualKey); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().Be(expectedDisplayNameValue); + createdSessionModel.Created.Should().Be(issuedUtc); + createdSessionModel.Renewed.Should().Be(issuedUtc); + createdSessionModel.Expires.Should().Be(expiresUtc); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + Created = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + Data = "EXISTING_PROTEXTEDDAAT", + }; + + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + await sut.RenewAsync(existingSession.Key, authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(existingSession.Key); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(existingSession.Created); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + Created = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + Data = "EXISTING_PROTEXTEDDAAT", + }; + + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + const string displayName = "Fake User"; + DateTime issuedUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + + AuthenticationTicket authenticationTicket = + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + IdentityServerServerSideSessions? updatedSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback((session) => { updatedSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + await sut.RenewAsync(existingSession.Key, authenticationTicket); + + updatedSessionModel.Should().NotBeNull(); + updatedSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + updatedSessionModel.Key.Should().Be(existingSession.Key); + updatedSessionModel.Scheme.Should().Be(authScheme); + updatedSessionModel.SessionId.Should().Be(sessionId); + updatedSessionModel.SubjectId.Should().Be(subjectId); + updatedSessionModel.DisplayName.Should().Be(displayName); + updatedSessionModel.Created.Should().Be(existingSession.Created); + updatedSessionModel.Renewed.Should().Be(issuedUtc); + updatedSessionModel.Expires.Should().Be(expiresUtc); + + var jsonElement = JsonElement.Parse(updatedSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenNoExistingSessionWithKey_ShouldCreateNewSession() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + const string nonExistent = "NonExistentSession"; + await sut.RenewAsync(nonExistent, authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(nonExistent); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(FakeNow); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RetrieveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) + { + ServerSessionTicketStore sut = CreateSut(); + + Func act = async () => await sut.RetrieveAsync(key!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RetrieveAsync_WhenNoSessionStoredForKey_ShouldReturnNull() + { + ServerSessionTicketStore sut = CreateSut(); + + AuthenticationTicket? actual = await sut.RetrieveAsync("non-existent-session"); + + actual.Should().BeNull(); + } + + [Theory] + [InlineData("{invalid.json}")] + [InlineData("{\"Version\": 2, Payload: \"SOMEDATA\"}")] + public async Task RetrieveAsync_WhenSessionDataDeserialisationFails_ShouldReturnNull(string data) + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + Created = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + Data = data + }; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + ServerSessionTicketStore sut = CreateSut(); + AuthenticationTicket? actual = await sut.RetrieveAsync(existingSession.Key); + + actual.Should().BeNull(); + } + + [Fact] + public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserializedAuthTicket() + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + Created = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + }; + SerializedAuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateSerializedAuthenticationTicket( + existingSession.Scheme, existingSession.SubjectId, existingSession.SessionId, + existingSession.DisplayName, existingSession.Renewed, existingSession.Expires); + existingSession.Data = GenerateFakeData(authenticationTicket); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + ServerSessionTicketStore sut = CreateSut(); + AuthenticationTicket? actual = await sut.RetrieveAsync(existingSession.Key); + + actual.Should().BeOfType(); + actual.AuthenticationScheme.Should().Be(existingSession.Scheme); + actual.Principal.Identity.Should().NotBeNull(); + actual.Principal.Identity?.AuthenticationType.Should() + .BeEquivalentTo(authenticationTicket.User.AuthenticationType); + actual.Principal.Identity?.Name.Should().BeEquivalentTo(existingSession.DisplayName); + actual.Principal.Identities.Should().Contain(x => + x.NameClaimType == JwtClaimTypes.Name && + x.RoleClaimType == JwtClaimTypes.Role); + actual.Properties.Items.Should().BeEquivalentTo(authenticationTicket.Items); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RemoveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) + { + ServerSessionTicketStore sut = CreateSut(); + + Func act = async () => await sut.RemoveAsync(key!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RemoveAsync_ShouldCallServerSideSessionStoreDelete() + { + string keyId = Guid.NewGuid().ToString(); + + ServerSessionTicketStore sut = CreateSut(); + await sut.RemoveAsync(keyId); + + Mock.Get(serverServerSideSessionStore) + .Verify(x => x.DeleteSession(keyId)); + } + + [Theory] + [InlineData("{invalid.json}")] + [InlineData("{\"Version\": 2, Payload: \"SOMEDATA\"}")] + public async Task FilterServerAuthenticationTickets_WhenSessionDataDeserialisationFails_ShouldReturnSkipFailingAuthTicket(string data) + { + const string testSubjectId = "bob"; + const string testSessionId = "session-0"; + + IEnumerable sessions = [ + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith", data: data), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(session => GenerateSerialisedData(expectedAuthTickets, session)); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + } + + [Fact] + public async Task FilterServerAuthenticationTickets_WhenNotingReturnedByStore_ShouldReturnEmptyCollection() + { + const string testSubjectId = "bob"; + const string testSessionId = "session-0"; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync([]); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterServerAuthenticationTickets_WhenSessionReturnedFromStore_ShouldReturnExtractedAuthTickets() + { + const string testSubjectId = "bob"; + const string testSessionId = "session-0"; + + IEnumerable sessions = [ + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)) + .ToList(); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + + foreach (var session in sessions) + { + ValidateAutTicketExists(actual, session); + } + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenSessionReturnedFromStore_ShouldReturnExtractedAuthTickets() + { + const int batchSize = 5; + + IEnumerable sessions = [ + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)) + .ToList(); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetAndRemoveExpiredSessions(batchSize)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.GetAndRemoveExpiredSessions(batchSize)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + + foreach (var session in sessions) + { + ValidateAutTicketExists(actual, session); + } + } + + private void ValidateAutTicketExists(IEnumerable result, IdentityServerServerSideSessions sesison) + { + result.Should().Contain(x => + x.AuthTicket != null && + x.AuthTicket.AuthenticationScheme == sesison.Scheme && + x.AuthTicket.Principal.GetSubjectId() == sesison.SubjectId && + x.AuthTicket.Properties.GetSessionId() == sesison.SessionId); + } + + [Fact] + public async Task FilterServerAuthenticationTickets_WhenQueryProvided_ShouldCallFilterWithQuery_AndReturnResponseWithDeserializedAuthTickets() + { + SessionQuery fakeQuery = new SessionQuery + { + SubjectId = "bob", + SessionId = "session-0", + }; + + QueryResult fakeResult; + IEnumerable sessions = [ + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ]; + + List expectedAuthTickets = []; + fakeResult = new QueryResult + { + + Results = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)).ToList(), + }; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(fakeQuery, It.IsAny())) + .ReturnsAsync(fakeResult); + + ServerSessionTicketStore sut = CreateSut(); + QueryResult actual = + (await sut.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); + + actual.Should().NotBeNull(); + actual.Should().BeEquivalentTo(fakeResult, cnf => cnf.Excluding(x => x.Results)); + actual.Results.Should().NotBeNullOrEmpty(); + actual.Results.Should().HaveCount(expectedAuthTickets.Count); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + AuthenticationTicket authTicket = + ServerSessionTestGenerators.GenerateAuthenticationTicket("FakeScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.StoreAsync(authTicket), "StoreAsync"), + (store => store.RenewAsync("FAKE_KEY", authTicket), "RenewAsync"), + (store => store.RetrieveAsync("FAKE_KEY"), "RetrieveAsync"), + (store => store.RemoveAsync("FAKE_KEY"), "RemoveAsync"), + (store => store.FilterServerAuthenticationTickets("FAKE_SUB_KEY", "FAKE_SESSION_KEY"), "FilterServerAuthenticationTickets"), + (store => store.FilterServerAuthenticationTickets(new SessionQuery()), "FilterServerAuthenticationTickets"), + (store => store.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), + ]; + + var sut = CreateSut(); + + foreach (var method in methods) + { + var trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Stores, sut, method.traceMethodName)); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(ServerSessionTicketStore).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(ServerSessionTicketStore)) + .Select(m => m.Name) + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + + private IdentityServerServerSideSessions GenerateSerialisedData( + List expectedAuthTickets, + IdentityServerServerSideSessions identityServerServerSideSessions) + { + if (string.IsNullOrWhiteSpace(identityServerServerSideSessions.Data)) + { + SerializedAuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateSerializedAuthenticationTicket( + identityServerServerSideSessions.Scheme, identityServerServerSideSessions.SubjectId, identityServerServerSideSessions.SessionId, + identityServerServerSideSessions.DisplayName, identityServerServerSideSessions.Renewed, identityServerServerSideSessions.Expires); + identityServerServerSideSessions.Data = GenerateFakeData(authenticationTicket); + + expectedAuthTickets.Add(authenticationTicket); + } + + return identityServerServerSideSessions; + } + + private string GenerateFakeData(SerializedAuthenticationTicket serializedAuthenticationTicket) + { + DataProtectedSessionData sessionData = new DataProtectedSessionData + { + Payload = dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(serializedAuthenticationTicket, + ServerSessionTicketStore.JsonSettings)) + }; + + return JsonSerializer.Serialize(sessionData, ServerSessionTicketStore.JsonSettings); + } +} \ 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/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs new file mode 100644 index 000000000..838453365 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -0,0 +1,780 @@ +// 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.Runtime.InteropServices; +using System.Threading.Tasks; +using AwesomeAssertions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Stores; + +public class InMemorySessionStoreTests +{ + private InMemorySessionStore CreateSut(IEnumerable? seedSessions = null) + { + InMemorySessionStore sut = new InMemorySessionStore(); + + foreach (var seedSession in seedSessions ?? []) + { + sut.CreateSession(seedSession); + } + + return sut; + } + + [Fact] + public async Task GetSession_WhenSessionWithKeyIsntStored_ShouldReturnNull() + { + InMemorySessionStore sut = CreateSut(); + + IdentityServerServerSideSessions? actual = await sut.GetSession("non-session-key"); + + actual.Should().BeNull(); + } + + [Fact] + public async Task GetSession_WhenSessionWithKeyStored_ShouldReturnSession() + { + const string testKey = "session-2"; + IdentityServerServerSideSessions testSession = new() { Key = testKey, DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + testSession, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + + actual.Should().BeEquivalentTo(testSession); + } + + [Fact] + public async Task CreateSession_WhenSessionWithKeyExists_ShouldStoreSession() + { + IdentityServerServerSideSessions existingSession = new() + { + Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut([existingSession]); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(newSession.Key); + preTestMethodsCall.Should().BeEquivalentTo(existingSession); + + await sut.CreateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(newSession.Key); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task CreateSession_WhenSessionWithKeyExists_ShouldNotThrow() + { + IdentityServerServerSideSessions existingSession = new() + { + Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut([existingSession]); + + Func act = async () => await sut.CreateSession(newSession); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateSession_WhenDoesntExist_ShouldStoreSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions sessionToCreate = new() + { + Key = testKey, DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeNull(); + + await sut.CreateSession(sessionToCreate); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(sessionToCreate); + } + + [Fact] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + + await sut.UpdateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldNotThrow() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + + Func act = async () => await sut.UpdateSession(newSession); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpdateSession_WhenSessionExistsWithKey_ShouldReplaceStoredSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions existingSession = new() + { + Key = testKey, DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut([existingSession]); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeEquivalentTo(existingSession); + + await sut.UpdateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task DeleteSession_WhenSessionDoesntExists_ShouldNotThrow() + { + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; + InMemorySessionStore sut = CreateSut(seededSessions); + + Func act = async () => await sut.DeleteSession("non-exitsnt-session"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteSession_WhenSessionExists_ShouldBeRemoved() + { + const string testKey = "session-2"; + IdentityServerServerSideSessions testSession = new() { Key = testKey, DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + testSession, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeEquivalentTo(testSession); + + await sut.DeleteSession(testKey); + + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeNull(); + } + + [Theory] + [InlineData(null, null)] + [InlineData(null, "")] + [InlineData(null, " ")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task DeleteSessions_WhenFiltersNullOrEmpty_ShouldThrowArgumentException(string? subjectId, string? sessionId) + { + InMemorySessionStore sut = CreateSut(); + + Func act = async () => await sut.DeleteSessions(subjectId, sessionId); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteSessions_WhenSubjectIdProvided_ShouldDeleteSessionsWithSubjectId() + { + var testSessionKey1 = "session-0"; + var testSessionKey2 = "session-3"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions("bob",null); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } + + [Fact] + public async Task DeleteSessions_WhenSessionIdProvided_ShouldDeleteSessionsWithSessionId() + { + var testSessionKey1 = "session-1"; + var testSessionKey2 = "session-2"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions(null, "sessionA"); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } + + [Fact] + public async Task DeleteSessions_WhenSubjectIdAndSessionIdProvided_ShouldDeleteSessionsWithSubjectIdAndSessionId() + { + var testSessionKey1 = "session-1"; + var testSessionKey2 = "session-3"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = "sessionA", SubjectId = "bob" }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = "sessionA", SubjectId = "bob" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions("bob", "sessionA"); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } + + [Fact] + public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WhenNoSessionsStored_ShouldReturnEmptySet() + { + InMemorySessionStore sut = CreateSut(); + + var actual = (await sut.FilterSessions("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WhenSessionIdAndSubjectIdNull_ShouldReturnAll() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions(null, null)).ToList(); + + actual.Should().HaveCount(7); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-2"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-4"); + actual.Should().Contain(x => x.Key == "key-5"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WhenSessionIdNull_ShouldReturnMatchingSubjectIdOnly() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions("alice", null)).ToList(); + + actual.Should().HaveCount(3); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WhenSubjectIdNull_ShouldReturnMatchingSessionIdOnly() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions(null, "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions("alice", "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + /// TODO: implement filter with query tests, types of query to test + /// 1. When no filter is provided, should use default values + /// 2. When no token is provided, it should get the first page of results + /// 3. When a token is provided, it should get the next page relative to the provided token + /// 4. When a subjectId filter is provided, it should filter the results using it + /// 5. When a sessionId filter is provided, it should filter results using it + /// 6. When a display name filter is provided, it should filter results using it + + [Fact] + public async Task FilterSessions_WithQuery_WhenNoResults_ShouldEmptyResultsSet() + { + InMemorySessionStore sut = CreateSut(); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.TotalPages.Should().Be(0); + actual.ResultsToken.Should().BeNullOrWhiteSpace(); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenNullQuery_ShouldUseDefaultValues() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(6).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(7); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenNoTokenInQuery_ShouldGetFirstPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(1).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousFalse_ShouldGetNextPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var testToken = $"{seededSessions.ElementAt(4).Key},{seededSessions.ElementAt(5).Key}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = false, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(6).Key},{seededSessions.ElementAt(6).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(4); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(1); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousTrue_ShouldGetNextPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var testToken = $"{seededSessions.ElementAt(4).Key},{seededSessions.ElementAt(5).Key}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = true, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(3); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(testToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenSessionIdProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SessionId = "session-0", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(4).Key}"; + + actual.TotalCount.Should().Be(2); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenSubjectIdProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SubjectId = "bob", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(2).Key}"; + + actual.TotalCount.Should().Be(4); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenDisplayNameProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + DisplayName = "Laura", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(1).Key},{seededSessions.ElementAt(3).Key}"; + + actual.TotalCount.Should().Be(3); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenNoExpiredSessionsExist_ShouldRemoveNothingAndReturnEmptyCollection() + { + IdentityServerServerSideSessions validSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession1 = FakeSessionSession("456", "session2"); + + InMemorySessionStore sut = CreateSut([validSession0, validSession1]); + + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession1.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().BeEmpty(); + + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession1.Key)).Should().NotBeNull(); + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndUnderBatchSize_ShouldDeleteExpiredSessionsAndReturnACollectionContainingRemovedSessions() + { + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + + InMemorySessionStore sut = CreateSut([expiredSession0, expiredSession1]); + + (await sut.GetSession(expiredSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + + (await sut.GetSession(expiredSession0.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().BeNull(); + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndExceedBatchSize_ShouldDeleteAndReturnExpiredSessions_WithACountOfBatchSize() + { + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + IdentityServerServerSideSessions expiredSession2 = FakeSessionSession("789", "session3", true); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + + InMemorySessionStore sut = CreateSut([expiredSession0, expiredSession1, expiredSession2, validSession0]); + + (await sut.GetSession(expiredSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession2.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + actual.Should().NotContain(x => x.Key == expiredSession2.Key); + actual.Should().NotContain(x => x.Key == validSession0.Key); + + (await sut.GetSession(expiredSession0.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession2.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + } + + private static IdentityServerServerSideSessions FakeSessionSession(string subject, string sessionId, bool expired = false) + { + IdentityServerServerSideSessions session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = DateTime.UtcNow.AddDays(-3), + Renewed = DateTime.UtcNow.AddDays(-3), + Expires = DateTime.UtcNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = DateTime.UtcNow.AddDays(-5); + session.Renewed = DateTime.UtcNow.AddDays(-4); + session.Expires = DateTime.UtcNow.AddDays(-3); + } + + return session; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs new file mode 100644 index 000000000..98d7fb1c0 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs @@ -0,0 +1,138 @@ +// Copyright (c) 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 System.Collections.Generic; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.UnitTests.Utilities.Generators; + +public static class ServerSessionTestGenerators +{ + public static AuthenticationTicket GenerateAuthenticationTicket( + string authScheme, + string? subjectId, + string? sessionId, + string? displayName = null, + DateTimeOffset? issuedUtc = null, + DateTimeOffset? expiresUtc = null, + string? issuer = null, + string[]? clientIds = null) + { + IdentityServerUser user = new(subjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(sessionId); + + user.DisplayName = displayName; + properties.IssuedUtc = issuedUtc; + properties.ExpiresUtc = expiresUtc; + + foreach (var clientId in clientIds ?? []) + { + properties.AddClientId(clientId); + } + + if (issuer != null) + { + properties.Items[JwtClaimTypes.Issuer] = issuer; + } + + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); + } + + public static SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket( + string authScheme, + string? subjectId, + string? sessionId, + string? displayName = null, + DateTimeOffset? issuedUtc = null, + DateTimeOffset? expiresUtc = null) + { + List claims = []; + + if (subjectId != null) + { + claims.Add(new ClaimLite { Type = "sub", Value = subjectId, ValueType = "", Issuer = "", }); + } + + if (displayName != null) + { + claims.Add(new ClaimLite { Type = "name", Value = displayName, ValueType = "", Issuer = "", }); + } + + var items = new Dictionary(); + + if (sessionId != null) + { + items["session_id"] = sessionId; + } + + if (issuedUtc != null) + { + items[".issued"] = issuedUtc.Value.ToString("R"); + } + + if (expiresUtc != null) + { + items[".expires"] = expiresUtc.Value.ToString("R"); + } + + return new SerializedAuthenticationTicket + { + Scheme = authScheme, + User = new ClaimsPrincipalLite + { + AuthenticationType = "Open.IdentityServer", + Claims = claims.ToArray(), + }, + Items = items, + }; + } + + public static IdentityServerServerSideSessions FakeSession( + string key, + string scheme, + string sessionId, + string subjectId, + string displayName, + string? data = null, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null) + { + return new IdentityServerServerSideSessions + { + Key = key, Scheme = scheme, SessionId = sessionId, SubjectId = subjectId, DisplayName = displayName, Data = data ?? string.Empty, + Created = created ?? new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = renewed ?? new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = expires ?? new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + }; + } + + public static AuthenticationTicketFilterResult GenerateAuthenticationTicketFilterResult( + string key, + string authScheme, + string subjectId, + string sessionId, + string displayName, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null, + string? issuer = null, + string[]? clientIds = null) + { + created ??= new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + renewed ??= new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc); + expires ??= new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc); + + return new AuthenticationTicketFilterResult + { + Session = FakeSession(key, authScheme, sessionId, subjectId, displayName, string.Empty, created, renewed, expires), + AuthTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, renewed, expires, issuer, clientIds), + }; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs new file mode 100644 index 000000000..07b0da6c2 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs @@ -0,0 +1,208 @@ +// 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.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Validation.Default; + +public class DefaultServerSideSessionRefreshTokenServiceTests +{ + private IRefreshTokenService decoratedService = Mock.Of(); + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + + private DefaultServerSideSessionRefreshTokenService CreateSut() => + new(new Decorator(decoratedService), userSessionEventsService, telemetry); + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsInvalid_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + IsError = true, + Error = "fake_error" + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsValid_AndValidateUserSessionUnsuccessful_ShouldReturnErrorResult() + { + const string fakeToken = "fake_token"; + const string fakeSubject = "fakeSubject"; + const string fakeSession = "fakeSession"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + RefreshToken = new RefreshToken { Subject = new IdentityServerUser(fakeSubject).CreatePrincipal(), SessionId = fakeSession }, + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(false); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.IsError.Should().BeTrue(); + actual.Error.Should().Be(OidcConstants.ProtectedResourceErrors.InvalidToken); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsValid_AndValidateUserSessionSuccessful_ShouldReturnDecoratedServiceResult() + { + const string fakeToken = "fake_token"; + const string fakeSubject = "fakeSubject"; + const string fakeSession = "fakeSession"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + RefreshToken = new RefreshToken { Subject = new IdentityServerUser(fakeSubject).CreatePrincipal(), SessionId = fakeSession }, + Client = fakeClient, IsError = false, + }; + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(true); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task CreateRefreshTokenAsync_ShouldJustUseDecoratedService() + { + RefreshTokenCreationRequest fakeRequest = new RefreshTokenCreationRequest + { + Subject = null, AccessToken = null, Client = null, AuthorisedScopes = null, + AuthorisedResourceIndicators = null, RequestedResourceIndicator = null + }; + string fakeToken = "fake_refresh_token"; + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.CreateRefreshTokenAsync(fakeRequest)) + .ReturnsAsync(fakeToken); + + string actual = await sut.CreateRefreshTokenAsync(fakeRequest); + + Mock.Get(decoratedService) + .Verify(x => x.CreateRefreshTokenAsync(fakeRequest)); + + actual.Should().Be(fakeToken); + } + + [Fact] + public async Task UpdateRefreshTokenAsync_ShouldJustUseDecoratedService() + { + string fakeHandle = "fakeHandle"; + RefreshToken fakeRefreshToken = new RefreshToken(); + Client fakeClient = new Client(); + string fakeToken = "fake_refresh_token"; + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient)) + .ReturnsAsync(fakeToken); + + string actual = await sut.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient); + + Mock.Get(decoratedService) + .Verify(x => x.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient)); + + actual.Should().Be(fakeToken); + } + + [Fact] + public async Task PublicMethods_WithCustomisedLogic_WhenCalled_ShouldTelemetryTrace() + { + string fakeHandle = "fakeHandle"; + Client fakeClient = new Client(); + string fakeToken = "fake_refresh_token"; + + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.ValidateRefreshTokenAsync(fakeToken, fakeClient), "ValidateRefreshTokenAsync"), + ]; + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + foreach ((Func actMethod, string traceMethodName) method in methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Validation, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + // typeof(DefaultServerSideSessionRefreshTokenService).GetMethods() + // .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + // .Where(m => m.DeclaringType == typeof(DefaultServerSideSessionRefreshTokenService)) + // .Select(m => m.Name) + // .Distinct() + // .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs new file mode 100644 index 000000000..232c26f2a --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs @@ -0,0 +1,219 @@ +// 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.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Validation.Default; + +public class DefaultServerSideSessionTokenValidatorTests +{ + private ITokenValidator decoratedService = Mock.Of(); + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + + private DefaultServerSideSessionTokenValidator CreateSut() => + new(new Decorator(decoratedService), userSessionEventsService, telemetry); + + + [Fact] + public async Task ValidateAccessTokenAsync_WhenDecoratedResultIsInvalid_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + + var fakeResult = new TokenValidationResult { Claims = [], IsError = true, Error = "fake_error" }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenDoesntContainsSubjectSessionClaims_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + + var fakeResult = new TokenValidationResult { Claims = [], IsError = false, }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenContainsSubjectSessionClaims_AndSessionInValid_ShouldReturnInavlidSessionError() + { + const string fakeToken = "fake_token"; + const string fakeSession = "fakeSession"; + const string fakeSubject = "fakeSubject"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + Claims = + [ + new Claim(JwtClaimTypes.SessionId, fakeSession), + new Claim(JwtClaimTypes.Subject, fakeSubject), + ], + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(false); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.IsError.Should().BeTrue(); + actual.Error.Should().Be(OidcConstants.ProtectedResourceErrors.InvalidToken); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenContainsSubjectSessionClaims_AndSessionValid_ShouldReturnDecoratedServiceResult() + { + const string fakeToken = "fake_token"; + const string fakeSession = "fakeSession"; + const string fakeSubject = "fakeSubject"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + Claims = + [ + new Claim(JwtClaimTypes.SessionId, fakeSession), + new Claim(JwtClaimTypes.Subject, fakeSubject), + ], + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(true); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task ValidateIdentityTokenAsync_ShouldJustUseDecoratedService() + { + string fakeToken = "fake_refresh_token"; + string fakeClientId = ""; + bool fakeValidateLifetime = false; + + TokenValidationResult fakeResult = new TokenValidationResult(); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime)) + .ReturnsAsync(fakeResult); + + TokenValidationResult actual = + await sut.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime); + + Mock.Get(decoratedService) + .Verify(x => x.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime)); + + actual.Should().Be(fakeResult); + } + + [Fact] + public async Task PublicMethods_WithCustomisedLogic_WhenCalled_ShouldTelemetryTrace() + { + string fakeToken = "fake_refresh_token"; + + List<(Func actMethod, string traceMethodName)> methods + = + [ + (store => store.ValidateAccessTokenAsync(fakeToken), "ValidateAccessTokenAsync"), + ]; + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + foreach ((Func actMethod, string traceMethodName) method in + methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Validation, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + // typeof(DefaultServerSideSessionTokenValidator).GetMethods() + // .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + // .Where(m => m.DeclaringType == typeof(DefaultServerSideSessionTokenValidator)) + // .Select(m => m.Name) + // .Distinct() + // .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } +} \ 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/Models/Compatibility/IdentityServerServerSideSessions.cs b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs new file mode 100644 index 000000000..67995e71d --- /dev/null +++ b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs @@ -0,0 +1,59 @@ +// 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; + +namespace Open.IdentityServer.Models; + +/// +/// A model for Server Side Sessions stored in a IdentityServer database +/// +public class IdentityServerServerSideSessions +{ + /// + /// Get or set key + /// + public string Key { get; set; } = null!; + + /// + /// Get or set scheme + /// + public string Scheme { get; set; } = null!; + + /// + /// Get or set subject identifier + /// + public string SubjectId { get; set; } = null!; + + /// + /// Get or set session identifier + /// + public string? SessionId { get; set; } + + /// + /// Get or set display name + /// + public string? DisplayName { get; set; } + + /// + /// Get or set created datetime + /// + public DateTime Created { get; set; } + + /// + /// Get or set renewed datetime + /// + public DateTime Renewed { get; set; } + + /// + /// Get or set expires datetime + /// + public DateTime? Expires { get; set; } + + /// + /// Get or set data value + /// + public string Data { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Storage/src/Models/QueryResult.cs b/src/Storage/src/Models/QueryResult.cs new file mode 100644 index 000000000..823328771 --- /dev/null +++ b/src/Storage/src/Models/QueryResult.cs @@ -0,0 +1,87 @@ +// 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; + +namespace Open.IdentityServer.Models; + +/// +/// Results from a query request +/// +/// Type of the results being returned +public class QueryResult +{ + /// + /// Token containing information on results. Contains first and last item ids in format 'first,last' + /// + public string? ResultsToken { get; init; } + + /// + /// If false, this is the first page of results; if true, then it is not + /// + public bool HasPrevResults { get; set; } + + /// + /// If false, this is the last page of results; if true then it is not + /// + public bool HasNextResults { get; set; } + + /// + /// Total results for query + /// + public int? TotalCount { get; init; } + + /// + /// Total pages for query + /// + public int? TotalPages { get; init; } + + /// + /// Current number of pages of results + /// + public int? CurrentPage { get; init; } + + /// + /// The results for the current page + /// + public IReadOnlyCollection Results { get; init; } = []; + + /// + /// Creates an empty instance of + /// + /// + public static QueryResult Empty() => new() + { + ResultsToken = null, + HasPrevResults = false, + HasNextResults = false, + TotalCount = 0, + TotalPages = 0, + CurrentPage = 0, + Results = [], + }; + + /// + /// Maps a QueryResult results set from one type to another + /// + /// mapping function to use + /// type to map results to + /// + public QueryResult MapTo(Func mapper) + { + return new QueryResult + { + ResultsToken = ResultsToken, + HasPrevResults = HasPrevResults, + HasNextResults = HasNextResults, + TotalCount = TotalCount, + TotalPages = TotalPages, + CurrentPage = CurrentPage, + Results = Results.Select(mapper).ToList() + }; + } +} \ No newline at end of file diff --git a/src/Storage/src/Models/SessionQuery.cs b/src/Storage/src/Models/SessionQuery.cs new file mode 100644 index 000000000..9dbf93319 --- /dev/null +++ b/src/Storage/src/Models/SessionQuery.cs @@ -0,0 +1,42 @@ +// 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 + +namespace Open.IdentityServer.Models; + +/// +/// Object containing query information to be applied to session queries +/// +public class SessionQuery +{ + /// + /// Token containing information on previously requested results. Contains first and last item ids in format 'first,last' + /// + public string? ResultsToken { get; set; } + + /// + /// If true, previous results are retrieved; else, next results relative to the results token are retrieved + /// + public bool RequestPriorResults { get; set; } + + /// + /// Number of results requested in response + /// + public int CountRequested { get; set; } = 25; + + /// + /// Optional subject identifier used to filter results + /// + public string? SubjectId { get; init; } + + /// + /// Optional session identifier used to filter results + /// + public string? SessionId { get; init; } + + /// + /// Optional display name used to filter results + /// + public string? DisplayName { get; init; } +} \ No newline at end of file diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs new file mode 100644 index 000000000..d19a8923c --- /dev/null +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -0,0 +1,76 @@ +// 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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// Storage and retrieval of server-side sessions +/// +public interface IIdentityServerServerSideSessionStore +{ + /// + /// Gets server-side session using unique key + /// + /// unique key of session + /// found session or null if not found + public Task GetSession(string key); + + /// + /// Stores the provided session model, must have a unique key set + /// + /// session model to store + /// void + public Task CreateSession(IdentityServerServerSideSessions session); + + /// + /// Updates the provided server-side session model. The model with a unique key must already exist in the store + /// + /// session model to update + /// void + public Task UpdateSession(IdentityServerServerSideSessions session); + + /// + /// Deletes server-side session using unique key + /// + /// unique key of session + /// void + public Task DeleteSession(string key); + + /// + /// Deletes server-side session using unique key + /// + /// subject id filter to apply + /// session id filter to apply + /// void + public Task DeleteSessions(string? subjectId, string? sessionId); + + /// + /// Filters server-side sessions using the provided filters + /// + /// subject id filter to apply + /// session id filter to apply + /// collection of session entities matching filter + public Task> FilterSessions(string? subjectId, string? sessionId); + + /// + /// Filters server-side sessions using the provided session query object + /// + /// query to applied to server auth tickets + /// cancellation token + /// QueryResult produced using provided query + public Task> FilterSessions(SessionQuery? query, CancellationToken ct = default); + + /// + /// Removes expired sessions and returns a collection of sessions that were removed + /// + /// optional batch size value, defaults to 100 + /// removed expired sessions + public Task> GetAndRemoveExpiredSessions(int batchSize = 100); +} \ No newline at end of file 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 diff --git a/src/Storage/src/Stores/Serialization/ClaimLite.cs b/src/Storage/src/Stores/Serialization/ClaimLite.cs index cc11fb55f..af3f97912 100644 --- a/src/Storage/src/Stores/Serialization/ClaimLite.cs +++ b/src/Storage/src/Stores/Serialization/ClaimLite.cs @@ -16,4 +16,6 @@ public class ClaimLite public string Value { get; set; } /// Gets or sets the claim value type. public string ValueType { get; set; } + /// Gets or sets the claim issuer. + public string Issuer { get; set; } } \ No newline at end of file diff --git a/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs b/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs new file mode 100644 index 000000000..33abc4703 --- /dev/null +++ b/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Open.IdentityServer.Stores.Serialization; + +/// +/// Model for serialized authentication ticket +/// +public class SerializedAuthenticationTicket +{ + /// + /// The authentication scheme + /// + public string Scheme { get; init; } = null!; + + /// + /// The authenticated user + /// + public ClaimsPrincipalLite User { get; init; } = null!; + + /// + /// The property items + /// + public IDictionary Items { get; init; } = null!; +} \ No newline at end of file diff --git a/src/Storage/src/TelemetryConstants.cs b/src/Storage/src/TelemetryConstants.cs index 52a4cd41b..8d9905ec4 100644 --- a/src/Storage/src/TelemetryConstants.cs +++ b/src/Storage/src/TelemetryConstants.cs @@ -52,6 +52,7 @@ public static class TagConstants public const string Resource = "resource"; public const string Subject = "subject"; public const string Session = "session"; + public const string Key = "key"; } public static class TraceCategories