diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 283d03f99..1ca292506 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,10 +12,10 @@ _Please include a summary of the changes in this pull request. List the issue n ## Does this PR introduce a breaking change? -_Does the change cause existing functionality to not work as previously expected, or does the DB schema or C# public API surface change?_ +_Does the change cause a breaking change to any of the following?_ -- [ ] Yes -- [ ] No +- [ ] Changes to existing public APIs or DB schema +- [ ] Changes which could cause existing behaviour to fail (eg. feature restrictions, changes to default values, stricter validation, etc) ## Testing diff --git a/docs/reference/ef.rst b/docs/reference/ef.rst index 4fcb4357c..6469dec4c 100644 --- a/docs/reference/ef.rst +++ b/docs/reference/ef.rst @@ -39,6 +39,79 @@ To use the configuration store support, use the ``AddConfigurationStore`` extens To configure the configuration store, use the ``ConfigurationStoreOptions`` options object passed to the configuration callback. +Customize the Models Created for Clients and Resources +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to extend the Client and/or Resource models that are returned from the configuration stores, you can do so by +creating your own models that inherit from the default models and then override the ToModel methods in a custom store. Below +is an example of how to do this for the Client model. The same approach can be used for the Resources. + +Start by extending the Client model: + +.. code-block:: csharp + + public class MyClient : Client + { + private const string CibaNotificationEndpointKey = nameof(CibaNotificationEndpoint); + + public DateTime? LastAccessed { get; set; } + + public string? CibaNotificationEndpoint + { + get + { + if (!Properties.ContainsKey(Client.CibaNotificationEndpointKey)) + return null; + return Properties[Client.CibaNotificationEndpointKey]; + } + set + { + if (string.IsNullOrEmpty(value)) + Properties.Remove(CibaNotificationEndpointKey); + else + Properties[CibaNotificationEndpointKey] = value; + } + } + } + +Then create a custom store that overrides the ToModel method: + +.. code-block:: csharp + + public class MyClientStore : ClientStore + { + public MyClientStore(ConfigurationDbContext context, ITelemetryService telemetry, IOptions options) + : base(context, telemetry, options) + { + } + + protected override Models.Client ToModel(Entities.Client client) + { + var model = client.ToModel(); + + //Map additional properties here (not needed for properties that uses the Properties for storage and retrieval) + model.LastAccessed = client.LastAccessed; + + return model; + } + } + +Finally register your custom store in the DI container: + +.. code-block:: csharp + + services.AddIdentityServer() + .AddConfigurationStore(options => + { + options.ConfigureDbContext = builder => + builder.UseSqlServer(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)); + }) + .AddClientStore(); + +By doing this you can now use your own models in customizations or extensions of the built-in support without needing to +create your own store from scratch (including all mapping of existing properties). + ConfigurationStoreOptions ^^^^^^^^^^^^^^^^^^^^^^^^^ This options class contains properties to control the configuration store and ``ConfigurationDbContext``. diff --git a/src/AspNetIdentity/host/Host.csproj b/src/AspNetIdentity/host/Host.csproj index 7810c5a08..633a3efd1 100644 --- a/src/AspNetIdentity/host/Host.csproj +++ b/src/AspNetIdentity/host/Host.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs index 187243b7a..643c86e92 100644 --- a/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs @@ -22,7 +22,18 @@ public static class ApiResourceMappingExtensions /// mapped instance of public Models.ApiResource ToModel() { - return new Models.ApiResource + return apiResourceEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.ApiResource, new() + { + return new T { Enabled = apiResourceEntity.Enabled, Name = apiResourceEntity.Name, diff --git a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs index ac9d823fc..0158e3c3c 100644 --- a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs @@ -27,7 +27,18 @@ public static class ClientMappingExtensions /// mapped instance of public Models.Client ToModel() { - return new Models.Client + return clientEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.Client, new() + { + return new T { Enabled = clientEntity.Enabled, ClientId = clientEntity.ClientId, @@ -77,6 +88,7 @@ public Models.Client ToModel() DeviceCodeLifetime = clientEntity.DeviceCodeLifetime, AllowedCorsOrigins = clientEntity.AllowedCorsOrigins.ToStringCollection(), Properties = clientEntity.Properties.ToModelDictionary(), + PollingInterval = clientEntity.PollingInterval, }; } } @@ -181,6 +193,7 @@ public Entities.Client ToEntity() DeviceCodeLifetime = clientModel.DeviceCodeLifetime, AllowedCorsOrigins = clientModel.AllowedCorsOrigins?.Select(x => new ClientCorsOrigin { Origin = x }).ToList() ?? [], Properties = clientModel.Properties.ToEntityList(), + PollingInterval = clientModel.PollingInterval, }; } } diff --git a/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs index d30c6180d..13853e072 100644 --- a/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs @@ -20,7 +20,18 @@ public static class IdentityResourceMappingExtensions /// mapped instance of public Models.IdentityResource ToModel() { - return new Models.IdentityResource + return identityResourceEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.IdentityResource, new() + { + return new T { Enabled = identityResourceEntity.Enabled, Name = identityResourceEntity.Name, diff --git a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs index f869874fa..c633dcf46 100644 --- a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs @@ -20,7 +20,18 @@ public static class ScopeMappingExtensions /// mapped instance of public Models.ApiScope ToModel() { - return new Models.ApiScope + return apiScopeEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.ApiScope, new() + { + return new T { Enabled = apiScopeEntity.Enabled, Name = apiScopeEntity.Name, diff --git a/src/EntityFramework.Storage/src/Stores/ClientStore.cs b/src/EntityFramework.Storage/src/Stores/ClientStore.cs index 56e278e32..f24314b99 100644 --- a/src/EntityFramework.Storage/src/Stores/ClientStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ClientStore.cs @@ -80,10 +80,24 @@ public virtual async Task FindClientByIdAsync(string clientId) await baseQuery.Include(x => x.Properties).SelectMany(c => c.Properties).LoadAsync(); await baseQuery.Include(x => x.RedirectUris).SelectMany(c => c.RedirectUris).LoadAsync(); - var model = client.ToModel(); + var model = ToModel(client); Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null); return model; } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending Client. + /// + /// Makes it possible to return an extended model. + /// + protected virtual Client ToModel(Entities.Client client) + { + return client.ToModel(); + } + } \ No newline at end of file diff --git a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs index 55920a2df..d97fed335 100644 --- a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs @@ -20,8 +20,7 @@ namespace Open.IdentityServer.EntityFramework.Stores; /// /// Implementation of IResourceStore that uses EF. /// -/// - +/// public class ResourceStore : IResourceStore { /// @@ -79,7 +78,7 @@ where apiResourceNames.Contains(apiResource.Name) var result = (await apis.ToArrayAsync()) .Where(x => apiResourceNames.Contains(x.Name)) - .Select(x => x.ToModel()).ToArray(); + .Select(ToApiResourceModel).ToArray(); if (result.Any()) { @@ -107,7 +106,7 @@ public virtual async Task> FindApiResourcesByScopeNameA var query = from api in Context.ApiResources - where api.Scopes.Where(x => names.Contains(x.Scope)).Any() + where api.Scopes.Any(x => names.Contains(x.Scope)) select api; var apis = query @@ -119,13 +118,26 @@ where api.Scopes.Where(x => names.Contains(x.Scope)).Any() var results = (await apis.ToArrayAsync()) .Where(api => api.Scopes.Any(x => names.Contains(x.Scope))); - var models = results.Select(x => x.ToModel()).ToArray(); + var models = results.Select(ToApiResourceModel).ToArray(); Logger.LogDebug("Found {apis} API resources in database", models.Select(x => x.Name)); return models; } + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending ApiScope. + /// + /// Makes it possible to return an extended model. + /// + protected virtual ApiResource ToApiResourceModel(Entities.ApiResource resource) + { + return resource.ToModel(); + } + /// /// Gets identity resources by scope name. /// @@ -153,7 +165,20 @@ where scopes.Contains(identityResource.Name) Logger.LogDebug("Found {scopes} identity scopes in database", results.Select(x => x.Name)); - return results.Select(x => x.ToModel()).ToArray(); + return results.Select(ToIdentityResourceModel).ToArray(); + } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending IdentityResource. + /// + /// Makes it possible to return an extended model. + /// + protected virtual IdentityResource ToIdentityResourceModel(Entities.IdentityResource resource) + { + return resource.ToModel(); } /// @@ -183,7 +208,20 @@ where scopes.Contains(scope.Name) Logger.LogDebug("Found {scopes} scopes in database", results.Select(x => x.Name)); - return results.Select(x => x.ToModel()).ToArray(); + return results.Select(ToApiScopeModel).ToArray(); + } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending ApiScope. + /// + /// Makes it possible to return an extended model. + /// + protected virtual ApiScope ToApiScopeModel(Entities.ApiScope scope) + { + return scope.ToModel(); } /// @@ -211,9 +249,9 @@ public virtual async Task GetAllResourcesAsync() .AsNoTracking(); var result = new Resources( - (await identity.ToArrayAsync()).Select(x => x.ToModel()), - (await apis.ToArrayAsync()).Select(x => x.ToModel()), - (await scopes.ToArrayAsync()).Select(x => x.ToModel()) + (await identity.ToArrayAsync()).Select(ToIdentityResourceModel), + (await apis.ToArrayAsync()).Select(ToApiResourceModel), + (await scopes.ToArrayAsync()).Select(ToApiScopeModel) ); Logger.LogDebug("Found {scopes} as all scopes, and {apis} as API resources", diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs index 4a76a8ce1..09e2d3aaa 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs @@ -75,6 +75,34 @@ public async Task FindClientByIdAsync_WhenClientExists_ExpectClientRetured( client.Should().NotBeNull(); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientExists_ExpectExtendedClientReturned( + DbContextOptions options) + { + var testClient = new Client + { + ClientId = "test_extended_client", + ClientName = "Test Extended Client", + Properties = { { "x", "xx" } } + }; + + await using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.Clients.Add(testClient.ToEntity()); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + ExtendedClient client; + await using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedClientStore(context, _telemetry, FakeLogger.Create()); + client = await store.FindClientByIdAsync(testClient.ClientId) as ExtendedClient; + } + + client.Should().NotBeNull(); + client.X.Should().Be("xx"); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections( DbContextOptions options) diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs new file mode 100644 index 000000000..bbdf7ed76 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.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. + +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedApiResource : ApiResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs new file mode 100644 index 000000000..676cba10e --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.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. + +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedApiScope : ApiScope +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs new file mode 100644 index 000000000..410604250 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.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. + +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedClient : Client +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs new file mode 100644 index 000000000..055a566ff --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedClientStore : ClientStore +{ + public ExtendedClientStore(IConfigurationDbContext context, ITelemetryService telemetry, ILogger logger) + : base(context, telemetry, logger) + { + } + + protected override Models.Client ToModel(Entities.Client client) + { + var model = client.ToModel(); + + model.Created = client.Created; + model.Updated = client.Updated; + model.LastAccessed = client.LastAccessed; + + return model; + } +} diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs new file mode 100644 index 000000000..354f55c5b --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs @@ -0,0 +1,19 @@ +// 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 Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedIdentityResource : IdentityResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs new file mode 100644 index 000000000..f0cd95f63 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -0,0 +1,50 @@ +// 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.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedResourceStore : ResourceStore +{ + public ExtendedResourceStore(IConfigurationDbContext context, ITelemetryService telemetry, ILogger logger) + : base(context, telemetry, logger) + { + } + + protected override Models.ApiResource ToApiResourceModel(Entities.ApiResource resource) + { + var model = resource.ToModel(); + + model.Created = resource.Created; + model.Updated = resource.Updated; + model.LastAccessed = resource.LastAccessed; + + return model; + } + + protected override Models.IdentityResource ToIdentityResourceModel(Entities.IdentityResource resource) + { + var model = resource.ToModel(); + + model.Created = resource.Created; + model.Updated = resource.Updated; + + return model; + } + + protected override Models.ApiScope ToApiScopeModel(Entities.ApiScope scope) + { + var model = scope.ToModel(); + + model.Created = scope.Created; + model.Updated = scope.Updated; + model.LastAccessed = scope.LastAccessed; + + return model; + } +} diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs index 6eac4ae9e..6365de7a4 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs @@ -3,20 +3,20 @@ // 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.Threading.Tasks; using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; using Open.IdentityServer.EntityFramework.DbContexts; +using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; using Open.IdentityServer.Models; -using Microsoft.EntityFrameworkCore; -using Moq; -using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.Services; using Open.IdentityServer.Utility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; @@ -107,6 +107,28 @@ public async Task FindApiResourcesByNameAsync_WhenResourceExists_ExpectResourceA Assert.NotEmpty(foundResource.Scopes); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiResourcesByNameAsync_WhenResourceExists_ExpectExtendedResourceReturned(DbContextOptions options) + { + var resource = CreateApiResourceTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiResources.Add(resource.ToEntity()); + context.SaveChanges(); + } + + ExtendedApiResource foundResource; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + foundResource = (await store.FindApiResourcesByNameAsync([resource.Name])).SingleOrDefault() as ExtendedApiResource; + } + + Assert.NotNull(foundResource); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindApiResourcesByNameAsync_WhenResourcesExist_ExpectOnlyResourcesRequestedReturned(DbContextOptions options) { @@ -166,6 +188,36 @@ public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectReso Assert.NotNull(resources.Single(x => x.Name == testApiResource.Name)); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectExtendedApiScopeReturned(DbContextOptions options) + { + var testApiResource = CreateApiResourceTestResource(); + var testApiScope = CreateApiScopeTestResource(); + testApiResource.Scopes.Add(testApiScope.Name); + testApiResource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiResources.Add(testApiResource.ToEntity()); + context.ApiScopes.Add(testApiScope.ToEntity()); + context.SaveChanges(); + } + + IEnumerable resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = await store.FindApiResourcesByScopeNameAsync( + [ + testApiScope.Name + ]); + } + + Assert.NotNull(resources); + var extendedApiResource = resources.SingleOrDefault(x => x.Name == testApiResource.Name) as ExtendedApiResource; + Assert.NotNull(extendedApiResource); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectOnlyResourcesRequestedReturned(DbContextOptions options) { @@ -227,6 +279,33 @@ public async Task FindIdentityResourcesByScopeNameAsync_WhenResourceExists_Expec Assert.NotEmpty(foundScope.UserClaims); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindIdentityResourcesByScopeNameAsync_WhenResourceExists_ExpectExtendedResourceReturned(DbContextOptions options) + { + var resource = CreateIdentityTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.IdentityResources.Add(resource.ToEntity()); + context.SaveChanges(); + } + + IList resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = (await store.FindIdentityResourcesByScopeNameAsync(new List + { + resource.Name + })).ToList(); + } + + Assert.NotNull(resources); + var foundScope = resources.Single() as ExtendedIdentityResource; + Assert.NotNull(foundScope); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindIdentityResourcesByScopeNameAsync_WhenResourcesExist_ExpectOnlyRequestedReturned(DbContextOptions options) { @@ -313,6 +392,33 @@ public async Task FindApiScopesByNameAsync_WhenResourcesExist_ExpectOnlyRequeste Assert.NotNull(resources.Single(x => x.Name == resource.Name)); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiScopesByNameAsync_WhenResourcesExist_ExpectExtendedApiScopeReturned(DbContextOptions options) + { + var resource = CreateApiScopeTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiScopes.Add(resource.ToEntity()); + context.SaveChanges(); + } + + IList resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = (await store.FindApiScopesByNameAsync(new List + { + resource.Name + })).ToList(); + } + + Assert.NotNull(resources); + var extendedApiScope = resources.SingleOrDefault(x => x.Name == resource.Name) as ExtendedApiScope; + Assert.NotNull(extendedApiScope); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task GetAllResources_WhenAllResourcesRequested_ExpectAllResourcesIncludingHidden(DbContextOptions options) { diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs index 35d37aa3f..f282780a2 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs @@ -2,11 +2,12 @@ // 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.Collections.Generic; -using System.Linq; using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Entities; using Open.IdentityServer.EntityFramework.Mappers; +using System; +using System.Collections.Generic; +using System.Linq; using Xunit; using ApiResource = Open.IdentityServer.Models.ApiResource; using Secret = Open.IdentityServer.Models.Secret; @@ -140,6 +141,29 @@ public void EntitiesApiResourceToModel_MissingValues_ShouldUseDefaults() model.ApiSecrets.First().Type.Should().Be(def.ApiSecrets.First().Type); } + [Fact] + public void CanMapToExtendedApiResourceModel() + { + var entity = new Entities.ApiResource + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-10), + Properties = [new ApiResourceProperty { Key = "x", Value = "xx" }, new ApiResourceProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -160,4 +184,38 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedApiResourceMappingExtensions +{ + extension(Entities.ApiResource apiResourceEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedApiResource ToExtendedModel() + { + var model = apiResourceEntity.ToModel(); + model.Created = apiResourceEntity.Created; + model.Updated = apiResourceEntity.Updated; + model.LastAccessed = apiResourceEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedApiResource : ApiResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs index c419b6303..9be1ad1a6 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs @@ -98,6 +98,28 @@ public void missing_values_should_use_defaults() model.ClientSecrets.First().Type.Should().Be(def.ClientSecrets.First().Type); } + [Fact] + public void CanMapToExtendedClientModel() + { + var entity = new Entities.Client + { + ClientName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-25), + Properties = [new Entities.ClientProperty { Key = "x", Value = "xx" }, new Entities.ClientProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -111,7 +133,6 @@ public void ToEntity_maps_all_properties() nameof(Entities.Client.NonEditable), // Compatibility properties intentionally not mapped nameof(Entities.Client.CibaLifetime), - nameof(Entities.Client.PollingInterval), nameof(Entities.Client.CoordinateLifetimeWithUserSession), nameof(Entities.Client.InitiateLoginUri), nameof(Entities.Client.DPoPClockSkew), @@ -129,7 +150,6 @@ public void ToModel_maps_all_properties() .ExcludeDestinationProperties( // Compatibility properties intentionally not mapped nameof(Client.CibaLifetime), - nameof(Client.PollingInterval), nameof(Client.CoordinateLifetimeWithUserSession), nameof(Client.InitiateLoginUri), nameof(Client.DPoPClockSkew), @@ -139,4 +159,39 @@ public void ToModel_maps_all_properties() nameof(Client.RequirePushedAuthorization)) .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedClientMappingExtensions +{ + extension(Entities.Client clientEntity) + { + /// + /// Mapper for to convert into an instance of + + /// + /// mapped instance of + public ExtendedClient ToExtendedModel() + { + var model = clientEntity.ToModel(); + model.Created = clientEntity.Created; + model.Updated = clientEntity.Updated; + model.LastAccessed = clientEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedClient : Client +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; internal set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs index 795f3e488..b65de5db4 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs @@ -3,8 +3,11 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.Models; +using System; +using System.Linq; using Xunit; namespace Open.IdentityServer.EntityFramework.UnitTests.Mappers; @@ -23,6 +26,27 @@ public void CanMapIdentityResources() Assert.NotNull(mappedEntity); } + [Fact] + public void CanMapToExtendedIdentityResourceModel() + { + var entity = new Entities.IdentityResource + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + Properties = [new Entities.IdentityResourceProperty { Key = "x", Value = "xx" }, new Entities.IdentityResourceProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -42,4 +66,36 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedIdentityResourceMappingExtensions +{ + extension(Entities.IdentityResource identityResourceEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedIdentityResource ToExtendedModel() + { + var model = identityResourceEntity.ToModel(); + model.Created = identityResourceEntity.Created; + model.Updated = identityResourceEntity.Updated; + + return model; + } + + } +} + +internal class ExtendedIdentityResource : IdentityResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs index b53af4969..bf4e1dbc7 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs @@ -3,6 +3,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; using System.Linq; using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Mappers; @@ -24,6 +25,30 @@ public void CanMapScope() Assert.NotNull(mappedEntity); } + [Fact] + public void CanMapToExtendedScopeModel() + { + var entity = new Entities.ApiScope + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-10), + UserClaims = [ new Entities.ApiScopeClaim { Type = "c1" }, new Entities.ApiScopeClaim { Type = "c2" } ], + Properties = [ new Entities.ApiScopeProperty { Key = "x", Value = "xx" }, new Entities.ApiScopeProperty { Key = "y", Value = "yy" } ] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void Properties_Map() { @@ -86,4 +111,38 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedScopeMappingExtensions +{ + extension(Entities.ApiScope apiScopeEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedApiScope ToExtendedModel() + { + var model = apiScopeEntity.ToModel(); + model.Created = apiScopeEntity.Created; + model.Updated = apiScopeEntity.Updated; + model.LastAccessed = apiScopeEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedApiScope : ApiScope +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Endpoints/Results/AuthorizeResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/AuthorizeResult.cs index 99fdbad48..81352b900 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/AuthorizeResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/AuthorizeResult.cs @@ -115,7 +115,7 @@ private async Task RenderAuthorizeResponseAsync(HttpContext context) Response.Request.ResponseMode == OidcConstants.ResponseModes.Fragment) { context.Response.SetNoCache(); - context.Response.Redirect(BuildRedirectUri()); + context.Response.RedirectToAbsoluteUrl(BuildRedirectUri()); } else if (Response.Request.ResponseMode == OidcConstants.ResponseModes.FormPost) { @@ -157,8 +157,8 @@ private string BuildRedirectUri() if (Response.IsError && !uri.Contains("#")) { - // https://tools.ietf.org/html/draft-bradley-oauth-open-redirector-00 - uri += "#_=_"; + // https://datatracker.ietf.org/doc/html/rfc9700#name-countermeasures + uri += "#_"; } return uri; diff --git a/src/Open.IdentityServer/src/Endpoints/Results/EndSessionResult.cs b/src/Open.IdentityServer/src/Endpoints/Results/EndSessionResult.cs index 00c198517..8b075c468 100644 --- a/src/Open.IdentityServer/src/Endpoints/Results/EndSessionResult.cs +++ b/src/Open.IdentityServer/src/Endpoints/Results/EndSessionResult.cs @@ -90,6 +90,6 @@ public async Task ExecuteAsync(HttpContext context) redirect = redirect.AddQueryString(_options.UserInteraction.LogoutIdParameter, id); } - context.Response.Redirect(redirect); + context.Response.RedirectToAbsoluteUrl(redirect); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/HttpResponseExtensions.cs b/src/Open.IdentityServer/src/Extensions/HttpResponseExtensions.cs index 8e5b22b84..c0c3c4848 100644 --- a/src/Open.IdentityServer/src/Extensions/HttpResponseExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/HttpResponseExtensions.cs @@ -124,7 +124,9 @@ public static void RedirectToAbsoluteUrl(this HttpResponse response, string url) if (url.StartsWith("~/")) url = url.Substring(1); url = response.HttpContext.GetIdentityServerBaseUrl().EnsureTrailingSlash() + url.RemoveLeadingSlash(); } - response.Redirect(url); + + response.Headers.Location = url; + response.StatusCode = StatusCodes.Status303SeeOther; } /// diff --git a/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs b/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs index c619901c0..b4d55176c 100644 --- a/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DistributedDeviceFlowThrottlingService.cs @@ -3,11 +3,12 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. -using System; -using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; using Open.IdentityServer.Configuration; using Open.IdentityServer.Models; -using Microsoft.Extensions.Caching.Distributed; +using Open.IdentityServer.Stores; +using System; +using System.Threading.Tasks; namespace Open.IdentityServer.Services; @@ -18,6 +19,7 @@ namespace Open.IdentityServer.Services; public class DistributedDeviceFlowThrottlingService : IDeviceFlowThrottlingService { private readonly IDistributedCache _cache; + private readonly IClientStore _clientStore; private readonly TimeProvider _clock; private readonly IdentityServerOptions _options; private readonly ITelemetryService _telemetry; @@ -28,16 +30,19 @@ public class DistributedDeviceFlowThrottlingService : IDeviceFlowThrottlingServi /// Initializes a new instance of the class. /// /// The cache. + /// The client store. /// The clock. /// The options. /// The telemetry public DistributedDeviceFlowThrottlingService( IDistributedCache cache, + IClientStore clientStore, TimeProvider clock, IdentityServerOptions options, ITelemetryService telemetry) { _cache = cache; + _clientStore = clientStore; _clock = clock; _options = options; _telemetry = telemetry; @@ -70,7 +75,8 @@ public async Task ShouldSlowDown(string deviceCode, DeviceCode details) // check interval if (DateTime.TryParse(lastSeenAsString, out var lastSeen)) { - if (_clock.GetUtcNow() < lastSeen.AddSeconds(_options.DeviceFlow.Interval)) + var client = await _clientStore.FindEnabledClientByIdAsync(details.ClientId); + if (_clock.GetUtcNow() < lastSeen.AddSeconds(client?.PollingInterval ?? _options.DeviceFlow.Interval)) { await _cache.SetStringAsync(key, _clock.GetUtcNow().ToString("O"), options); return true; 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 f03d6b190..81ce71835 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -214,7 +214,7 @@ private async Task IssueLoginCookie(HttpContext ctx) var url = ctx.Request.Query[Options!.UserInteraction.LoginReturnUrlParameter].FirstOrDefault(); if (url != null) { - ctx.Response.Redirect(url); + ctx.Response.RedirectToAbsoluteUrl(url); } } } @@ -263,7 +263,7 @@ private async Task CreateConsentResponse(HttpContext ctx) var url = ctx.Request.Query[Options!.UserInteraction.ConsentReturnUrlParameter].FirstOrDefault(); if (url != null) { - ctx.Response.Redirect(url); + ctx.Response.RedirectToAbsoluteUrl(url); } } } @@ -367,7 +367,7 @@ public async Task RequestAuthorizationEndpointAsync( var url = CreateAuthorizeUrl(clientId, responseType, scope, redirectUri, state, nonce, loginHint, acrValues, responseMode, codeChallenge, codeChallengeMethod, extra); var result = await BrowserClient.GetAsync(url); - result.StatusCode.Should().Be(HttpStatusCode.Found); + result.StatusCode.Should().Be(HttpStatusCode.SeeOther); BrowserClient.AllowAutoRedirect = old; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/RedirectUriTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/RedirectUriTests.cs index 89221cfb2..0b46da58f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/RedirectUriTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/RedirectUriTests.cs @@ -125,7 +125,7 @@ public async Task Preserves_query_parameters_in_redirect_uri() nonce: nonce); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://code_client/callback?"); var authorization = _mockPipeline.ParseAuthorizationResponseUrl(response.Headers.Location.ToString()); authorization.Code.Should().NotBeNull(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/ResponseTypeResponseModeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/ResponseTypeResponseModeTests.cs index 332b55c66..1562c1384 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/ResponseTypeResponseModeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Conformance/Basic/ResponseTypeResponseModeTests.cs @@ -82,7 +82,7 @@ public async Task Request_with_response_type_code_supported() state: state, nonce: nonce); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IsError.Should().BeFalse(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeResourceIndicatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeResourceIndicatorTests.cs index 494a249e8..854dd1d81 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeResourceIndicatorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeResourceIndicatorTests.cs @@ -70,7 +70,7 @@ public async Task CodeFlow_WithResourceIndicator_AuthenticatedUser_ShouldSucceed var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://server/cb"); @@ -215,7 +215,7 @@ public async Task HybridFlow_WithResourceIndicator_AuthenticatedUser_ShouldSucce var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://server/cb"); } @@ -296,7 +296,7 @@ public async Task ImplicitFlow_WithResourceIndicator_AuthenticatedUser_ShouldSuc var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("oob://implicit/cb"); @@ -326,7 +326,7 @@ public async Task ImplicitFlow_WithoutResourceIndicator_AuthenticatedUser_Should var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("oob://implicit/cb"); @@ -360,7 +360,7 @@ public async Task ImplicitFlow_WithResourceIndicator_AndScopesOutsideResourceReq var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("oob://implicit/cb"); @@ -491,7 +491,7 @@ public async Task CodeFlow_WithMultipleScopes_ForSingleResource_ShouldSucceed() var response = await mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://server/cb"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs index 15320f0b0..4f2953953 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthorizeTests.cs @@ -237,7 +237,7 @@ public async Task signin_response_should_allow_successful_authorization_response nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://client1/callback"); @@ -264,7 +264,7 @@ public async Task authenticated_user_with_valid_request_should_receive_authoriza nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://client1/callback"); @@ -308,7 +308,7 @@ public async Task login_response_and_consent_response_should_receive_authorizati nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.Should().NotBeNull(); response.Headers.Location!.ToString().Should().StartWith("https://client2/callback"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ConsentTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ConsentTests.cs index cfae09b0c..405a87990 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ConsentTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ConsentTests.cs @@ -196,7 +196,7 @@ public async Task consent_response_should_allow_successful_authorization_respons nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://client2/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); @@ -228,20 +228,20 @@ public async Task consent_response_should_reject_modified_request_params() nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://server/consent"); response = await _mockPipeline.BrowserClient.GetAsync(response.Headers.Location.ToString(), TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); - response.Headers.Location.ToString().Should().StartWith("/connect/authorize/callback"); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); + response.Headers.Location.ToString().Should().StartWith("https://server/connect/authorize/callback"); - var modifiedAuthorizeCallback = "https://server" + response.Headers.Location.ToString(); + var modifiedAuthorizeCallback = response.Headers.Location.ToString(); modifiedAuthorizeCallback = modifiedAuthorizeCallback.Replace("api2", "api1%20api2"); response = await _mockPipeline.BrowserClient.GetAsync(modifiedAuthorizeCallback, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://server/consent"); } @@ -265,7 +265,7 @@ public async Task consent_response_missing_required_scopes_should_error() state: "123_state", nonce: "123_nonce"); var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://client2/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/RestrictAccessTokenViaBrowserTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/RestrictAccessTokenViaBrowserTests.cs index f2779a7d3..48c9d4989 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/RestrictAccessTokenViaBrowserTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/RestrictAccessTokenViaBrowserTests.cs @@ -100,7 +100,7 @@ public async Task Unrestricted_implicit_client_can_request_IdToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client1/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); @@ -119,7 +119,7 @@ public async Task Unrestricted_implicit_client_can_request_IdTokenToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client1/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); @@ -138,7 +138,7 @@ public async Task Restricted_implicit_client_can_request_IdToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client2/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); @@ -171,7 +171,7 @@ public async Task Unrestricted_hybrid_client_can_request_CodeIdToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client3/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); @@ -191,7 +191,7 @@ public async Task Unrestricted_hybrid_client_can_request_CodeIdTokenToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client3/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); @@ -212,7 +212,7 @@ public async Task Restricted_hybrid_client_can_request_CodeIdToken() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Found); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.AbsoluteUri.Should().StartWith("https://client4/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); authorization.IdentityToken.Should().NotBeNull(); 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 fb7e2612b..8227c257a 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 @@ -151,7 +151,7 @@ public async Task get_request_should_redirect_to_configured_logout_path() "&post_logout_redirect_uri=https://client1/signout-callback", TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://server/logout?id="); } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Extensibility/CustomProfileServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Extensibility/CustomProfileServiceTests.cs index 7b67cedfd..6e7792e45 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Extensibility/CustomProfileServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Extensibility/CustomProfileServiceTests.cs @@ -68,7 +68,7 @@ public async Task custom_profile_should_return_claims_for_implicit_client() _mockPipeline.BrowserClient.AllowAutoRedirect = false; var response = await _mockPipeline.BrowserClient.GetAsync(url, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Headers.Location.ToString().Should().StartWith("https://client/callback"); var authorization = new AuthorizeResponse(response.Headers.Location.ToString()); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Pipeline/FederatedSignoutTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Pipeline/FederatedSignoutTests.cs index bb9eebe07..527a6709a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Pipeline/FederatedSignoutTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Pipeline/FederatedSignoutTests.cs @@ -15,6 +15,7 @@ using Open.IdentityServer.Test; using Microsoft.AspNetCore.Authentication; using Xunit; +using Open.IdentityServer.Extensions; namespace IdentityServer.IntegrationTests.Pipeline; @@ -174,7 +175,7 @@ public async Task non_200_should_not_render_page_with_iframe() _pipeline.OnFederatedSignout = async ctx => { await ctx.SignOutAsync(); // even if we signout, we should not see iframes - ctx.Response.Redirect("http://foo"); + ctx.Response.RedirectToAbsoluteUrl("http://foo"); return true; }; @@ -193,7 +194,7 @@ await _pipeline.RequestAuthorizationEndpointAsync( IdentityServerPipeline.FederatedSignOutUrl + "?sid=123", TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.StatusCode.Should().Be(HttpStatusCode.SeeOther); response.Content.Headers.ContentType.Should().BeNull(); var html = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); html.Should().Be(String.Empty); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/AuthorizeResultTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/AuthorizeResultTests.cs index 7aee5a7e7..5d3b62744 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/AuthorizeResultTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/AuthorizeResultTests.cs @@ -65,13 +65,38 @@ public async Task Error_ShouldRedirectToErrorPage_AndPassInfo() await _subject.ExecuteAsync(_context); _mockErrorMessageStore.Messages.Count.Should().Be(1); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = _context.Response.Headers["Location"].First(); location.Should().StartWith("https://server/error"); var query = QueryHelpers.ParseQuery(new Uri(location).Query); query["errorId"].First().Should().Be(_mockErrorMessageStore.Messages.First().Key); } + [Theory] + [InlineData(OidcConstants.AuthorizeErrors.AccessDenied)] + [InlineData(OidcConstants.AuthorizeErrors.AccountSelectionRequired)] + [InlineData(OidcConstants.AuthorizeErrors.LoginRequired)] + [InlineData(OidcConstants.AuthorizeErrors.ConsentRequired)] + [InlineData(OidcConstants.AuthorizeErrors.InteractionRequired)] + public async Task SafeErrors_ShouldReturnToClient(string error) + { + _response.Error = error; + _response.Request = new ValidatedAuthorizeRequest + { + ResponseMode = OidcConstants.ResponseModes.Query, + RedirectUri = "http://client/callback" + }; + + await _subject.ExecuteAsync(_context); + + _mockUserSession.Clients.Count.Should().Be(0); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); + + var location = _context.Response.Headers["Location"].First(); + location.Should().StartWith("http://client/callback"); + location.Should().Contain("#_"); + } + [Theory] [InlineData(OidcConstants.AuthorizeErrors.AccountSelectionRequired)] [InlineData(OidcConstants.AuthorizeErrors.LoginRequired)] @@ -90,7 +115,7 @@ public async Task PromptNoneErrors_ShouldReturnToClient(string error) await _subject.ExecuteAsync(_context); _mockUserSession.Clients.Count.Should().Be(0); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = _context.Response.Headers["Location"].First(); location.Should().StartWith("http://client/callback"); } @@ -114,7 +139,7 @@ public async Task PromptNoneErrorsForAnonymousUsers_ShouldIncludeSessionState(st await _subject.ExecuteAsync(_context); _mockUserSession.Clients.Count.Should().Be(0); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = _context.Response.Headers["Location"].First(); var query = QueryHelpers.ParseQuery(new Uri(location).Query); query["session_state"].First().Should().Be(_response.SessionState); @@ -137,7 +162,7 @@ public async Task AccessDenied_WithIssuerInResponseDisabled_ShouldReturnToClient await _subject.ExecuteAsync(_context); _mockUserSession.Clients.Count.Should().Be(0); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = _context.Response.Headers["Location"].First(); location.Should().StartWith("http://client/callback"); @@ -166,7 +191,7 @@ public async Task AccessDenied_WithIssuerInResponseEnabled_ShouldReturnToClient_ await _subject.ExecuteAsync(_context); _mockUserSession.Clients.Count.Should().Be(0); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = _context.Response.Headers["Location"].First(); location.Should().StartWith("http://client/callback"); @@ -207,7 +232,7 @@ public async Task QueryMode_WithIssuerInResponseDisabled_ShouldPassResultsInQuer await _subject.ExecuteAsync(_context); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-store"); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-cache"); _context.Response.Headers["Cache-Control"].First().Should().Contain("max-age=0"); @@ -232,7 +257,7 @@ public async Task QueryMode_WithIssuerInResponseEnabled_ShouldPassResultsInQuery await _subject.ExecuteAsync(_context); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-store"); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-cache"); _context.Response.Headers["Cache-Control"].First().Should().Contain("max-age=0"); @@ -257,7 +282,7 @@ public async Task FragmentMode_WithIssuerInResponseDisabled_ShouldPassResultsInF await _subject.ExecuteAsync(_context); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-store"); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-cache"); _context.Response.Headers["Cache-Control"].First().Should().Contain("max-age=0"); @@ -282,7 +307,7 @@ public async Task FragmentMode_WithIssuerInResponseEnabled_ShouldPassResultsInFr await _subject.ExecuteAsync(_context); - _context.Response.StatusCode.Should().Be(302); + _context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-store"); _context.Response.Headers["Cache-Control"].First().Should().Contain("no-cache"); _context.Response.Headers["Cache-Control"].First().Should().Contain("max-age=0"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/ReturnUrlResultTestBase.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/ReturnUrlResultTestBase.cs index 4bd8be823..635aec9ee 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/ReturnUrlResultTestBase.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Endpoints/Results/ReturnUrlResultTestBase.cs @@ -83,7 +83,7 @@ public async Task ExecuteAsync_ShouldRedirectWithReturnUrlContainingQueryStringP await sut.ExecuteAsync(Context); - Context.Response.StatusCode.Should().Be(302); + Context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var urlDecoded = DecodeLocation(); urlDecoded.Should().Contain(ExpectedRedirectUrlPath); urlDecoded.Should().Contain($"{ExpectedReturnUrlParameterName}="); @@ -189,7 +189,7 @@ public async Task ExecuteAsync_WithEmptyRawParameters_ShouldStillRedirect() await sut.ExecuteAsync(Context); - Context.Response.StatusCode.Should().Be(302); + Context.Response.StatusCode.Should().Be(StatusCodes.Status303SeeOther); var location = Context.Response.Headers["Location"].ToString(); location.Should().Contain(ExpectedRedirectUrlPath); } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs index 3f84c6044..beb743ca8 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DistributedDeviceFlowThrottlingServiceTests.cs @@ -2,18 +2,19 @@ // 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 AwesomeAssertions; +using Microsoft.Extensions.Caching.Distributed; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Common; using System; using System.Collections.Generic; using System.Text; using System.Threading; using System.Threading.Tasks; -using AwesomeAssertions; -using Open.IdentityServer.UnitTests.Common; -using Open.IdentityServer.Configuration; -using Open.IdentityServer.Models; -using Open.IdentityServer.Services; -using Microsoft.Extensions.Caching.Distributed; -using Moq; using Xunit; namespace Open.IdentityServer.UnitTests.Services.Default; @@ -34,7 +35,20 @@ public class DistributedDeviceFlowThrottlingServiceTests private Mock _telemetry = new(); private Mock _trace = new(); - + private Mock _clientStore; + private Client _client; + + public DistributedDeviceFlowThrottlingServiceTests() + { + _clientStore = new Mock(); + _client = new Client + { + ClientId = "client" + }; + _clientStore.Setup(x => x.FindClientByIdAsync(It.IsAny())) + .ReturnsAsync(_client); + } + [Fact] public async Task First_Poll() { @@ -52,6 +66,7 @@ private DistributedDeviceFlowThrottlingService CreateSubject() { return new DistributedDeviceFlowThrottlingService( cache, + _clientStore.Object, new StubClock {UtcNowFunc = () => testDate}, options, _telemetry.Object); @@ -76,10 +91,11 @@ public async Task ShouldSlowDown_ShouldInitiateTelemetryTrace() } [Fact] - public async Task Second_Poll_Too_Fast() + public async Task Second_Poll_Too_Fast_For_Options() { var handle = Guid.NewGuid().ToString(); var service = CreateSubject(); + _client.PollingInterval = null; await cache.SetAsync( CacheKey + handle, @@ -94,11 +110,31 @@ await cache.SetAsync( } [Fact] - public async Task Second_Poll_After_Interval() + public async Task Second_Poll_Too_Fast_For_Client() + { + var handle = Guid.NewGuid().ToString(); + var service = CreateSubject(); + _client.PollingInterval = 4; + + await cache.SetAsync( + CacheKey + handle, + Encoding.UTF8.GetBytes(testDate.AddSeconds(-2).ToString("O")), + TestContext.Current.CancellationToken); + + var result = await service.ShouldSlowDown(handle, deviceCode); + + result.Should().BeTrue(); + + CheckCacheEntry(handle); + } + + [Fact] + public async Task Second_Poll_After_Options_Interval() { var handle = Guid.NewGuid().ToString(); var service = CreateSubject(); + _client.PollingInterval = null; await cache.SetAsync( $"devicecode_{handle}", @@ -112,6 +148,26 @@ await cache.SetAsync( CheckCacheEntry(handle); } + [Fact] + public async Task Second_Poll_After_Client_Interval() + { + var handle = Guid.NewGuid().ToString(); + + var service = CreateSubject(); + _client.PollingInterval = 4; + + await cache.SetAsync( + $"devicecode_{handle}", + Encoding.UTF8.GetBytes(testDate.AddSeconds(-deviceCode.Lifetime - 2).ToString("O")), + TestContext.Current.CancellationToken); + + var result = await service.ShouldSlowDown(handle, deviceCode); + + result.Should().BeFalse(); + + CheckCacheEntry(handle); + } + /// /// Addresses race condition from #3860 ///