├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── tests.yml ├── .gitignore ├── IdentityServer4.Contrib.RedisStore.Tests ├── ConfigurationUtils.cs ├── Extensions │ └── TestingExtensions.cs ├── Fakes │ ├── FakeCache.cs │ ├── FakeConnectionMultiplexer.cs │ ├── FakeLogger.cs │ ├── FakeProfileService.cs │ └── FakeResourceOwnerPasswordValidator.cs ├── IdentityServer4.Contrib.RedisStore.Tests.csproj ├── IntegrationTesting │ └── CachingProfileServiceTests.cs ├── UnitTesting │ ├── Cache │ │ ├── CachingProfileServiceTests.cs │ │ └── RedisCacheTests.cs │ ├── Options │ │ └── RedisOptionsTests.cs │ └── Stores │ │ └── PersistedGrantStoreTests.cs └── appsettings.json ├── IdentityServer4.Contrib.RedisStore.sln ├── IdentityServer4.Contrib.RedisStore ├── Cache │ ├── CachingProfileService.cs │ └── RedisCache.cs ├── Extensions │ ├── IdentityServerRedisBuilderExtensions.cs │ ├── ProfileServiceCachingOptions.cs │ ├── RedisMultiplexer.cs │ └── RedisOptions.cs ├── IdentityServer4.Contrib.RedisStore.csproj ├── IdentityServer4.Contrib.RedisStore.nuspec ├── Stores │ └── PersistedGrantStore.cs └── icon.jpg ├── LICENSE ├── README.md ├── docker-compose.yml └── makefile /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://paypal.me/alibazzi'] 2 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Automated tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | env: 9 | DOTNET_NOLOGO: true 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Setup Redis 14 | uses: zhulik/redis-action@1.1.0 15 | - name: Setup .NET Core 16 | uses: actions/setup-dotnet@v1.5.0 17 | with: 18 | dotnet-version: 3.1.* 19 | - name: Install dependencies 20 | run: dotnet restore 21 | - name: Build 22 | run: dotnet build --configuration Release --no-restore 23 | - name: Test 24 | run: dotnet test --no-restore 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc 262 | 263 | .vscode 264 | **/*.rdb 265 | **/*.DS_Store 266 | 267 | **/.ionide -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/ConfigurationUtils.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Configuration; 2 | 3 | namespace IdentityServer4.Contrib.RedisStore.Tests 4 | { 5 | public static class ConfigurationUtils 6 | { 7 | public static IConfiguration GetConfiguration() 8 | { 9 | var config = new ConfigurationBuilder() 10 | .AddJsonFile("appsettings.json"); 11 | 12 | return config.Build(); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Extensions/TestingExtensions.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Contrib.RedisStore.Tests; 2 | using IdentityServer4.Contrib.RedisStore.Tests.Cache; 3 | using IdentityServer4.Services; 4 | using Microsoft.Extensions.Caching.Memory; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace Microsoft.Extensions.DependencyInjection 8 | { 9 | internal static class TestingExtensions 10 | { 11 | 12 | public static IIdentityServerBuilder AddFakeMemeoryCaching(this IIdentityServerBuilder builder) 13 | { 14 | builder.Services.AddSingleton(new MemoryCache(new MemoryCacheOptions())); 15 | builder.Services.AddScoped(typeof(ICache<>), typeof(FakeCache<>)); 16 | return builder; 17 | } 18 | 19 | public static IIdentityServerBuilder AddFakeLogger(this IIdentityServerBuilder builder) 20 | { 21 | builder.Services.AddSingleton(new FakeLogger()); 22 | return builder; 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Fakes/FakeCache.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Services; 2 | using Microsoft.Extensions.Caching.Memory; 3 | using Microsoft.Extensions.Logging; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | 8 | namespace IdentityServer4.Contrib.RedisStore.Tests.Cache 9 | { 10 | public class FakeCache : ICache where T : class 11 | { 12 | private readonly IMemoryCache cache; 13 | 14 | private readonly ILogger> logger; 15 | 16 | public FakeCache(IMemoryCache memoryCache, FakeLogger> logger) 17 | { 18 | cache = memoryCache; 19 | this.logger = logger; 20 | } 21 | 22 | public Task GetAsync(string key) 23 | { 24 | var result = cache.Get(key); 25 | 26 | if (result == null) 27 | logger.LogDebug($"Cache miss for {key}"); 28 | else 29 | logger.LogDebug($"Cache hit for {key}"); 30 | 31 | return Task.FromResult((T)result); 32 | } 33 | 34 | public Task SetAsync(string key, T item, TimeSpan expiration) 35 | { 36 | cache.Set(key, item, expiration); 37 | return Task.CompletedTask; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Fakes/FakeConnectionMultiplexer.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using StackExchange.Redis.Profiling; 3 | using System; 4 | using System.IO; 5 | using System.Net; 6 | using System.Threading.Tasks; 7 | 8 | namespace IdentityServer4.Contrib.RedisStore.Tests.Fakes 9 | { 10 | internal class FakeConnectionMultiplexer : IConnectionMultiplexer 11 | { 12 | public string ClientName { get; set; } 13 | 14 | public string Configuration { get; set; } 15 | 16 | public int TimeoutMilliseconds { get; set; } 17 | 18 | public long OperationCount { get; set; } 19 | 20 | public bool PreserveAsyncOrder { get; set; } 21 | 22 | public bool IsConnected { get; set; } 23 | 24 | public bool IsConnecting { get; set; } 25 | 26 | public bool IncludeDetailInExceptions { get; set; } 27 | public int StormLogThreshold { get; set; } 28 | 29 | public event EventHandler ErrorMessage; 30 | public event EventHandler ConnectionFailed; 31 | public event EventHandler InternalError; 32 | public event EventHandler ConnectionRestored; 33 | public event EventHandler ConfigurationChanged; 34 | public event EventHandler ConfigurationChangedBroadcast; 35 | public event EventHandler HashSlotMoved; 36 | 37 | public void Close(bool allowCommandsToComplete = true) { } 38 | public Task CloseAsync(bool allowCommandsToComplete = true) => Task.CompletedTask; 39 | public bool Configure(TextWriter log = null) => true; 40 | public Task ConfigureAsync(TextWriter log = null) => Task.FromResult(true); 41 | public void Dispose() { } 42 | public void ExportConfiguration(Stream destination, ExportOptions options = (ExportOptions)(-1)) { } 43 | public ServerCounters GetCounters() => new ServerCounters(null); 44 | public IDatabase GetDatabase(int db = -1, object asyncState = null) => null; 45 | public EndPoint[] GetEndPoints(bool configuredOnly = false) => new EndPoint[0]; 46 | public int GetHashSlot(RedisKey key) => -1; 47 | public IServer GetServer(string host, int port, object asyncState = null) => null; 48 | public IServer GetServer(string hostAndPort, object asyncState = null) => null; 49 | public IServer GetServer(IPAddress host, int port) => null; 50 | public IServer GetServer(EndPoint endpoint, object asyncState = null) => null; 51 | public string GetStatus() => string.Empty; 52 | public void GetStatus(TextWriter log) { } 53 | public string GetStormLog() => string.Empty; 54 | public ISubscriber GetSubscriber(object asyncState = null) => null; 55 | public int HashSlot(RedisKey key) => -1; 56 | public long PublishReconfigure(CommandFlags flags = CommandFlags.None) => -1; 57 | public Task PublishReconfigureAsync(CommandFlags flags = CommandFlags.None) => Task.FromResult(-1L); 58 | public void RegisterProfiler(Func profilingSessionProvider) { } 59 | public void ResetStormLog() { } 60 | public void Wait(Task task) { } 61 | public T Wait(Task task) => default(T); 62 | public void WaitAll(params Task[] tasks) { } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Fakes/FakeLogger.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.Logging; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace IdentityServer4.Contrib.RedisStore.Tests 7 | { 8 | public class FakeLogger : ILogger 9 | { 10 | 11 | private Dictionary accessCount = new Dictionary(); 12 | 13 | public IReadOnlyDictionary AccessCount => accessCount; 14 | 15 | public IDisposable BeginScope(TState state) 16 | { 17 | return null; 18 | } 19 | 20 | public bool IsEnabled(LogLevel logLevel) 21 | { 22 | return true; 23 | } 24 | 25 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) 26 | { 27 | if (accessCount.ContainsKey(state.ToString())) 28 | { accessCount[state.ToString()] += 1; } 29 | else 30 | { accessCount[state.ToString()] = 1; } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Fakes/FakeProfileService.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Models; 2 | using IdentityServer4.Services; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Security.Claims; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using System.Linq; 9 | 10 | namespace IdentityServer4.Contrib.RedisStore.Tests.Cache 11 | { 12 | public class FakeProfileService : IProfileService 13 | { 14 | public IEnumerable Claims = new List(); 15 | 16 | public Task GetProfileDataAsync(ProfileDataRequestContext context) 17 | { 18 | context.IssuedClaims = Claims.ToList(); 19 | return Task.CompletedTask; 20 | } 21 | 22 | public Action IsActive; 23 | 24 | public Task IsActiveAsync(IsActiveContext context) 25 | { 26 | IsActive?.Invoke(context); 27 | return Task.CompletedTask; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/Fakes/FakeResourceOwnerPasswordValidator.cs: -------------------------------------------------------------------------------- 1 | using IdentityModel; 2 | using IdentityServer4.Validation; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Security.Claims; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace IdentityServer4.Contrib.RedisStore.Tests.Cache 10 | { 11 | class FakeResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator 12 | { 13 | public Task ValidateAsync(ResourceOwnerPasswordValidationContext context) 14 | { 15 | context.Result = new GrantValidationResult(subject: "1", 16 | authenticationMethod: OidcConstants.AuthenticationMethods.Password, 17 | claims: new List { }); 18 | 19 | return Task.CompletedTask; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/IdentityServer4.Contrib.RedisStore.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | PreserveNewest 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/IntegrationTesting/CachingProfileServiceTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using IdentityModel.Client; 3 | using IdentityServer4.Contrib.RedisStore.Tests.Cache; 4 | using IdentityServer4.Models; 5 | using IdentityServer4.Services; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.TestHost; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using System; 11 | using System.Collections.Generic; 12 | using System.Linq; 13 | using System.Net.Http; 14 | using System.Threading.Tasks; 15 | using Xunit; 16 | 17 | namespace IdentityServer4.Contrib.RedisStore.Tests 18 | { 19 | public class CachingProfileServiceTests 20 | { 21 | private FakeLogger> logger; 22 | 23 | private TestServer CreateTestServer(bool shouldCache) 24 | { 25 | return new TestServer(new WebHostBuilder() 26 | .ConfigureServices(services => 27 | { 28 | services.AddIdentityServer() 29 | .AddDeveloperSigningCredential(persistKey: false) 30 | .AddInMemoryApiScopes(new List 31 | { 32 | new ApiScope("api1") 33 | }) 34 | .AddInMemoryApiResources(new List 35 | { 36 | new ApiResource("api1") 37 | { 38 | ApiSecrets = { new Secret("secret".Sha256())}, 39 | Scopes = { "api1" } 40 | } 41 | }) 42 | .AddInMemoryClients(new List 43 | { 44 | new Client 45 | { 46 | ClientId = "client1", 47 | ClientSecrets = { new Secret("secret".Sha256()) }, 48 | AllowedGrantTypes = GrantTypes.ResourceOwnerPassword, 49 | AccessTokenType = AccessTokenType.Reference, 50 | AllowedScopes = { "api1" } 51 | } 52 | }) 53 | .AddFakeLogger>() 54 | .AddFakeMemeoryCaching() 55 | .AddResourceOwnerValidator() 56 | .AddProfileService() 57 | .AddProfileServiceCache(option => 58 | { 59 | option.ShouldCache = context => shouldCache; 60 | }); 61 | }) 62 | .Configure(app => 63 | { 64 | app.UseIdentityServer(); 65 | logger = app.ApplicationServices.GetService>>(); 66 | })); 67 | } 68 | 69 | [Fact] 70 | public async Task Test() 71 | { 72 | var server = CreateTestServer(shouldCache: true); 73 | 74 | var httpHandler = server.CreateHandler(); 75 | 76 | var discoveryClient = new HttpClient(httpHandler); 77 | discoveryClient.BaseAddress = new Uri("https://idp"); 78 | var docs = await discoveryClient.GetDiscoveryDocumentAsync(); 79 | 80 | var client = new HttpClient(httpHandler); 81 | var tokenResponse = await client.RequestPasswordTokenAsync(new PasswordTokenRequest 82 | { 83 | Address = docs.TokenEndpoint, 84 | ClientId = "client1", 85 | ClientSecret = "secret", 86 | Scope = "api1", 87 | UserName = "test", 88 | Password = "test" 89 | }); 90 | 91 | var introspection = new HttpClient(httpHandler); 92 | var introspectionResponse = await introspection.IntrospectTokenAsync(new TokenIntrospectionRequest 93 | { 94 | Address = docs.IntrospectionEndpoint, 95 | Token = tokenResponse.AccessToken, 96 | ClientId = "api1", 97 | ClientSecret = "secret" 98 | }); 99 | foreach (var _ in Enumerable.Range(1, 10)) 100 | { 101 | var result = await introspection.IntrospectTokenAsync(new TokenIntrospectionRequest 102 | { 103 | Address = docs.IntrospectionEndpoint, 104 | Token = tokenResponse.AccessToken, 105 | ClientId = "api1", 106 | ClientSecret = "secret" 107 | }); 108 | result.IsActive.Should().BeTrue(); 109 | } 110 | logger.AccessCount["Cache hit for 1"].Should().Equals(10); 111 | } 112 | 113 | [Fact] 114 | public async Task Test2() 115 | { 116 | var server = CreateTestServer(shouldCache: false); 117 | 118 | var httpHandler = server.CreateHandler(); 119 | 120 | var discoveryClient = new HttpClient(httpHandler); 121 | discoveryClient.BaseAddress = new Uri("https://idp"); 122 | var docs = await discoveryClient.GetDiscoveryDocumentAsync(); 123 | 124 | var client = new HttpClient(httpHandler); 125 | var tokenResponse = await client.RequestPasswordTokenAsync(new PasswordTokenRequest 126 | { 127 | Address = docs.TokenEndpoint, 128 | ClientId = "client1", 129 | ClientSecret = "secret", 130 | Scope = "api1", 131 | UserName = "test", 132 | Password = "test" 133 | }); 134 | 135 | var introspection = new HttpClient(httpHandler); 136 | var introspectionResponse = await introspection.IntrospectTokenAsync(new TokenIntrospectionRequest 137 | { 138 | Address = docs.IntrospectionEndpoint, 139 | Token = tokenResponse.AccessToken, 140 | ClientId = "api1", 141 | ClientSecret = "secret" 142 | }); 143 | foreach (var _ in Enumerable.Range(1, 10)) 144 | { 145 | var result = await introspection.IntrospectTokenAsync(new TokenIntrospectionRequest 146 | { 147 | Address = docs.IntrospectionEndpoint, 148 | Token = tokenResponse.AccessToken, 149 | ClientId = "api1", 150 | ClientSecret = "secret" 151 | }); 152 | result.IsActive.Should().BeTrue(); 153 | } 154 | logger.AccessCount.Should().BeEmpty(); 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/UnitTesting/Cache/CachingProfileServiceTests.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Models; 2 | using IdentityServer4.Services; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Security.Claims; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | using Xunit; 9 | using FluentAssertions; 10 | using Microsoft.Extensions.Logging; 11 | using System.Threading; 12 | using Microsoft.Extensions.Caching.Memory; 13 | using Moq; 14 | 15 | namespace IdentityServer4.Contrib.RedisStore.Tests.Cache 16 | { 17 | public class CachingProfileServiceTests 18 | { 19 | private readonly FakeProfileService inner; 20 | private readonly FakeCache cache; 21 | private readonly FakeLogger> logger; 22 | private readonly CachingProfileService profileServiceCache; 23 | private readonly IMemoryCache memoryCache; 24 | 25 | public CachingProfileServiceTests() 26 | { 27 | inner = new FakeProfileService(); 28 | memoryCache = new MemoryCache(new MemoryCacheOptions()); 29 | logger = new FakeLogger>(); 30 | cache = new FakeCache(memoryCache, logger); 31 | profileServiceCache = new CachingProfileService(inner, cache, new ProfileServiceCachingOptions(), Mock.Of>>()); 32 | } 33 | 34 | [Fact] 35 | public async Task AssertHitingDataStoreAtLeastOnce() 36 | { 37 | var principal = new ClaimsPrincipal(new ClaimsIdentity(new List { new Claim("sub", "1") })); 38 | var context = new IsActiveContext(principal, new Client(), "test"); 39 | await profileServiceCache.IsActiveAsync(context); 40 | await profileServiceCache.IsActiveAsync(context); 41 | await profileServiceCache.IsActiveAsync(context); 42 | context.IsActive.Should().BeTrue(); 43 | logger.AccessCount["Cache hit for 1"].Should().Equals(2); 44 | } 45 | 46 | [Fact] 47 | public async Task AssertIsInactive() 48 | { 49 | inner.IsActive = cxt => cxt.IsActive = false; 50 | var principal = new ClaimsPrincipal(new ClaimsIdentity(new List { new Claim("sub", "1") })); 51 | var context = new IsActiveContext(principal, new Client(), "test"); 52 | await profileServiceCache.IsActiveAsync(context); 53 | context.IsActive.Should().BeFalse(); 54 | } 55 | 56 | [Fact] 57 | public async Task AssertExpiryOfCacheEntry() 58 | { 59 | var profileServiceCache = new CachingProfileService(inner, cache, new ProfileServiceCachingOptions() { Expiration = TimeSpan.FromSeconds(1) }, Mock.Of>>()); 60 | var principal = new ClaimsPrincipal(new ClaimsIdentity(new List { new Claim("sub", "1") })); 61 | var context = new IsActiveContext(principal, new Client(), "test"); 62 | await profileServiceCache.IsActiveAsync(context); 63 | await profileServiceCache.IsActiveAsync(context); 64 | Thread.Sleep(1000); 65 | await profileServiceCache.IsActiveAsync(context); 66 | await profileServiceCache.IsActiveAsync(context); 67 | context.IsActive.Should().BeTrue(); 68 | logger.AccessCount["Cache hit for 1"].Should().Equals(2); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/UnitTesting/Cache/RedisCacheTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using IdentityServer4.Contrib.RedisStore.Cache; 5 | using Microsoft.Extensions.Logging; 6 | using Moq; 7 | using Xunit; 8 | 9 | namespace IdentityServer4.Contrib.RedisStore.Tests.Cache 10 | { 11 | public class RedisCacheTests 12 | { 13 | private readonly RedisCache _cache; 14 | 15 | public RedisCacheTests() 16 | { 17 | var logger = new Mock>>(); 18 | var options = new RedisCacheOptions { RedisConnectionString = ConfigurationUtils.GetConfiguration()["Redis:ConnectionString"] }; 19 | var multiplexer = new RedisMultiplexer(options); 20 | 21 | _cache = new RedisCache(multiplexer, logger.Object); 22 | } 23 | 24 | [Fact] 25 | public void RedisCache_Null_Multiplexer_Throws_ArgumentNullException() 26 | { 27 | var logger = new Mock>>(); 28 | 29 | Assert.Throws(() => new RedisCache(null, logger.Object)); 30 | } 31 | 32 | [Fact] 33 | public void RedisCache_Null_Logger_Throws_ArgumentNullException() 34 | { 35 | var multiplexer = new RedisMultiplexer(new RedisCacheOptions { RedisConnectionString = ConfigurationUtils.GetConfiguration()["Redis:ConnectionString"] }); 36 | 37 | Assert.Throws(() => new RedisCache(multiplexer, null)); 38 | } 39 | 40 | [Fact] 41 | public async Task SetAsync_Stores_Entries() 42 | { 43 | string key = nameof(SetAsync_Stores_Entries); 44 | string expected = "test_value"; 45 | await _cache.SetAsync(key, expected, TimeSpan.FromSeconds(1)); 46 | 47 | var actual = await _cache.GetAsync(key); 48 | 49 | Assert.Equal(expected, actual); 50 | } 51 | 52 | [Fact] 53 | public async Task GetAsync_Does_Not_Return_Expired_Entries() 54 | { 55 | string key = nameof(GetAsync_Does_Not_Return_Expired_Entries); 56 | string expected = "test_value"; 57 | await _cache.SetAsync(key, expected, TimeSpan.FromSeconds(2)); 58 | 59 | var actual = await _cache.GetAsync(key); 60 | Assert.Equal(expected, actual); 61 | 62 | Thread.Sleep(TimeSpan.FromSeconds(2.1)); 63 | 64 | actual = await _cache.GetAsync(key); 65 | 66 | Assert.Null(actual); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/UnitTesting/Options/RedisOptionsTests.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Contrib.RedisStore.Tests.Fakes; 2 | using Xunit; 3 | 4 | namespace IdentityServer4.Contrib.RedisStore.Tests.Options 5 | { 6 | public class RedisOptionsTests 7 | { 8 | [Fact] 9 | public void Multiplexer_Provided_Uses_Provided_Multiplexer() 10 | { 11 | var cacheOptions = new RedisCacheOptions() 12 | { 13 | RedisConnectionMultiplexer = new FakeConnectionMultiplexer() 14 | }; 15 | 16 | Assert.IsType(cacheOptions.RedisConnectionMultiplexer); 17 | 18 | var storeOptions = new RedisOperationalStoreOptions() 19 | { 20 | RedisConnectionMultiplexer = new FakeConnectionMultiplexer() 21 | }; 22 | 23 | Assert.IsType(storeOptions.RedisConnectionMultiplexer); 24 | } 25 | 26 | [Fact] 27 | public void Multiplexer_And_ConnectionString_Provided_Uses_Provided_Multiplexer() 28 | { 29 | var cacheOptions = new RedisCacheOptions() 30 | { 31 | RedisConnectionString = "fake", // if connection is made, this will throw 32 | RedisConnectionMultiplexer = new FakeConnectionMultiplexer() 33 | }; 34 | 35 | Assert.IsType(cacheOptions.RedisConnectionMultiplexer); 36 | 37 | var storeOptions = new RedisOperationalStoreOptions() 38 | { 39 | RedisConnectionString = "fake", // if connection is made, this will throw 40 | RedisConnectionMultiplexer = new FakeConnectionMultiplexer() 41 | }; 42 | 43 | Assert.IsType(storeOptions.RedisConnectionMultiplexer); 44 | } 45 | 46 | [Fact] 47 | public void ConnectionString_Provided_Makes_Connection() 48 | { 49 | var cacheOptions = new RedisCacheOptions() 50 | { 51 | RedisConnectionString = ConfigurationUtils.GetConfiguration()["Redis:ConnectionString"] 52 | }; 53 | 54 | Assert.IsType(cacheOptions.RedisConnectionMultiplexer); 55 | 56 | var storeOptions = new RedisOperationalStoreOptions() 57 | { 58 | RedisConnectionString = ConfigurationUtils.GetConfiguration()["Redis:ConnectionString"] 59 | }; 60 | 61 | Assert.IsType(storeOptions.RedisConnectionMultiplexer); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/UnitTesting/Stores/PersistedGrantStoreTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using IdentityServer4.Contrib.RedisStore.Stores; 3 | using IdentityServer4.Models; 4 | using Microsoft.AspNetCore.Authentication; 5 | using Microsoft.Extensions.Logging; 6 | using Moq; 7 | using System; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Xunit; 12 | 13 | namespace IdentityServer4.Contrib.RedisStore.Tests.Stores 14 | { 15 | public class PersistedGrantStoreTests 16 | { 17 | private readonly PersistedGrantStore _store; 18 | private readonly RedisMultiplexer _multiplexer; 19 | private readonly Mock> _logger; 20 | private readonly Mock _clock; 21 | 22 | public PersistedGrantStoreTests() 23 | { 24 | _logger = new Mock>(); 25 | _clock = new Mock(); 26 | string connectionString = ConfigurationUtils.GetConfiguration()["Redis:ConnectionString"]; 27 | var options = new RedisOperationalStoreOptions { RedisConnectionString = connectionString }; 28 | _multiplexer = new RedisMultiplexer(options); 29 | 30 | _store = new PersistedGrantStore(_multiplexer, _logger.Object, _clock.Object); 31 | } 32 | 33 | [Fact] 34 | public void PersistedGrantStore_Null_Multiplexer_Throws_ArgumentNullException() 35 | { 36 | Assert.Throws(() => new PersistedGrantStore(null, _logger.Object, _clock.Object)); 37 | } 38 | 39 | [Fact] 40 | public void PersistedGrantStore_Null_Logger_Throws_ArgumentNullException() 41 | { 42 | Assert.Throws(() => new PersistedGrantStore(_multiplexer, null, _clock.Object)); 43 | } 44 | 45 | [Fact] 46 | public async Task StoreAsync_Stores_Entries() 47 | { 48 | var now = DateTime.Now; 49 | _clock.Setup(x => x.UtcNow).Returns(now); 50 | string key = nameof(StoreAsync_Stores_Entries); 51 | string expected = "this is a test"; 52 | var grant = new PersistedGrant { Key = key, Data = expected, ClientId = "client1", SubjectId = "sub1", Type = "type1", Expiration = now.AddSeconds(1) }; 53 | await _store.StoreAsync(grant); 54 | 55 | var actual = await _store.GetAsync(key); 56 | 57 | Assert.NotNull(actual); 58 | Assert.Equal(expected, actual.Data); 59 | } 60 | 61 | [Fact] 62 | public async Task Store_And_Remove_Entries() 63 | { 64 | var now = DateTime.Now; 65 | _clock.Setup(x => x.UtcNow).Returns(now); 66 | string key = nameof(Store_And_Remove_Entries); 67 | string expected = "this is a test"; 68 | var grant = new PersistedGrant { Key = key, Data = expected, ClientId = "client1", SubjectId = "sub1", Type = "type1", Expiration = now.AddSeconds(1) }; 69 | await _store.StoreAsync(grant); 70 | 71 | await _store.RemoveAsync(key); 72 | 73 | var actual = await _store.GetAsync(key); 74 | 75 | Assert.Null(actual); 76 | } 77 | 78 | [Fact] 79 | public async Task RemoveAll_Entries() 80 | { 81 | var now = DateTime.Now; 82 | _clock.Setup(x => x.UtcNow).Returns(now); 83 | string subjectId = $"{nameof(RemoveAll_Entries)}-subjectId"; 84 | var expected = Enumerable.Range(0, 5).Select(x => 85 | new PersistedGrant 86 | { 87 | Key = $"{nameof(RemoveAll_Entries)}-{now:O}-{x}", 88 | SubjectId = subjectId, 89 | Expiration = now.AddSeconds(2), 90 | ClientId = "client1", 91 | Type = "type1", 92 | } 93 | ).ToList(); 94 | 95 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 96 | 97 | await _store.RemoveAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1" }); 98 | 99 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 100 | 101 | Assert.Empty(actual); 102 | } 103 | 104 | [Fact] 105 | public async Task RemoveAll_Entries_With_SessionId() 106 | { 107 | var now = DateTime.Now; 108 | _clock.Setup(x => x.UtcNow).Returns(now); 109 | string subjectId = $"{nameof(RemoveAll_Entries_With_SessionId)}-subjectId"; 110 | var expected = Enumerable.Range(0, 5).Select(x => 111 | new PersistedGrant 112 | { 113 | Key = $"{nameof(RemoveAll_Entries_With_SessionId)}-{now:O}-{x}", 114 | SubjectId = subjectId, 115 | Expiration = now.AddSeconds(2), 116 | ClientId = "client1", 117 | Type = "type1", 118 | SessionId = $"session{x}" 119 | } 120 | ).ToList(); 121 | 122 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 123 | 124 | await _store.RemoveAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", SessionId = "session1" }); 125 | 126 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 127 | 128 | actual.Should().HaveCount(4); 129 | } 130 | 131 | [Fact] 132 | public async Task RemoveAll_Entries_With_Type() 133 | { 134 | var now = DateTime.Now; 135 | _clock.Setup(x => x.UtcNow).Returns(now); 136 | string subjectId = $"{nameof(RemoveAll_Entries_With_Type)}-subjectId"; 137 | var expected = Enumerable.Range(0, 5).Select(x => 138 | new PersistedGrant 139 | { 140 | Key = $"{nameof(RemoveAll_Entries_With_Type)}-{now:O}-{x}", 141 | SubjectId = subjectId, 142 | Expiration = now.AddSeconds(2), 143 | ClientId = "client1", 144 | Type = x > 2 ? "type1" : "type2", 145 | SessionId = $"session{x}" 146 | } 147 | ).ToList(); 148 | 149 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 150 | 151 | await _store.RemoveAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", Type = "type2" }); 152 | 153 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 154 | 155 | actual.Should().HaveCount(2); 156 | } 157 | 158 | [Fact] 159 | public async Task RemoveAll_Entries_WithType() 160 | { 161 | var now = DateTime.Now; 162 | _clock.Setup(x => x.UtcNow).Returns(now); 163 | string subjectId = $"{nameof(RemoveAll_Entries_WithType)}-subjectId"; 164 | var expected = Enumerable.Range(0, 5).Select(x => 165 | new PersistedGrant 166 | { 167 | Key = $"{nameof(RemoveAll_Entries_WithType)}-{now:O}-{x}", 168 | SubjectId = subjectId, 169 | Expiration = now.AddSeconds(2), 170 | ClientId = "client1", 171 | Type = "type1", 172 | } 173 | ).ToList(); 174 | 175 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 176 | 177 | await _store.RemoveAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", Type = "type1" }); 178 | 179 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 180 | 181 | Assert.Empty(actual); 182 | } 183 | 184 | [Fact] 185 | public async Task GetAsync_Does_Not_Return_Expired_Entries() 186 | { 187 | var now = DateTime.Now; 188 | _clock.Setup(x => x.UtcNow).Returns(now); 189 | string key = $"{nameof(GetAsync_Does_Not_Return_Expired_Entries)}-{now:O}"; 190 | string expected = "this is a test"; 191 | var grant = new PersistedGrant { Key = key, Data = expected, ClientId = "client1", SubjectId = "sub1", Type = "type1", Expiration = now.AddSeconds(1) }; 192 | await _store.StoreAsync(grant); 193 | 194 | var actual = await _store.GetAsync(key); 195 | 196 | Assert.Equal(expected, actual.Data); 197 | 198 | Thread.Sleep(TimeSpan.FromSeconds(2)); 199 | actual = await _store.GetAsync(key); 200 | 201 | Assert.Null(actual); 202 | } 203 | 204 | [Fact] 205 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId() 206 | { 207 | var now = DateTime.Now; 208 | _clock.Setup(x => x.UtcNow).Returns(now); 209 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId)}-subjectId"; 210 | var expected = Enumerable.Range(0, 5).Select(x => 211 | new PersistedGrant 212 | { 213 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId)}-{now:O}-{x}", 214 | SubjectId = subjectId, 215 | Expiration = now.AddSeconds(2), 216 | ClientId = "client1", 217 | Type = "type1", 218 | } 219 | ).ToList(); 220 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 221 | 222 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 223 | 224 | Assert.NotNull(actual); 225 | actual.Should().BeEquivalentTo(expected); 226 | } 227 | 228 | [Fact] 229 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId() 230 | { 231 | var now = DateTime.Now; 232 | _clock.Setup(x => x.UtcNow).Returns(now); 233 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId)}-subjectId"; 234 | var expected = Enumerable.Range(0, 5).Select(x => 235 | new PersistedGrant 236 | { 237 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId)}-{now:O}-{x}", 238 | SubjectId = subjectId, 239 | Expiration = now.AddSeconds(2), 240 | ClientId = $"client{x}", 241 | Type = "type1", 242 | } 243 | ).ToList(); 244 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 245 | 246 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1" })).ToList(); 247 | 248 | Assert.NotNull(actual); 249 | actual.Should().HaveCount(1); 250 | } 251 | 252 | [Fact] 253 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_Type() 254 | { 255 | var now = DateTime.Now; 256 | _clock.Setup(x => x.UtcNow).Returns(now); 257 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_Type)}-subjectId"; 258 | var expected = Enumerable.Range(0, 5).Select(x => 259 | new PersistedGrant 260 | { 261 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_Type)}-{now:O}-{x}", 262 | SubjectId = subjectId, 263 | Expiration = now.AddSeconds(2), 264 | ClientId = $"client{x}", 265 | Type = "type1", 266 | } 267 | ).ToList(); 268 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 269 | 270 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, Type = "type1" })).ToList(); 271 | 272 | Assert.NotNull(actual); 273 | actual.Should().HaveCount(5); 274 | } 275 | 276 | [Fact] 277 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_And_Type() 278 | { 279 | var now = DateTime.Now; 280 | _clock.Setup(x => x.UtcNow).Returns(now); 281 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_And_Type)}-subjectId"; 282 | var expected = Enumerable.Range(0, 5).Select(x => 283 | new PersistedGrant 284 | { 285 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_And_Type)}-{now:O}-{x}", 286 | SubjectId = subjectId, 287 | Expiration = now.AddSeconds(2), 288 | ClientId = $"client{x}", 289 | Type = "type1", 290 | } 291 | ).ToList(); 292 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 293 | 294 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", Type = "type1" })).ToList(); 295 | 296 | Assert.NotNull(actual); 297 | actual.Should().HaveCount(1); 298 | } 299 | 300 | [Fact] 301 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId() 302 | { 303 | var now = DateTime.Now; 304 | _clock.Setup(x => x.UtcNow).Returns(now); 305 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId)}-subjectId"; 306 | var expected = Enumerable.Range(0, 5).Select(x => 307 | new PersistedGrant 308 | { 309 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId)}-{now:O}-{x}", 310 | SubjectId = subjectId, 311 | Expiration = now.AddSeconds(2), 312 | ClientId = $"client{x}", 313 | SessionId = "session1", 314 | Type = "type1", 315 | } 316 | ).ToList(); 317 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 318 | 319 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", SessionId = "session1" })).ToList(); 320 | 321 | Assert.NotNull(actual); 322 | actual.Should().HaveCount(1); 323 | } 324 | 325 | [Fact] 326 | public async Task GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId_and_Type() 327 | { 328 | var now = DateTime.Now; 329 | _clock.Setup(x => x.UtcNow).Returns(now); 330 | string subjectId = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId_and_Type)}-subjectId"; 331 | var expected = Enumerable.Range(0, 5).Select(x => 332 | new PersistedGrant 333 | { 334 | Key = $"{nameof(GetAllAsync_Retrieves_All_Grants_For_SubjectId_and_ClientId_and_SessionId_and_Type)}-{now:O}-{x}", 335 | SubjectId = subjectId, 336 | Expiration = now.AddSeconds(2), 337 | ClientId = $"client{x}", 338 | SessionId = "session1", 339 | Type = "type1", 340 | } 341 | ).ToList(); 342 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 343 | 344 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId, ClientId = "client1", SessionId = "session1", Type = "type1" })).ToList(); 345 | 346 | Assert.NotNull(actual); 347 | actual.Should().HaveCount(1); 348 | } 349 | 350 | [Fact] 351 | public async Task GetAllAsync_Does_Not_Retrieve_Expired_Grants() 352 | { 353 | var now = DateTime.Now; 354 | _clock.Setup(x => x.UtcNow).Returns(now); 355 | string subjectId = $"{nameof(GetAllAsync_Does_Not_Retrieve_Expired_Grants)}-subjectId"; 356 | var expected = Enumerable.Range(0, 5).Select(x => 357 | new PersistedGrant 358 | { 359 | Key = $"{nameof(GetAllAsync_Does_Not_Retrieve_Expired_Grants)}-{now:O}-{x}", 360 | SubjectId = subjectId, 361 | Expiration = now.AddSeconds(-1), 362 | ClientId = "client1", 363 | Type = "type1", 364 | } 365 | ).ToList(); 366 | Task.WaitAll(expected.Select(x => _store.StoreAsync(x)).ToArray()); 367 | 368 | var actual = (await _store.GetAllAsync(new IdentityServer4.Stores.PersistedGrantFilter { SubjectId = subjectId })).ToList(); 369 | 370 | Assert.NotNull(actual); 371 | Assert.Empty(actual); 372 | } 373 | } 374 | } 375 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.Tests/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Redis": { 3 | "ConnectionString": "localhost" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26730.8 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IdentityServer4.Contrib.RedisStore", "IdentityServer4.Contrib.RedisStore\IdentityServer4.Contrib.RedisStore.csproj", "{B6285ED9-1D6D-49FB-B9A7-5C5460415E86}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IdentityServer4.Contrib.RedisStore.Tests", "IdentityServer4.Contrib.RedisStore.Tests\IdentityServer4.Contrib.RedisStore.Tests.csproj", "{B2273D4D-FBD6-4665-9856-16F08527571D}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | Release|Any CPU = Release|Any CPU 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {B6285ED9-1D6D-49FB-B9A7-5C5460415E86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 17 | {B6285ED9-1D6D-49FB-B9A7-5C5460415E86}.Debug|Any CPU.Build.0 = Debug|Any CPU 18 | {B6285ED9-1D6D-49FB-B9A7-5C5460415E86}.Release|Any CPU.ActiveCfg = Release|Any CPU 19 | {B6285ED9-1D6D-49FB-B9A7-5C5460415E86}.Release|Any CPU.Build.0 = Release|Any CPU 20 | {B2273D4D-FBD6-4665-9856-16F08527571D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 21 | {B2273D4D-FBD6-4665-9856-16F08527571D}.Debug|Any CPU.Build.0 = Debug|Any CPU 22 | {B2273D4D-FBD6-4665-9856-16F08527571D}.Release|Any CPU.ActiveCfg = Release|Any CPU 23 | {B2273D4D-FBD6-4665-9856-16F08527571D}.Release|Any CPU.Build.0 = Release|Any CPU 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {258B4519-0BDA-45DD-8FA5-A46D1BC27A4D} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Cache/CachingProfileService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using IdentityServer4.Contrib.RedisStore; 3 | using IdentityServer4.Models; 4 | using Microsoft.Extensions.Logging; 5 | using IdentityServer4.Extensions; 6 | 7 | namespace IdentityServer4.Services 8 | { 9 | /// 10 | /// Caching decorator for IProfileService 11 | /// 12 | /// 13 | public class CachingProfileService : IProfileService 14 | where TProfileService : class, IProfileService 15 | { 16 | private readonly TProfileService inner; 17 | 18 | private readonly ICache cache; 19 | 20 | private readonly ProfileServiceCachingOptions options; 21 | 22 | private readonly ILogger> logger; 23 | 24 | public CachingProfileService(TProfileService inner, ICache cache, ProfileServiceCachingOptions options, ILogger> logger) 25 | { 26 | this.inner = inner; 27 | this.logger = logger; 28 | this.cache = cache; 29 | this.options = options; 30 | } 31 | 32 | /// 33 | /// This method is called whenever claims about the user are requested (e.g. during token creation or via the userinfo endpoint) 34 | /// 35 | /// The context. 36 | /// 37 | public async Task GetProfileDataAsync(ProfileDataRequestContext context) 38 | { 39 | await this.inner.GetProfileDataAsync(context); 40 | } 41 | 42 | /// 43 | /// This method gets called whenever identity server needs to determine if the user is valid or active (e.g. if the user's account has been deactivated since they logged in). 44 | /// (e.g. during token issuance or validation). 45 | /// 46 | /// The context. 47 | /// 48 | public async Task IsActiveAsync(IsActiveContext context) 49 | { 50 | var key = $"{options.KeyPrefix}{options.KeySelector(context)}"; 51 | 52 | if (options.ShouldCache(context)) 53 | { 54 | var entry = await cache.GetAsync(key, options.Expiration, 55 | async () => 56 | { 57 | await inner.IsActiveAsync(context); 58 | return new IsActiveContextCacheEntry { IsActive = context.IsActive }; 59 | }, 60 | logger); 61 | 62 | context.IsActive = entry.IsActive; 63 | } 64 | else 65 | { 66 | await inner.IsActiveAsync(context); 67 | } 68 | } 69 | } 70 | 71 | /// 72 | /// Represents cache entry for IsActiveContext 73 | /// 74 | public class IsActiveContextCacheEntry 75 | { 76 | public bool IsActive { get; set; } 77 | } 78 | } -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Cache/RedisCache.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Services; 2 | using IdentityServer4.Stores.Serialization; 3 | using Microsoft.Extensions.Logging; 4 | using Newtonsoft.Json; 5 | using StackExchange.Redis; 6 | using System; 7 | using System.Threading.Tasks; 8 | 9 | namespace IdentityServer4.Contrib.RedisStore.Cache 10 | { 11 | /// 12 | /// Redis based implementation for ICache 13 | /// 14 | /// 15 | public class RedisCache : ICache where T : class 16 | { 17 | private readonly IDatabase database; 18 | 19 | private readonly RedisCacheOptions options; 20 | 21 | private readonly ILogger> logger; 22 | 23 | public RedisCache(RedisMultiplexer multiplexer, ILogger> logger) 24 | { 25 | if (multiplexer is null) 26 | throw new ArgumentNullException(nameof(multiplexer)); 27 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 28 | 29 | this.options = multiplexer.RedisOptions; 30 | this.database = multiplexer.Database; 31 | } 32 | 33 | private string GetKey(string key) => $"{this.options.KeyPrefix}{typeof(T).FullName}:{key}"; 34 | 35 | public async Task GetAsync(string key) 36 | { 37 | var cacheKey = GetKey(key); 38 | var item = await this.database.StringGetAsync(cacheKey); 39 | if (item.HasValue) 40 | { 41 | logger.LogDebug("retrieved {type} with Key: {key} from Redis Cache successfully.", typeof(T).FullName, key); 42 | return Deserialize(item); 43 | } 44 | else 45 | { 46 | logger.LogDebug("missed {type} with Key: {key} from Redis Cache.", typeof(T).FullName, key); 47 | return default(T); 48 | } 49 | } 50 | 51 | public async Task SetAsync(string key, T item, TimeSpan expiration) 52 | { 53 | var cacheKey = GetKey(key); 54 | await this.database.StringSetAsync(cacheKey, Serialize(item), expiration); 55 | logger.LogDebug("persisted {type} with Key: {key} in Redis Cache successfully.", typeof(T).FullName, key); 56 | } 57 | 58 | #region Json 59 | private JsonSerializerSettings SerializerSettings 60 | { 61 | get 62 | { 63 | var settings = new JsonSerializerSettings(); 64 | settings.Converters.Add(new ClaimConverter()); 65 | return settings; 66 | } 67 | } 68 | 69 | private T Deserialize(string json) 70 | { 71 | return JsonConvert.DeserializeObject(json, this.SerializerSettings); 72 | } 73 | 74 | private string Serialize(T item) 75 | { 76 | return JsonConvert.SerializeObject(item, this.SerializerSettings); 77 | } 78 | #endregion 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Extensions/IdentityServerRedisBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Contrib.RedisStore; 2 | using IdentityServer4.Contrib.RedisStore.Cache; 3 | using IdentityServer4.Contrib.RedisStore.Stores; 4 | using IdentityServer4.Services; 5 | using IdentityServer4.Stores; 6 | using Microsoft.Extensions.DependencyInjection.Extensions; 7 | using System; 8 | 9 | namespace Microsoft.Extensions.DependencyInjection 10 | { 11 | public static class IdentityServerRedisBuilderExtensions 12 | { 13 | /// 14 | /// Add Redis Operational Store. 15 | /// 16 | /// 17 | /// Redis Operational Store Options builder 18 | /// 19 | public static IIdentityServerBuilder AddOperationalStore(this IIdentityServerBuilder builder, Action optionsBuilder) 20 | { 21 | var options = new RedisOperationalStoreOptions(); 22 | optionsBuilder?.Invoke(options); 23 | builder.Services.AddSingleton(options); 24 | 25 | builder.Services.AddScoped>(); 26 | builder.Services.AddTransient(); 27 | return builder; 28 | } 29 | 30 | /// 31 | /// Add Redis caching that implements ICache 32 | /// 33 | /// 34 | /// Redis Cache Options builder 35 | /// 36 | public static IIdentityServerBuilder AddRedisCaching(this IIdentityServerBuilder builder, Action optionsBuilder) 37 | { 38 | var options = new RedisCacheOptions(); 39 | optionsBuilder?.Invoke(options); 40 | builder.Services.AddSingleton(options); 41 | 42 | builder.Services.AddScoped>(); 43 | builder.Services.AddTransient(typeof(ICache<>), typeof(RedisCache<>)); 44 | return builder; 45 | } 46 | 47 | /// 48 | /// Add Redis caching for IProfileService Implementation 49 | /// 50 | /// 51 | /// Profile Service Redis Cache Options builder 52 | /// 53 | public static IIdentityServerBuilder AddProfileServiceCache(this IIdentityServerBuilder builder, Action> optionsBuilder = null) 54 | where TProfileService : class, IProfileService 55 | { 56 | var options = new ProfileServiceCachingOptions(); 57 | optionsBuilder?.Invoke(options); 58 | builder.Services.AddSingleton(options); 59 | 60 | builder.Services.TryAddTransient(typeof(TProfileService)); 61 | builder.Services.AddTransient>(); 62 | return builder; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Extensions/ProfileServiceCachingOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using IdentityServer4.Models; 3 | using IdentityServer4.Services; 4 | using System.Linq; 5 | 6 | namespace IdentityServer4.Contrib.RedisStore 7 | { 8 | /// 9 | /// Represents the Profile Service caching options. 10 | /// 11 | public class ProfileServiceCachingOptions 12 | where T : class, IProfileService 13 | { 14 | /// 15 | /// Key selector for IsActiveContext, defaults select the Subject (sub) claim value. 16 | /// 17 | public Func KeySelector { get; set; } = (context) => context.Subject.Claims.First(_ => _.Type == "sub").Value; 18 | 19 | /// 20 | /// A predicate to determine whether the current IsActiveContext should be cached or not, default to true on all IsActiveContext instances. 21 | /// 22 | public Func ShouldCache { get; set; } = (context) => true; 23 | 24 | /// 25 | /// Expiration of the cache entry of IsActiveContext, defaults to 10 minutes. 26 | /// 27 | public TimeSpan Expiration { get; set; } = TimeSpan.FromMinutes(10); 28 | 29 | private string _keyPrefix = string.Empty; 30 | 31 | /// 32 | /// The Prefix to add to each key stored on Redis Cache, default is Empty. 33 | /// 34 | public string KeyPrefix 35 | { 36 | get 37 | { 38 | return string.IsNullOrEmpty(this._keyPrefix) ? this._keyPrefix : $"{_keyPrefix}:"; 39 | } 40 | set 41 | { 42 | this._keyPrefix = value; 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Extensions/RedisMultiplexer.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | 3 | namespace IdentityServer4.Contrib.RedisStore 4 | { 5 | /// 6 | /// represents Redis general multiplexer 7 | /// 8 | /// 9 | public class RedisMultiplexer where T : RedisOptions 10 | { 11 | public RedisMultiplexer(T redisOptions) 12 | { 13 | this.RedisOptions = redisOptions; 14 | this.GetDatabase(); 15 | } 16 | 17 | private void GetDatabase() 18 | { 19 | this.Database = this.RedisOptions.Multiplexer.GetDatabase(string.IsNullOrEmpty(this.RedisOptions.RedisConnectionString) ? -1 : this.RedisOptions.Db); 20 | } 21 | 22 | internal T RedisOptions { get; } 23 | 24 | internal IDatabase Database { get; private set; } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Extensions/RedisOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using StackExchange.Redis; 3 | 4 | namespace IdentityServer4.Contrib.RedisStore 5 | { 6 | /// 7 | /// Represents Redis general options. 8 | /// 9 | public abstract class RedisOptions 10 | { 11 | /// 12 | ///Configuration options objects for StackExchange.Redis Library. 13 | /// 14 | public ConfigurationOptions ConfigurationOptions { get; set; } 15 | 16 | /// 17 | /// Connection String for connecting to Redis Instance. 18 | /// 19 | public string RedisConnectionString { get; set; } 20 | 21 | /// 22 | /// Connection Multiplexer for connecting to Redis Instance. 23 | /// When provided, the values for and 24 | /// are ignored. 25 | /// 26 | public IConnectionMultiplexer RedisConnectionMultiplexer 27 | { 28 | get 29 | { 30 | return this.multiplexer.Value; 31 | } 32 | set 33 | { 34 | // if someone already asked for the multiplexer before, we 35 | // may have already connected using the connection string. 36 | // in that case we must disconnect so we don't leak anything. 37 | if (this.multiplexer.IsValueCreated && this.multiplexer.Value != this.providedMultiplexer) 38 | { 39 | this.multiplexer.Value.Dispose(); 40 | this.multiplexer = new Lazy(() => value); 41 | } 42 | 43 | this.providedMultiplexer = value; 44 | } 45 | } 46 | 47 | /// 48 | ///The specific Db number to connect to, default is -1. 49 | /// 50 | public int Db { get; set; } = -1; 51 | 52 | private string _keyPrefix = string.Empty; 53 | 54 | /// 55 | /// The Prefix to add to each key stored on Redis Cache, default is Empty. 56 | /// 57 | public string KeyPrefix 58 | { 59 | get 60 | { 61 | return string.IsNullOrEmpty(this._keyPrefix) ? this._keyPrefix : $"{_keyPrefix}:"; 62 | } 63 | set 64 | { 65 | this._keyPrefix = value; 66 | } 67 | } 68 | 69 | internal RedisOptions() 70 | { 71 | this.multiplexer = GetConnectionMultiplexer(); 72 | } 73 | 74 | private Lazy GetConnectionMultiplexer() 75 | { 76 | return new Lazy( 77 | () => 78 | { 79 | // if the user provided a multiplexer, we should use it 80 | if (this.providedMultiplexer != null) 81 | { 82 | return this.providedMultiplexer; 83 | } 84 | 85 | // otherwise we must make our own connection 86 | return string.IsNullOrEmpty(this.RedisConnectionString) 87 | ? ConnectionMultiplexer.Connect(this.ConfigurationOptions) 88 | : ConnectionMultiplexer.Connect(this.RedisConnectionString); 89 | }); 90 | } 91 | 92 | private IConnectionMultiplexer providedMultiplexer = null; 93 | private Lazy multiplexer = null; 94 | 95 | internal IConnectionMultiplexer Multiplexer => this.multiplexer.Value; 96 | } 97 | 98 | /// 99 | /// Represents Redis Operational store options. 100 | /// 101 | public class RedisOperationalStoreOptions : RedisOptions 102 | { 103 | 104 | } 105 | 106 | /// 107 | /// Represents Redis Cache options. 108 | /// 109 | public class RedisCacheOptions : RedisOptions 110 | { 111 | 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/IdentityServer4.Contrib.RedisStore.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | false 6 | Redis Store for operational data and for Caching of Identity Server 4 7 | Ali Bazzi 8 | Ali Bazzi 9 | 4.0.0.0 10 | Supports Identity Server 4 v4. 11 | 4.0.0 12 | https://github.com/AliBazzi/IdentityServer4.Contrib.RedisStore 13 | https://identityserver.github.io/Documentation/assets/images/icons/IDserver_icon128.jpg 14 | icon.jpg 15 | Redis Store IdentityServer4 16 | MIT 17 | Ali Bazzi 18 | 4.0.0.0 19 | true 20 | true 21 | snupkg 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | bin\Debug\netcoreapp3.1\IdentityServer4.Contrib.RedisStore.xml 31 | 32 | 33 | 34 | bin\Release\netcoreapp3.1\IdentityServer4.Contrib.RedisStore.xml 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/IdentityServer4.Contrib.RedisStore.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | $id$ 5 | $version$ 6 | $title$ 7 | Ali Bazzi 8 | Ali Bazzi 9 | false 10 | $description$ 11 | Add support to use already established connection through ConnectionMultiplexer 12 | Copyright 2019 13 | IdentityServer IdentityServer4 Redis Store 14 | 15 | -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/Stores/PersistedGrantStore.cs: -------------------------------------------------------------------------------- 1 | using IdentityServer4.Extensions; 2 | using IdentityServer4.Models; 3 | using IdentityServer4.Stores; 4 | using Microsoft.AspNetCore.Authentication; 5 | using Microsoft.Extensions.Logging; 6 | using Newtonsoft.Json; 7 | using StackExchange.Redis; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Threading.Tasks; 12 | 13 | namespace IdentityServer4.Contrib.RedisStore.Stores 14 | { 15 | /// 16 | /// Provides the implementation of IPersistedGrantStore for Redis Cache. 17 | /// 18 | public class PersistedGrantStore : IPersistedGrantStore 19 | { 20 | protected readonly RedisOperationalStoreOptions options; 21 | 22 | protected readonly IDatabase database; 23 | 24 | protected readonly ILogger logger; 25 | 26 | protected ISystemClock clock; 27 | 28 | public PersistedGrantStore(RedisMultiplexer multiplexer, ILogger logger, ISystemClock clock) 29 | { 30 | if (multiplexer is null) 31 | throw new ArgumentNullException(nameof(multiplexer)); 32 | this.options = multiplexer.RedisOptions; 33 | this.database = multiplexer.Database; 34 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 35 | this.clock = clock; 36 | } 37 | 38 | protected string GetKey(string key) => $"{this.options.KeyPrefix}{key}"; 39 | 40 | protected string GetSetKey(string subjectId) => $"{this.options.KeyPrefix}{subjectId}"; 41 | 42 | protected string GetSetKey(string subjectId, string clientId) => $"{this.options.KeyPrefix}{subjectId}:{clientId}"; 43 | 44 | protected string GetSetKeyWithType(string subjectId, string clientId, string type) => $"{this.options.KeyPrefix}{subjectId}:{clientId}:{type}"; 45 | 46 | protected string GetSetKeyWithSession(string subjectId, string clientId, string sessionId) => $"{this.options.KeyPrefix}{subjectId}:{clientId}:{sessionId}"; 47 | 48 | public virtual async Task StoreAsync(PersistedGrant grant) 49 | { 50 | if (grant == null) 51 | throw new ArgumentNullException(nameof(grant)); 52 | try 53 | { 54 | var data = ConvertToJson(grant); 55 | var grantKey = GetKey(grant.Key); 56 | var expiresIn = grant.Expiration - this.clock.UtcNow; 57 | if (!string.IsNullOrEmpty(grant.SubjectId)) 58 | { 59 | var setKeyforType = GetSetKeyWithType(grant.SubjectId, grant.ClientId, grant.Type); 60 | var setKeyforSubject = GetSetKey(grant.SubjectId); 61 | var setKeyforClient = GetSetKey(grant.SubjectId, grant.ClientId); 62 | var setKetforSession = GetSetKeyWithSession(grant.SubjectId, grant.ClientId, grant.SessionId); 63 | 64 | var ttlOfClientSet = this.database.KeyTimeToLiveAsync(setKeyforClient); 65 | var ttlOfSubjectSet = this.database.KeyTimeToLiveAsync(setKeyforSubject); 66 | var ttlofSessionSet = this.database.KeyTimeToLiveAsync(setKetforSession); 67 | 68 | await Task.WhenAll(ttlOfSubjectSet, ttlOfClientSet, ttlofSessionSet); 69 | 70 | var transaction = this.database.CreateTransaction(); 71 | transaction.StringSetAsync(grantKey, data, expiresIn); 72 | transaction.SetAddAsync(setKeyforSubject, grantKey); 73 | transaction.SetAddAsync(setKeyforClient, grantKey); 74 | transaction.SetAddAsync(setKeyforType, grantKey); 75 | if (!grant.SessionId.IsNullOrEmpty()) 76 | transaction.SetAddAsync(setKetforSession, grantKey); 77 | if ((ttlOfSubjectSet.Result ?? TimeSpan.Zero) <= expiresIn) 78 | transaction.KeyExpireAsync(setKeyforSubject, expiresIn); 79 | if ((ttlOfClientSet.Result ?? TimeSpan.Zero) <= expiresIn) 80 | transaction.KeyExpireAsync(setKeyforClient, expiresIn); 81 | if (!grant.SessionId.IsNullOrEmpty() && (ttlofSessionSet.Result ?? TimeSpan.Zero) <= expiresIn) 82 | transaction.KeyExpireAsync(setKetforSession, expiresIn); 83 | transaction.KeyExpireAsync(setKeyforType, expiresIn); 84 | await transaction.ExecuteAsync(); 85 | } 86 | else 87 | { 88 | await this.database.StringSetAsync(grantKey, data, expiresIn); 89 | } 90 | logger.LogDebug("grant for subject {subjectId}, clientId {clientId}, grantType {grantType} and sessionId {session} persisted successfully", grant.SubjectId, grant.ClientId, grant.Type, grant.SessionId); 91 | } 92 | catch (Exception ex) 93 | { 94 | logger.LogError(ex, "exception storing persisted grant to Redis database for subject {subjectId}, clientId {clientId}, grantType {grantType} and session {sessionId}", grant.SubjectId, grant.ClientId, grant.Type, grant.SessionId); 95 | throw; 96 | } 97 | } 98 | 99 | public virtual async Task GetAsync(string key) 100 | { 101 | try 102 | { 103 | var data = await this.database.StringGetAsync(GetKey(key)); 104 | logger.LogDebug("{key} found in database: {hasValue}", key, data.HasValue); 105 | return data.HasValue ? ConvertFromJson(data) : null; 106 | } 107 | catch (Exception ex) 108 | { 109 | logger.LogError(ex, "exception retrieving grant for key {key}", key); 110 | throw; 111 | } 112 | } 113 | 114 | public virtual async Task> GetAllAsync(PersistedGrantFilter filter) 115 | { 116 | try 117 | { 118 | var setKey = GetSetKey(filter); 119 | var (grants, keysToDelete) = await GetGrants(setKey); 120 | if (keysToDelete.Any()) 121 | { 122 | var keys = keysToDelete.ToArray(); 123 | var transaction = this.database.CreateTransaction(); 124 | transaction.SetRemoveAsync(GetSetKey(filter.SubjectId), keys); 125 | transaction.SetRemoveAsync(GetSetKey(filter.SubjectId, filter.ClientId), keys); 126 | transaction.SetRemoveAsync(GetSetKeyWithType(filter.SubjectId, filter.ClientId, filter.Type), keys); 127 | transaction.SetRemoveAsync(GetSetKeyWithSession(filter.SubjectId, filter.ClientId, filter.SessionId), keys); 128 | await transaction.ExecuteAsync(); 129 | } 130 | logger.LogDebug("{grantsCount} persisted grants found for {subjectId}", grants.Count(), filter.SubjectId); 131 | return grants.Where(_ => _.HasValue).Select(_ => ConvertFromJson(_)).Where(_ => IsMatch(_, filter)); 132 | } 133 | catch (Exception ex) 134 | { 135 | logger.LogError(ex, "exception while retrieving grants"); 136 | throw; 137 | } 138 | } 139 | 140 | protected virtual async Task<(IEnumerable grants, IEnumerable keysToDelete)> GetGrants(string setKey) 141 | { 142 | var grantsKeys = await this.database.SetMembersAsync(setKey); 143 | if (!grantsKeys.Any()) 144 | return (Enumerable.Empty(), Enumerable.Empty()); 145 | var grants = await this.database.StringGetAsync(grantsKeys.Select(_ => (RedisKey)_.ToString()).ToArray()); 146 | var keysToDelete = grantsKeys.Zip(grants, (key, value) => new KeyValuePair(key, value)) 147 | .Where(_ => !_.Value.HasValue).Select(_ => _.Key); 148 | return (grants, keysToDelete); 149 | } 150 | 151 | public virtual async Task RemoveAsync(string key) 152 | { 153 | try 154 | { 155 | var grant = await this.GetAsync(key); 156 | if (grant == null) 157 | { 158 | logger.LogDebug("no {key} persisted grant found in database", key); 159 | return; 160 | } 161 | var grantKey = GetKey(key); 162 | logger.LogDebug("removing {key} persisted grant from database", key); 163 | var transaction = this.database.CreateTransaction(); 164 | transaction.KeyDeleteAsync(grantKey); 165 | transaction.SetRemoveAsync(GetSetKey(grant.SubjectId), grantKey); 166 | transaction.SetRemoveAsync(GetSetKey(grant.SubjectId, grant.ClientId), grantKey); 167 | transaction.SetRemoveAsync(GetSetKeyWithType(grant.SubjectId, grant.ClientId, grant.Type), grantKey); 168 | transaction.SetRemoveAsync(GetSetKeyWithSession(grant.SubjectId, grant.ClientId, grant.SessionId), grantKey); 169 | await transaction.ExecuteAsync(); 170 | } 171 | catch (Exception ex) 172 | { 173 | logger.LogError(ex, "exception removing {key} persisted grant from database", key); 174 | throw; 175 | } 176 | 177 | } 178 | 179 | public virtual async Task RemoveAllAsync(PersistedGrantFilter filter) 180 | { 181 | try 182 | { 183 | filter.Validate(); 184 | var setKey = GetSetKey(filter); 185 | var grants = await this.database.SetMembersAsync(setKey); 186 | logger.LogDebug("removing {grantKeysCount} persisted grants from database for subject {subjectId}, clientId {clientId}, grantType {type} and session {session}", grants.Count(), filter.SubjectId, filter.ClientId, filter.Type, filter.SessionId); 187 | if (!grants.Any()) return; 188 | var transaction = this.database.CreateTransaction(); 189 | transaction.KeyDeleteAsync(grants.Select(_ => (RedisKey)_.ToString()).Concat(new RedisKey[] { setKey }).ToArray()); 190 | transaction.SetRemoveAsync(GetSetKey(filter.SubjectId), grants); 191 | transaction.SetRemoveAsync(GetSetKey(filter.SubjectId, filter.ClientId), grants); 192 | transaction.SetRemoveAsync(GetSetKeyWithType(filter.SubjectId, filter.ClientId, filter.Type), grants); 193 | transaction.SetRemoveAsync(GetSetKeyWithSession(filter.SubjectId, filter.ClientId, filter.SessionId), grants); 194 | await transaction.ExecuteAsync(); 195 | } 196 | catch (Exception ex) 197 | { 198 | logger.LogError(ex, "exception removing persisted grants from database for subject {subjectId}, clientId {clientId}, grantType {type} and session {session}", filter.SubjectId, filter.ClientId, filter.Type, filter.SessionId); 199 | throw; 200 | } 201 | } 202 | 203 | protected virtual string GetSetKey(PersistedGrantFilter filter) => 204 | (!filter.ClientId.IsNullOrEmpty(), !filter.SessionId.IsNullOrEmpty(), !filter.Type.IsNullOrEmpty()) switch 205 | { 206 | (true, true, false) => GetSetKeyWithSession(filter.SubjectId, filter.ClientId, filter.SessionId), 207 | (true, _, false) => GetSetKey(filter.SubjectId, filter.ClientId), 208 | (true, _, true) => GetSetKeyWithType(filter.SubjectId, filter.ClientId, filter.Type), 209 | _ => GetSetKey(filter.SubjectId), 210 | }; 211 | 212 | protected bool IsMatch(PersistedGrant grant, PersistedGrantFilter filter) 213 | { 214 | return (filter.SubjectId.IsNullOrEmpty() ? true : grant.SubjectId == filter.SubjectId) 215 | && (filter.ClientId.IsNullOrEmpty() ? true : grant.ClientId == filter.ClientId) 216 | && (filter.SessionId.IsNullOrEmpty() ? true : grant.SessionId == filter.SessionId) 217 | && (filter.Type.IsNullOrEmpty() ? true : grant.Type == filter.Type); 218 | } 219 | 220 | #region Json 221 | protected static string ConvertToJson(PersistedGrant grant) 222 | { 223 | return JsonConvert.SerializeObject(grant); 224 | } 225 | 226 | protected static PersistedGrant ConvertFromJson(string data) 227 | { 228 | return JsonConvert.DeserializeObject(data); 229 | } 230 | #endregion 231 | } 232 | } -------------------------------------------------------------------------------- /IdentityServer4.Contrib.RedisStore/icon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AliBazzi/IdentityServer4.Contrib.RedisStore/ddfaa756b07348d91ca7b0a2f611e13d13e5ee13/IdentityServer4.Contrib.RedisStore/icon.jpg -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mohammad Ali Bazzi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Automated tests](https://github.com/AliBazzi/IdentityServer4.Contrib.RedisStore/workflows/Automated%20tests/badge.svg) 2 | 3 | # IdentityServer4.Contrib.RedisStore 4 | 5 | IdentityServer4.Contrib.RedisStore is a persistence layer using [Redis](https://redis.io) DB for operational data and for caching capability for Identity Server 4. Specifically, this store provides implementation for [IPersistedGrantStore](http://docs.identityserver.io/en/release/topics/deployment.html#operational-data) and [ICache](http://docs.identityserver.io/en/release/topics/startup.html#caching). 6 | 7 | ## How to use 8 | 9 | You need to install the [nuget package](https://www.nuget.org/packages/IdentityServer4.Contrib.RedisStore) 10 | 11 | then you can inject the operational store in the Identity Server 4 Configuration at startup using one of the overloads of `AddOperationalStore`: 12 | 13 | ```csharp 14 | public void ConfigureServices(IServiceCollection services) 15 | { 16 | ... 17 | services.AddIdentityServer() 18 | ... 19 | .AddOperationalStore(options => 20 | { 21 | options.RedisConnectionString = "---redis store connection string---"; 22 | options.Db = 1; 23 | }) 24 | ... 25 | } 26 | ``` 27 | 28 | And for adding caching capability you can use `AddRedisCaching`: 29 | 30 | ```csharp 31 | public void ConfigureServices(IServiceCollection services) 32 | { 33 | ... 34 | services.AddIdentityServer() 35 | ... 36 | .AddRedisCaching(options => 37 | { 38 | options.RedisConnectionString = "---redis store connection string---"; 39 | options.KeyPrefix = "prefix"; 40 | }) 41 | ... 42 | } 43 | ``` 44 | 45 | As an alternative, you can pass ConfigurationOptions instance, which contains the configuration of Redis store: 46 | 47 | ```csharp 48 | public void ConfigureServices(IServiceCollection services) 49 | { 50 | var operationalStoreOptions = new ConfigurationOptions { /* ... */ }; 51 | var cacheOptions = new ConfigurationOptions { /* ... */ }; 52 | 53 | ... 54 | 55 | services.AddIdentityServer() 56 | ... 57 | .AddOperationalStore(options => 58 | { 59 | options.ConfigurationOptions = operationalStoreOptions; 60 | options.KeyPrefix = "another_prefix"; 61 | }) 62 | .AddRedisCaching(options => 63 | { 64 | options.ConfigurationOptions = cacheOptions; 65 | }) 66 | ... 67 | } 68 | ``` 69 | 70 | Finally, you have the option of passing an already established connection using `ConnectionMultiplexer` by passing it directly like: 71 | 72 | ```csharp 73 | public void ConfigureServices(IServiceCollection services) 74 | { 75 | ... 76 | 77 | services.AddIdentityServer() 78 | ... 79 | .AddOperationalStore(options => 80 | { 81 | options.RedisConnectionMultiplexer = connectionMultiplexer; 82 | }) 83 | .AddRedisCaching(options => 84 | { 85 | options.RedisConnectionMultiplexer = connectionMultiplexer; 86 | }) 87 | ... 88 | } 89 | ``` 90 | 91 | don't forget to register the caching for specific configuration store you like to apply the caching on after registering the services, like the following: 92 | 93 | ```csharp 94 | public void ConfigureServices(IServiceCollection services) 95 | { 96 | ... 97 | 98 | services.AddIdentityServer() 99 | ... 100 | .AddRedisCaching(options => 101 | { 102 | options.ConfigurationOptions = cacheOptions; 103 | }) 104 | ... 105 | .AddClientStoreCache() 106 | .AddResourceStoreCache() 107 | .AddCorsPolicyCache() 108 | .AddProfileServiceCache() 109 | ... 110 | } 111 | 112 | ``` 113 | 114 | In this previous snippet, registration of caching capability are added for Client Store, Resource Store and Cors Policy Service, and it's registered for [Entity Framework stores](https://github.com/IdentityServer/IdentityServer4/tree/main/src/EntityFramework.Storage) in this case, but if you have your own Stores you should register them here in order to allow the caching for these specific stores. 115 | 116 | > Note: operational store and caching are not related, you can use them separately or combined. 117 | 118 | > Note: for `AddProfileServiceCache`, you can configure it with custom key selector, the default implementation is to select `sub` claim value. 119 | 120 | ## the solution approach 121 | 122 | the solution was approached based on how the [SQL Store](https://github.com/IdentityServer/IdentityServer4/tree/main/src/EntityFramework.Storage) storing the operational data, but the concept of Redis as a NoSQL db is totally different than relational db concepts, all the operational data stores implement the following [IPersistedGrantStore](https://github.com/IdentityServer/IdentityServer4/blob/main/src/Storage/src/Stores/IPersistedGrantStore.cs) interface: 123 | 124 | ```csharp 125 | public interface IPersistedGrantStore 126 | { 127 | Task StoreAsync(PersistedGrant grant); 128 | 129 | Task GetAsync(string key); 130 | 131 | Task> GetAllAsync(PersistedGrantFilter filter); 132 | 133 | Task RemoveAsync(string key); 134 | 135 | Task RemoveAllAsync(PersistedGrantFilter filter); 136 | } 137 | ``` 138 | 139 | with the IPersistedGrantStore contract, we notice that the GetAllAsync(filter), RemoveAllAsync(filter) defines a contract to read based on subject id and remove all the grants in the store based on subject, client ids and/or session ids and type of the grant. 140 | 141 | this brings trouble to Redis store since redis as a reliable dictionary is not designed for relational queries, so the trick is to store multiple key entries for the same grant, and the keys can be reached using key, subject, client ids, session ids and type. 142 | 143 | so the StoreAsync operation stores the following entries in Redis: 144 | 145 | 1. Key -> RedisStruct: stored as key string value pairs, used to retrieve/remove the grant based on the key, if the grant exists or not expired. 146 | 147 | 1. Key(SubjectId) -> Key\* : stored in a redis Set, used on retrieve all/remove all, to retrieve all the grant related to a given subject id. 148 | 149 | 1. Key(SubjectId,ClientId) -> Key\* : stored in a redis set, used to retrieve all/remove all the keys that are related to a subject and client ids. 150 | 151 | 1. Key(SubjectId,ClientId,type) -> Key\* : stored in a redis set, used to retrieve all/remove all the keys that are related to a subject, client ids and type of the grant. 152 | 153 | 1. Key(SubjectId,ClientId,SessionId) -> Key\* : stored in a redis set, used to retrieve all/remove all the keys that are related to a subject, client ids, sessions ids. 154 | 155 | for more information on data structures used to store the grant please refer to [Redis data types documentation](https://redis.io/topics/data-types) 156 | 157 | > Note: PersistedGrantFilter combinations are not all covered by sets persisted in Redis, if the combination used to be not mapping to an already existing set key, then the retrieval for grants will fallback to Key(SubjectId) -> Key, and the evaluation of the filter will happen on client side. 158 | 159 | since Redis has a [key Expiration](https://redis.io/commands/expire) feature based on a defined date time or time span, and to not implement a logic similar to SQL store implementation for [cleaning up the store](http://docs.identityserver.io/en/release/quickstarts/8_entity_framework.html) periodically from dangling grants, the store uses the key expiration of Redis while storing entries based on the following criteria: 160 | 161 | 1. for Key of the grant, the expiration is straight forward, it's set on the StringSet Redis operation as defined by identity server on the grant object. 162 | 163 | 1. for `Key(SubjectId,ClientId,type)` it's absolute expiry time, but it will be extended every time new entry is added to the set. 164 | 165 | 1. for `Key(SubjectId)`, `Key(SubjectId,ClientId)` and `Key(SubjectId,ClientId,SessionId)` the expiration is sliding, and it will slide on every entry added to the set, since the same and only store type is persisting the grants regardless of their type, not like the identity server 3, where it has multiple stores for each grant type. and we are setting expiration for Key(SubjectId,clientId,type) since this set for the same grant type, and client, so the keys are consistent here. 166 | 167 | > Note: because of the sliding expiration mechanism, if Redis instance memory is full, [eviction](https://redis.io/topics/lru-cache) will take places based on the policy you set. 168 | 169 | ## Feedback 170 | 171 | feedbacks are always welcomed, please open an issue for any problem or bug found, and the suggestions are also welcomed. 172 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | dynamodb-local: 4 | image: redis:latest 5 | ports: 6 | - "6379:6379" -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | up: 2 | make down 3 | docker-compose up -d 4 | 5 | down: 6 | docker-compose down 7 | 8 | test: 9 | make up 10 | dotnet test 11 | make down --------------------------------------------------------------------------------