diff --git a/AspireForIdentityServer.AppHost/Program.cs b/AspireForIdentityServer.AppHost/Program.cs index 079f486..2662e11 100644 --- a/AspireForIdentityServer.AppHost/Program.cs +++ b/AspireForIdentityServer.AppHost/Program.cs @@ -16,6 +16,7 @@ _ = builder.AddProject("clientapp") .WithExternalHttpEndpoints() + .WithReference(redis, connectionName: "Redis") .WithEnvironment("IdentityProvider__Authority", identityServer.GetEndpoint("https")) .WithEnvironment("IdentityProvider__ClientId", "mvc.par") .WithEnvironment("IdentityProvider__ClientSecret", "secret"); diff --git a/AspireForIdentityServer.IdentityServer/ApiControllers/SampleApiController.cs b/AspireForIdentityServer.IdentityServer/ApiControllers/SampleApiController.cs index 6953a5e..2bf9dd1 100644 --- a/AspireForIdentityServer.IdentityServer/ApiControllers/SampleApiController.cs +++ b/AspireForIdentityServer.IdentityServer/ApiControllers/SampleApiController.cs @@ -11,10 +11,10 @@ namespace IdentityServer.ApiControllers; [ApiVersion(version: 1)] [Authorize(LocalApi.PolicyName)] [ApiController, Route(template: "api/v{v:apiVersion}/samples")] -public class SampleApiController(IDistributedCache distributedCache, ILogger logger, CancellationToken cancellationToken) +public class SampleApiController(IDistributedCache distributedCache, ILogger logger) : ApiControllerBase { - private readonly CancellationToken _cancellationToken = cancellationToken; + private readonly CancellationToken _cancellationToken = CancellationToken.None; private readonly IDistributedCache _distributedCache = distributedCache; private readonly ILogger _logger = logger; diff --git a/AspireForIdentityServer.ParClient/Client.csproj b/AspireForIdentityServer.ParClient/Client.csproj index 0ea551e..054813f 100644 --- a/AspireForIdentityServer.ParClient/Client.csproj +++ b/AspireForIdentityServer.ParClient/Client.csproj @@ -11,9 +11,13 @@ + + + + diff --git a/AspireForIdentityServer.ParClient/Common/UserClaimAction.cs b/AspireForIdentityServer.ParClient/Common/UserClaimAction.cs new file mode 100644 index 0000000..7c6c2f8 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Common/UserClaimAction.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using System.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; + +namespace Client.Common; + +public class UserClaimAction(string claimName) : ClaimAction(claimName, ClaimValueTypes.String) +{ + private readonly string _claimName = claimName; + + public override void Run(JsonElement userData, ClaimsIdentity identity, string issuer) + { + if (userData.TryGetProperty(_claimName, out var tokens)) + { + var values = new List(); + + if (tokens.ValueKind == JsonValueKind.String) + { + values.Add(tokens.ToString()); + } + else + { + foreach (var token in tokens.EnumerateArray()) + { + values.Add(token.ToString()); + } + } + + foreach (var v in values) + { + Claim claim = new(_claimName, v, ValueType, issuer); + if (!identity.HasClaim(c => c.Type == identity.RoleClaimType && c.Value == claim.Value)) + { + identity.AddClaim(claim); + } + } + } + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Constants.cs b/AspireForIdentityServer.ParClient/Constants.cs index b5d039b..2c2fdca 100644 --- a/AspireForIdentityServer.ParClient/Constants.cs +++ b/AspireForIdentityServer.ParClient/Constants.cs @@ -3,4 +3,6 @@ public class ConfigurationSections { public const string IdentityProvider = "IdentityProvider"; + public const string PollyResilience = "PollyResilience"; + public const string ConnectionStrings = "ConnectionStrings"; } \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Controllers/HomeController.cs b/AspireForIdentityServer.ParClient/Controllers/HomeController.cs index a59f19a..ea108d7 100644 --- a/AspireForIdentityServer.ParClient/Controllers/HomeController.cs +++ b/AspireForIdentityServer.ParClient/Controllers/HomeController.cs @@ -1,16 +1,64 @@ +using Client.Services; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Threading.Tasks; namespace Client.Controllers; -public class HomeController() : Controller +public class HomeController( + ILogger logger, + RedisUserSessionStore redisUserSessionStore, + IdentityServerSamplesApiService IdentityServerSamplesApiService +) : Controller { + private readonly ILogger _logger = logger; + + private readonly IdentityServerSamplesApiService _identityServerSamplesApiService = IdentityServerSamplesApiService; + private readonly RedisUserSessionStore _redisUserSessionStore = redisUserSessionStore; + [AllowAnonymous] public IActionResult Index() => View(); - public IActionResult Secure() => View(); + public async Task Secure() + { + #region Example of using the RedisUserSessionStore + + var hasBeenSecured = _redisUserSessionStore.GetString(User.FindFirst("sub").Value, "secure_page"); + + if (string.IsNullOrWhiteSpace(hasBeenSecured)) + { + _redisUserSessionStore.SetString(User.FindFirst("sub").Value, "secure_page", "visited"); + } + + var sessionKeys = _redisUserSessionStore.GetUserStorageKeys(User.FindFirst("sub").Value); + var sessionItems = new List>(); + + foreach (var key in sessionKeys) + { + var value = _redisUserSessionStore.GetString(User.FindFirst("sub").Value, key); + sessionItems.Add(new KeyValuePair(key, value)); + } + + ViewBag.SessionKeyValues = sessionItems; + + #endregion + + #region Example of using the IdentityServerSamplesApiService + + // Call the IdentityServerSamplesApiService to get sample data + // The endpoint has caching enabled, so the first call will generate new data and cache it. + var sampleData = await _identityServerSamplesApiService.GetSampleData(); + ViewBag.SampleData = sampleData; + + #endregion + + + return View(); + } [AllowAnonymous] public IActionResult Logout() diff --git a/AspireForIdentityServer.ParClient/Dtos/SampleDto.cs b/AspireForIdentityServer.ParClient/Dtos/SampleDto.cs new file mode 100644 index 0000000..b248471 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Dtos/SampleDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Client.Dtos; + +public class SampleDto +{ + public int Id { get; set; } + public string Name { get; set; } + public DateTime GeneratedDate { get; set; } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Extensions/ClaimActionsExtensions.cs b/AspireForIdentityServer.ParClient/Extensions/ClaimActionsExtensions.cs new file mode 100644 index 0000000..2f81a6f --- /dev/null +++ b/AspireForIdentityServer.ParClient/Extensions/ClaimActionsExtensions.cs @@ -0,0 +1,47 @@ +using Client.Common; +using IdentityModel; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using System.Collections.Generic; + +namespace Client.Extensions; + +public static class ClaimActionsExtensions +{ + /// + /// Applies custom claim actions to the claim action collection. + /// + /// The claim action collection to apply the custom claim actions to. + public static void ApplyCustomClaimsActions(this ClaimActionCollection claimActions) + { + claimActions.Clear(); + claimActions.RemoveUnwantedClaimActions(); + claimActions.AddCustomClaimActions(); + } + + private static void AddCustomClaimActions(this ClaimActionCollection claimActions) + { + List addClaimActions = [ + JwtClaimTypes.Name, + JwtClaimTypes.Email, + JwtClaimTypes.EmailVerified, + JwtClaimTypes.GivenName, + JwtClaimTypes.MiddleName, + JwtClaimTypes.FamilyName, + JwtClaimTypes.Role + ]; + + addClaimActions.ForEach(claimAction => claimActions.Add(action: new UserClaimAction(claimAction))); + } + + private static void RemoveUnwantedClaimActions(this ClaimActionCollection claimActions) + { + List removeClaimActions = [ + JwtClaimTypes.IdentityProvider, + JwtClaimTypes.Nonce, + JwtClaimTypes.AccessTokenHash + ]; + + removeClaimActions.ForEach(claimAction => claimActions.DeleteClaim(claimAction)); + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Extensions/HostingExtensions.cs b/AspireForIdentityServer.ParClient/Extensions/HostingExtensions.cs new file mode 100644 index 0000000..421484f --- /dev/null +++ b/AspireForIdentityServer.ParClient/Extensions/HostingExtensions.cs @@ -0,0 +1,53 @@ +using Client.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Client.Extensions; + +public static class HostingExtensions +{ + public static WebApplication ConfigureServices(this WebApplicationBuilder builder) + { + // Add custom services + builder.AddAndConfigurePushedAuthorizationSupport(); + builder.AddAndConfigureRemoteApi(); + builder.AddAndConfigureSessionWithRedis(); + builder.AddAndConfigureAuthorization(); + + // Add DI Services + builder.Services.AddScoped(); + builder.Services.AddTransient(); + builder.Services.AddSingleton(); + + // Add MVC + builder.Services.AddControllersWithViews(); + + return builder.Build(); + } + + public static WebApplication ConfigurePipeline(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseHttpsRedirection(); + app.UseStaticFiles(); + + app.UseRouting(); + + app.UseSession(); + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapDefaultControllerRoute() + .RequireAuthorization(); + + app.MapBffManagementBackchannelEndpoint(); + + return app; + } +} diff --git a/AspireForIdentityServer.ParClient/Extensions/OptionsExtensions.cs b/AspireForIdentityServer.ParClient/Extensions/OptionsExtensions.cs new file mode 100644 index 0000000..45b04a2 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Extensions/OptionsExtensions.cs @@ -0,0 +1,27 @@ +using Client.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using System; + +namespace Client.Extensions; + +public static class OptionsExtensions +{ + /// + /// Retrieves a custom configuration section from an application's configuration and binds it to a strongly typed object. + /// + /// The type of the custom options class to bind to, which must implement the interface and have a parameterless constructor. + /// The instance used to build the web application. + /// The name of the configuration section in the application's configuration files (e.g., appsettings.json) that contains the settings to be bound to the custom options class. + /// An instance of the specified custom options class , with its properties populated from the specified configuration section. + /// + /// This method creates a new instance of the custom options class and uses the available in to bind the configuration values from the specified section to the properties of the class. + /// + public static T GetCustomOptionsConfiguration(this WebApplicationBuilder builder, string configurationSectionName) where T : class, ICustomOptions, new() + { + var optionsClass = Activator.CreateInstance(); + builder.Configuration.GetSection(configurationSectionName).Bind(optionsClass); + + return optionsClass; + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Extensions/WebApplicationBuilderExtensions.cs b/AspireForIdentityServer.ParClient/Extensions/WebApplicationBuilderExtensions.cs new file mode 100644 index 0000000..ec9e2c7 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Extensions/WebApplicationBuilderExtensions.cs @@ -0,0 +1,149 @@ +using Client.Options; +using Client.Services; +using IdentityModel; +using IdentityModel.Client; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Polly; +using StackExchange.Redis; +using System; + +namespace Client.Extensions; + +public static class WebApplicationBuilderExtensions +{ + public static void AddAndConfigurePushedAuthorizationSupport(this WebApplicationBuilder builder) + { + var identityProviderOptions = builder.GetCustomOptionsConfiguration(ConfigurationSections.IdentityProvider); + + // Register the IdentityProviderOptions for DI + builder.Services.Configure(builder.Configuration.GetSection(ConfigurationSections.IdentityProvider)); + + // Setup the rest of the client. + builder.Services.AddTransient(); + builder.Services.AddSingleton(_ => new DiscoveryCache(identityProviderOptions.Authority)); + + // Add PAR interaction httpClient + builder.Services.AddHttpClient(name: "par_interaction_client", options => { + options.BaseAddress = new Uri(uriString: identityProviderOptions.Authority); + }); + } + + public static void AddAndConfigureRemoteApi(this WebApplicationBuilder builder) + { + var identityProviderOptions = builder.GetCustomOptionsConfiguration(ConfigurationSections.IdentityProvider); + var resilienceOptions = builder.GetCustomOptionsConfiguration(ConfigurationSections.PollyResilience); + + // add automatic token management + builder.Services.AddOpenIdConnectAccessTokenManagement(); + + // Add IdentityServerApi httpClient + builder.Services.AddHttpClient(name: nameof(IdentityServerApiServiceBase), options => + { + options.BaseAddress = new Uri(uriString: identityProviderOptions.Authority + "/api/v1/"); + }) + .AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(retryCount: resilienceOptions.AllowedRetryCountBeforeFailure, retryAttempt => TimeSpan.FromMilliseconds(100 * Math.Pow(x: 2, retryAttempt)))) + .AddTransientHttpErrorPolicy(policy => policy.CircuitBreakerAsync(resilienceOptions.AllowedEventsBeforeCircuitBreaker, TimeSpan.FromSeconds(value: resilienceOptions.DurationOfBreakSeconds))) + .AddUserAccessTokenHandler(); + + } + + public static void AddAndConfigureSessionWithRedis(this WebApplicationBuilder builder) + { + var connectionStrings = builder.GetCustomOptionsConfiguration(ConfigurationSections.ConnectionStrings); + var identityProviderOptions = builder.GetCustomOptionsConfiguration(ConfigurationSections.IdentityProvider); + + // Add session + builder.Services.AddDistributedMemoryCache(); + builder.Services.AddSession(options => + { + options.IdleTimeout = TimeSpan.FromMinutes(30); + options.Cookie.Name = $"{identityProviderOptions.ClientId}_session"; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + }); + + builder.Services.AddSingleton(provider => + ConnectionMultiplexer.Connect(connectionStrings.Redis) + ); + + builder.Services.AddStackExchangeRedisCache(o => { + o.Configuration = connectionStrings.Redis; + }); + + builder.Services.AddBff() + .AddServerSideSessions(); + + // Register the RedisUserSessionStore + builder.Services.AddSingleton(); + } + + public static void AddAndConfigureAuthorization(this WebApplicationBuilder builder) + { + var identityProviderOptions = builder.GetCustomOptionsConfiguration(ConfigurationSections.IdentityProvider); + + // add cookie-based session management with OpenID Connect authentication + builder.Services.AddAuthentication(options => + { + options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; + }) + .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => + { + options.Cookie.Name = $"{identityProviderOptions.ClientId}_app"; + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + + options.AccessDeniedPath = "/Error/AccessDenied"; + + options.Events.OnSigningOut = async e => + { + // automatically revoke refresh token at signout time + await e.HttpContext.RevokeRefreshTokenAsync(); + }; + + }) + .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => + { + // Needed to add PAR support + options.EventsType = typeof(ParOidcEvents); + + // Setup Client + options.Authority = identityProviderOptions.Authority; + options.ClientId = identityProviderOptions.ClientId; + options.ClientSecret = identityProviderOptions.ClientSecret; + + // code flow + PKCE (PKCE is turned on by default and required by the identity provider in this sample) + options.ResponseType = OpenIdConnectResponseType.Code; + options.UsePkce = true; + + options.Scope.Clear(); + options.Scope.Add(OpenIdConnectScope.OpenId); + options.Scope.Add(OpenIdConnectScope.OfflineAccess); + options.Scope.Add("profile"); + options.Scope.Add("IdentityServerApi"); + + options.ClaimActions.ApplyCustomClaimsActions(); + + options.GetClaimsFromUserInfoEndpoint = true; + options.SaveTokens = true; + options.MapInboundClaims = false; + options.DisableTelemetry = false; + + options.TokenValidationParameters = new TokenValidationParameters + { + NameClaimType = JwtClaimTypes.Name, + RoleClaimType = JwtClaimTypes.Role + }; + + }); + } +} diff --git a/AspireForIdentityServer.ParClient/Options/ConnectionStringsOptions.cs b/AspireForIdentityServer.ParClient/Options/ConnectionStringsOptions.cs new file mode 100644 index 0000000..e58f5e9 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Options/ConnectionStringsOptions.cs @@ -0,0 +1,6 @@ +namespace Client.Options; + +public class ConnectionStringsOptions : ICustomOptions +{ + public string Redis { get; set; } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Options/ICustomOptions.cs b/AspireForIdentityServer.ParClient/Options/ICustomOptions.cs new file mode 100644 index 0000000..07e9c25 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Options/ICustomOptions.cs @@ -0,0 +1,3 @@ +namespace Client.Options; + +public interface ICustomOptions; \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Options/IdentityProviderOptions.cs b/AspireForIdentityServer.ParClient/Options/IdentityProviderOptions.cs index c2fd0c8..6705dcb 100644 --- a/AspireForIdentityServer.ParClient/Options/IdentityProviderOptions.cs +++ b/AspireForIdentityServer.ParClient/Options/IdentityProviderOptions.cs @@ -2,9 +2,9 @@ namespace Client.Options; -public class IdentityProviderOptions +public class IdentityProviderOptions : ICustomOptions { public string Authority { get; set; } = String.Empty; public string ClientId { get; set; } = String.Empty; public string ClientSecret { get; set; } = String.Empty; -} +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Options/PollyResilienceOptions.cs b/AspireForIdentityServer.ParClient/Options/PollyResilienceOptions.cs new file mode 100644 index 0000000..c758e48 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Options/PollyResilienceOptions.cs @@ -0,0 +1,8 @@ +namespace Client.Options; + +public class PollyResilienceOptions : ICustomOptions +{ + public int AllowedEventsBeforeCircuitBreaker { get; set; } + public int DurationOfBreakSeconds { get; set; } + public int AllowedRetryCountBeforeFailure { get; set; } +} diff --git a/AspireForIdentityServer.ParClient/ParOidcEvents.cs b/AspireForIdentityServer.ParClient/ParOidcEvents.cs index b771823..1b32cdb 100644 --- a/AspireForIdentityServer.ParClient/ParOidcEvents.cs +++ b/AspireForIdentityServer.ParClient/ParOidcEvents.cs @@ -131,7 +131,7 @@ private async Task PushAuthorizationParameters(RedirectContext cont // Send our PAR request var requestBody = new FormUrlEncodedContent(context.ProtocolMessage.Parameters); - // Load the clientSecret (password) from appSettings + // Load the clientSecret from configuration using Options API _httpClient.SetBasicAuthentication(clientId, password: _idpOptions.ClientSecret); var disco = await _discoveryCache.GetAsync(); diff --git a/AspireForIdentityServer.ParClient/Program.cs b/AspireForIdentityServer.ParClient/Program.cs index b85aa25..659b922 100644 --- a/AspireForIdentityServer.ParClient/Program.cs +++ b/AspireForIdentityServer.ParClient/Program.cs @@ -1,46 +1,40 @@ -using Microsoft.AspNetCore.Hosting; +using Client.Extensions; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; using Serilog; -using Serilog.Events; using System; -namespace Client; +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .CreateLogger(); -public class Program +Log.Information("Starting application host..."); + +try { - public static int Main(string[] args) - { - Log.Logger = new LoggerConfiguration() - .MinimumLevel.Warning() - .MinimumLevel.Override("IdentityModel", LogEventLevel.Debug) - .MinimumLevel.Override("System.Net.Http", LogEventLevel.Information) - .MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Information) - .Enrich.FromLogContext() - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}") - .CreateLogger(); + var builder = WebApplication + .CreateBuilder(args); + + builder + .Host.UseSerilog((ctx, lc) => lc + .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}") + .Enrich.FromLogContext() + .ReadFrom.Configuration(ctx.Configuration)); - try - { - Log.Information("Starting client host..."); - CreateHostBuilder(args).Build().Run(); - return 0; - } - catch (Exception ex) - { - Log.Fatal(ex, "Host terminated unexpectedly."); - return 1; - } - finally - { - Log.CloseAndFlush(); - } - } + builder + .AddServiceDefaults(); - public static IHostBuilder CreateHostBuilder(string[] args) => - Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }) - .UseSerilog(); + builder + .ConfigureServices() + .ConfigurePipeline() + .Run(); } +catch (Exception ex) when (ex.GetType().Name is not "HostAbortedException") +{ + Log.Fatal(ex, messageTemplate: "Unhandled exception"); +} +finally +{ + Log.Information(messageTemplate: "Shut down complete"); + Log.CloseAndFlush(); +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Services/IdentityServerApiServiceBase.cs b/AspireForIdentityServer.ParClient/Services/IdentityServerApiServiceBase.cs new file mode 100644 index 0000000..da56fe9 --- /dev/null +++ b/AspireForIdentityServer.ParClient/Services/IdentityServerApiServiceBase.cs @@ -0,0 +1,54 @@ +using Duende.AccessTokenManagement.OpenIdConnect; +using IdentityModel.Client; +using Microsoft.AspNetCore.Http; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Client.Services; + +public class IdentityServerApiServiceBase( + IUserTokenManagementService userTokenManagementService, + IHttpContextAccessor contextAccessor, + IHttpClientFactory httpClientFactory +) +{ + private readonly IUserTokenManagementService _userTokenManagementServicee = userTokenManagementService; + private readonly IHttpContextAccessor _contextAccessor = contextAccessor; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(name: nameof(IdentityServerApiServiceBase)); + + private readonly JsonSerializerOptions _defaultJsonSerializerOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public async Task CallApi(HttpMethod httpMethod, string endpoint, object payload = null) + { + var response = await ExecuteApiCall(httpMethod, endpoint, payload); + string responseBody = await response.Content.ReadAsStringAsync(); + + return JsonSerializer.Deserialize(responseBody, _defaultJsonSerializerOptions) ?? default!; + } + + public async Task CallApi(HttpMethod httpMethod, string endpoint, object payload = null, HttpStatusCode expectation = HttpStatusCode.OK) + { + var response = await ExecuteApiCall(httpMethod, endpoint, payload); + return response.StatusCode == expectation; + } + + private async Task ExecuteApiCall(HttpMethod method, string requesturl, object requestContent = null) + { + var token = await _userTokenManagementServicee.GetAccessTokenAsync(_contextAccessor!.HttpContext!.User); + _httpClient.SetBearerToken(token.AccessToken ?? ""); + + var request = new HttpRequestMessage(method, requesturl); + if (requestContent != null) + { + request.Content = JsonContent.Create(requestContent); + } + + return await _httpClient.SendAsync(request); + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Services/IdentityServerSamplesApiService.cs b/AspireForIdentityServer.ParClient/Services/IdentityServerSamplesApiService.cs new file mode 100644 index 0000000..11185df --- /dev/null +++ b/AspireForIdentityServer.ParClient/Services/IdentityServerSamplesApiService.cs @@ -0,0 +1,15 @@ +using Client.Dtos; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Client.Services; + +public class IdentityServerSamplesApiService(IdentityServerApiServiceBase serviceBase) +{ + public async Task> GetSampleData() + { + string apiPath = $"samples/getsample"; + return await serviceBase.CallApi>(HttpMethod.Get, apiPath) ?? []; + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Services/RedisUserSessionStore.cs b/AspireForIdentityServer.ParClient/Services/RedisUserSessionStore.cs new file mode 100644 index 0000000..118e39b --- /dev/null +++ b/AspireForIdentityServer.ParClient/Services/RedisUserSessionStore.cs @@ -0,0 +1,128 @@ +using Duende.Bff; +using Microsoft.Extensions.Caching.Distributed; +using StackExchange.Redis; +using System.Collections.Generic; +using System.Linq; +using System; +using System.Text.Json; +using System.Threading.Tasks; +using System.Threading; + +namespace Client.Services; + +public class RedisUserSessionStore(IDistributedCache cache, IConnectionMultiplexer connectionMultiplexer) : IUserSessionStore +{ + private readonly IDistributedCache _cache = cache; + private readonly IConnectionMultiplexer _connectionMultiplexer = connectionMultiplexer; + + private readonly DistributedCacheEntryOptions _defaultCacheOptions = new() + { + SlidingExpiration = TimeSpan.FromHours(6) + }; + + private static string UserSessionCacheKey(string userSession) => $"user-session:{userSession}"; + private static string UserStorageCacheKey(string subjectId, string customKey) => $"user-storage:{subjectId}:{customKey}"; + + public void SetString(string SubjectId, string key, string value) + { + var cacheKey = UserStorageCacheKey(SubjectId, key); + _cache.SetString(cacheKey, value, _defaultCacheOptions); + } + + public string GetString(string SubjectId, string key) + { + var cacheKey = UserStorageCacheKey(SubjectId, key); + return _cache.GetString(cacheKey); + } + + public void SetObject(string SubjectId, string key, T value) + { + var cacheKey = UserStorageCacheKey(SubjectId, key); + var valueJson = JsonSerializer.Serialize(value); + _cache.SetString(cacheKey, valueJson, _defaultCacheOptions); + } + + public T GetObject(string SubjectId, string key) + { + var cacheKey = UserStorageCacheKey(SubjectId, key); + var value = _cache.GetString(cacheKey); + return value != null ? JsonSerializer.Deserialize(value) : default; + } + + public IEnumerable GetUserStorageKeys(string subjectId) + { + var server = _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints()[0]); + var allKeys = server.Keys(pattern: $"user-storage:{subjectId}:*").ToList(); + return allKeys.Select(k => k.ToString().Split(":").Last()); + } + + public async Task CreateUserSessionAsync(UserSession session, CancellationToken cancellationToken = default) + { + var sessionJson = JsonSerializer.Serialize(session); + await _cache.SetStringAsync(UserSessionCacheKey(session.Key), sessionJson, _defaultCacheOptions, cancellationToken); + } + + public async Task GetUserSessionAsync(string key, CancellationToken cancellationToken = default) + { + var sessionJson = await _cache.GetStringAsync(UserSessionCacheKey(key), cancellationToken); + return sessionJson != null ? JsonSerializer.Deserialize(sessionJson) : null; + } + + public async Task UpdateUserSessionAsync(string key, UserSessionUpdate sessionUpdate, CancellationToken cancellationToken = default) + { + var sessionJson = await _cache.GetStringAsync(UserSessionCacheKey(key), cancellationToken); + if (sessionJson is not null) + { + var session = JsonSerializer.Deserialize(sessionJson); + if (session is not null) + { + sessionUpdate.CopyTo(session); + + var updatedSessionJson = JsonSerializer.Serialize(session); + await _cache.SetStringAsync(UserSessionCacheKey(key), updatedSessionJson, cancellationToken); + } + } + } + + public async Task DeleteUserSessionAsync(string key, CancellationToken cancellationToken = default) + { + await _cache.RemoveAsync(UserSessionCacheKey(key), cancellationToken); + } + + public async Task> GetUserSessionsAsync(UserSessionsFilter filter, CancellationToken cancellationToken = default) + { + filter.Validate(); + + var server = _connectionMultiplexer.GetServer(_connectionMultiplexer.GetEndPoints()[0]); + var allKeys = (server.Keys(pattern: "user-session:*") ?? []).ToList(); + var sessions = new HashSet(); + + foreach (var item in allKeys) + { + var i = await _cache.GetStringAsync(item.ToString(), cancellationToken); + var session = i is null ? null : JsonSerializer.Deserialize(i); + + if (session is not null && + ( + (!string.IsNullOrWhiteSpace(filter.SessionId) && filter.SessionId == session.SessionId) || + (!string.IsNullOrWhiteSpace(filter.SubjectId) && filter.SubjectId == session.SubjectId) + ) + ) + { + sessions.Add(session); + } + } + return sessions; + } + + public async Task DeleteUserSessionsAsync(UserSessionsFilter filter, CancellationToken cancellationToken = default) + { + filter.Validate(); + + var keysToBeDeleted = await GetUserSessionsAsync(filter, cancellationToken); + foreach (var key in keysToBeDeleted.Select(s => s.Key).ToList()) + { + await DeleteUserSessionAsync(key, cancellationToken); + } + } +} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Startup.cs b/AspireForIdentityServer.ParClient/Startup.cs deleted file mode 100644 index 5f20132..0000000 --- a/AspireForIdentityServer.ParClient/Startup.cs +++ /dev/null @@ -1,149 +0,0 @@ -using Client.Options; -using HealthChecks.UI.Client; -using HealthChecks.Uptime; -using IdentityModel; -using IdentityModel.Client; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; -using Microsoft.IdentityModel.Tokens; -using System; - -namespace Client; - -public class Startup(IConfiguration configuration) -{ - private readonly IConfiguration _configuration = configuration; - - public void ConfigureServices(IServiceCollection services) - { - // Configure our options objects. - services.Configure(_configuration.GetSection(key: ConfigurationSections.IdentityProvider)); - - // Create a local instance of the IDP options for immediate use. - var identityProviderOptions = new IdentityProviderOptions(); - _configuration.GetSection(ConfigurationSections.IdentityProvider).Bind(identityProviderOptions); - - // Setup the rest of the client. - services.AddTransient(); - services.AddSingleton(_ => new DiscoveryCache(identityProviderOptions.Authority)); - - // add automatic token management - services.AddOpenIdConnectAccessTokenManagement(); - - // Add PAR interaction httpClient - services.AddHttpClient(name: "par_interaction_client", options => { - options.BaseAddress = new Uri(uriString: identityProviderOptions.Authority); - }); - - // Add session - services.AddDistributedMemoryCache(); - services.AddSession(options => - { - options.Cookie.Name = "mvc.par.session"; - options.IdleTimeout = TimeSpan.FromMinutes(30); - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - }); - - // add MVC - services.AddControllersWithViews(); - - // add cookie-based session management with OpenID Connect authentication - services.AddAuthentication(options => - { - options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; - }) - .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => - { - options.Cookie.Name = "mvc.par"; - options.AccessDeniedPath = "/Error/AccessDenied"; - - options.Events.OnSigningOut = async e => - { - // automatically revoke refresh token at signout time - await e.HttpContext.RevokeRefreshTokenAsync(); - }; - - }) - .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => - { - // Needed to add PAR support - options.EventsType = typeof(ParOidcEvents); - - // Setup Client - options.Authority = identityProviderOptions.Authority; - options.ClientId = identityProviderOptions.ClientId; - options.ClientSecret = identityProviderOptions.ClientSecret; - - // code flow + PKCE (PKCE is turned on by default and required by the identity provider in this sample) - options.ResponseType = OpenIdConnectResponseType.Code; - options.UsePkce = true; - - options.Scope.Clear(); - options.Scope.Add(OpenIdConnectScope.OpenId); - options.Scope.Add(OpenIdConnectScope.OfflineAccess); - options.Scope.Add("profile"); - - options.GetClaimsFromUserInfoEndpoint = true; - options.SaveTokens = true; - options.MapInboundClaims = false; - - options.TokenValidationParameters = new TokenValidationParameters - { - NameClaimType = JwtClaimTypes.Name, - RoleClaimType = JwtClaimTypes.Role - }; - - }); - - services.AddBff(options => { - options.EnableSessionCleanup = true; - options.SessionCleanupInterval = TimeSpan.FromMinutes(5); - }) - .AddServerSideSessions(); - - services.AddHealthChecks() - .AddUptimeHealthCheck() - .AddIdentityServer(idSvrUri: new Uri(uriString: identityProviderOptions.Authority)); - } - - public void Configure(IApplicationBuilder app, IHostEnvironment environment) - { - if (environment.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - app.UseHttpsRedirection(); - app.UseStaticFiles(); - - app.UseRouting(); - app.UseSession(); - app.UseAuthentication(); - app.UseAuthorization(); - - app.UseHealthChecks(path: "/_health", options: new HealthCheckOptions - { - ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse, - AllowCachingResponses = false - }); - - app.UseEndpoints(endpoints => - { - endpoints - .MapDefaultControllerRoute() - .RequireAuthorization(); - - endpoints - .MapBffManagementEndpoints(); - }); - } -} \ No newline at end of file diff --git a/AspireForIdentityServer.ParClient/Views/Home/Secure.cshtml b/AspireForIdentityServer.ParClient/Views/Home/Secure.cshtml index 334caa7..2b5030d 100644 --- a/AspireForIdentityServer.ParClient/Views/Home/Secure.cshtml +++ b/AspireForIdentityServer.ParClient/Views/Home/Secure.cshtml @@ -1,16 +1,25 @@ @using Microsoft.AspNetCore.Authentication +@using Client.Dtos @{ ViewData["Title"] = "Secure"; + var sessionKeyValues = (List>)ViewBag.SessionKeyValues; + var sampleData = (List)ViewBag.SampleData; }
@@ -36,4 +45,26 @@ }
+
+ + @foreach (var prop in sessionKeyValues) + { +
+
@prop.Key
+
@prop.Value
+
+ } + +
+
+ + @foreach (var entry in sampleData) + { +
+
@entry.Id
+
@entry.Name (@entry.GeneratedDate)
+
+ } + +
diff --git a/AspireForIdentityServer.ParClient/appsettings.json b/AspireForIdentityServer.ParClient/appsettings.json index ed7f75c..68c4329 100644 --- a/AspireForIdentityServer.ParClient/appsettings.json +++ b/AspireForIdentityServer.ParClient/appsettings.json @@ -1,7 +1,15 @@ { + "ConnectionStrings": { + "Redis": "" + }, "IdentityProvider": { "Authority": "", "ClientId": "", "ClientSecret": "" + }, + "PollyResilience": { + "AllowedEventsBeforeCircuitBreaker": 3, + "DurationOfBreakSeconds": 5, + "AllowedRetryCountBeforeFailure": 2 } } \ No newline at end of file