From fdb59ab975f4be1308de692f8d9f7766a05d3fb1 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 18 May 2026 21:26:49 +0200 Subject: [PATCH 01/69] =?UTF-8?q?init:=202.0.0=E2=80=91alpha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 860cb73b..3ffdc493 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -14,7 +14,7 @@ true True Core library of the WebExpress web server. - 0.0.11-alpha + 2.0.0-alpha https://github.com/webexpress-framework/WebExpress icon.png README.md From d46d7f63d01554f2609a956a3069cecda1985935 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Wed, 20 May 2026 21:48:45 +0200 Subject: [PATCH 02/69] add: MaxRequestHeadersTotalSize to webexpress.config.xml limit section --- src/WebExpress.WebCore/Config/LimitConfig.cs | 7 +++++++ src/WebExpress.WebCore/HttpServer.cs | 3 +++ 2 files changed, 10 insertions(+) diff --git a/src/WebExpress.WebCore/Config/LimitConfig.cs b/src/WebExpress.WebCore/Config/LimitConfig.cs index 284ae852..9c3f951c 100644 --- a/src/WebExpress.WebCore/Config/LimitConfig.cs +++ b/src/WebExpress.WebCore/Config/LimitConfig.cs @@ -20,6 +20,13 @@ public sealed class LimitConfig [XmlElement("uploadlimit", DataType = "long")] public long UploadLimit { get; set; } + /// + /// The maximum allowed size of the combined request headers, in bytes. + /// A value of 0 means the Kestrel default (32 KiB) is used. + /// + [XmlElement("maxrequestheaderstotalsize", DataType = "int")] + public int MaxRequestHeadersTotalSize { get; set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 7bc746d2..3882ca35 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -167,6 +167,9 @@ public void Start() serverOptions.Value.Limits.MaxRequestBodySize = Config?.Limit?.UploadLimit > 0 ? Config?.Limit?.UploadLimit : serverOptions.Value.Limits.MaxRequestBodySize; + serverOptions.Value.Limits.MaxRequestHeadersTotalSize = Config?.Limit?.MaxRequestHeadersTotalSize > 0 + ? Config.Limit.MaxRequestHeadersTotalSize + : serverOptions.Value.Limits.MaxRequestHeadersTotalSize; foreach (var endpoint in Config.Endpoints) { From f78296af5aa060e60d42ba577abf26179a8ce398 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 22 May 2026 18:26:42 +0200 Subject: [PATCH 03/69] feat: general improvements and minor bugs --- src/WebExpress.WebCore/HttpServer.cs | 29 ++++++++++++------- .../WebEndpoint/EndpointManager.cs | 14 ++++----- .../WebEvent/Model/EventItem.cs | 10 +++++-- .../WebResource/ResourceFile.cs | 21 +++++++++++--- 4 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 3882ca35..dc97bb32 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -93,6 +93,7 @@ public class HttpServer : IHost, IHttpApplication private static DateTime _lastCpuTime = DateTime.UtcNow; private static TimeSpan _lastProcessorTime = Process.GetCurrentProcess().TotalProcessorTime; private static readonly Process _currentProcess = Process.GetCurrentProcess(); + private static readonly Lock _cpuStatLock = new(); /// /// Initializes a new instance of the class. @@ -421,21 +422,25 @@ private static void UpdateStatistics(IResponse response, long duration) // calculate memory usage in MB var memUsage = _currentProcess.WorkingSet64 / (1024.0 * 1024.0); - // calculate cpu usage + // calculate cpu usage (read & update protected by _cpuStatLock) var currentCpuTime = _currentProcess.TotalProcessorTime; var currentWallTime = DateTime.UtcNow; - var cpuUsedMs = (currentCpuTime - _lastProcessorTime).TotalMilliseconds; - var totalMsPassed = (currentWallTime - _lastCpuTime).TotalMilliseconds; var cpuUsage = 0.0; - if (totalMsPassed > 0) + lock (_cpuStatLock) { - cpuUsage = (cpuUsedMs / (totalMsPassed * Environment.ProcessorCount)) * 100.0; - } + var cpuUsedMs = (currentCpuTime - _lastProcessorTime).TotalMilliseconds; + var totalMsPassed = (currentWallTime - _lastCpuTime).TotalMilliseconds; - // update pointers for next calculation - _lastProcessorTime = currentCpuTime; - _lastCpuTime = currentWallTime; + if (totalMsPassed > 0) + { + cpuUsage = (cpuUsedMs / (totalMsPassed * Environment.ProcessorCount)) * 100.0; + } + + // update pointers for next calculation + _lastProcessorTime = currentCpuTime; + _lastCpuTime = currentWallTime; + } lock (_statLock) { @@ -501,8 +506,10 @@ private static IResponse CreateStatusPage(string message, IRequest re var statusPageManager = WebEx.ComponentHub.StatusPageManager; var applicationManager = WebEx.ComponentHub.ApplicationManager; var route = new RouteEndpoint(request.Uri.PathSegments)?.ToString(); - var applicationContext = applicationManager.Applications - .FirstOrDefault(x => route.StartsWith(x.Route.ToString())); + var applicationContext = string.IsNullOrEmpty(route) + ? null + : applicationManager.Applications + .FirstOrDefault(x => route.StartsWith(x.Route.ToString())); if (searchResult is not null) { diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 39d7000a..5f8bab6f 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -234,17 +234,15 @@ public static IRoute CreateEndpointRoute }; }); - segmentAttributesMapping = segmentAttributesMapping.Any() && _namespacePrefixes - .Contains(segmentAttributesMapping - .First().Segment - .ToString()) + var firstMapping = segmentAttributesMapping.FirstOrDefault(); + segmentAttributesMapping = firstMapping is not null && _namespacePrefixes + .Contains(firstMapping.Segment?.ToString()) ? segmentAttributesMapping.Skip(1) : segmentAttributesMapping; - segmentAttributesMapping = segmentAttributesMapping.Any() && (namespacePrefixes ?? []) - .Contains(segmentAttributesMapping - .First().Segment - .ToString()) + firstMapping = segmentAttributesMapping.FirstOrDefault(); + segmentAttributesMapping = firstMapping is not null && (namespacePrefixes ?? []) + .Contains(firstMapping.Segment?.ToString()) ? segmentAttributesMapping.Skip(1) : segmentAttributesMapping; diff --git a/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs b/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs index d4dd672d..ea469ecb 100644 --- a/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs +++ b/src/WebExpress.WebCore/WebEvent/Model/EventItem.cs @@ -99,9 +99,15 @@ public void Process(object sender, IEventArgument eventArgument) if (handlerType is not null) { - var genericArgument = handlerType.GetGenericArguments().First(); + var genericArgument = handlerType.GetGenericArguments().FirstOrDefault(); + + if (genericArgument is null) + { + return; + } + var method = handlerType.GetMethod("Process"); - method.Invoke(_instance, [sender, eventArgument]); + method?.Invoke(_instance, [sender, eventArgument]); } } diff --git a/src/WebExpress.WebCore/WebResource/ResourceFile.cs b/src/WebExpress.WebCore/WebResource/ResourceFile.cs index 805d9321..4e84b2ef 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceFile.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceFile.cs @@ -11,7 +11,7 @@ public class ResourceFile : ResourceBinary /// /// Gets the protection in case of concurrency. /// - private object Gard { get; set; } + private object Guard { get; set; } /// /// Gets the root directory. @@ -25,7 +25,7 @@ public class ResourceFile : ResourceBinary public ResourceFile(IResourceContext resourceContext) : base(resourceContext) { - Gard = new object(); + Guard = new object(); } /// @@ -35,9 +35,22 @@ public ResourceFile(IResourceContext resourceContext) /// The response. public override IResponse Process(IRequest request) { - lock (Gard) + lock (Guard) { - var url = request.Uri.ToString()[ResourceContext.Route.ToString().Length..]; + var requestUri = request.Uri.ToString(); + var routePrefix = ResourceContext.Route.ToString(); + + if (string.IsNullOrEmpty(requestUri) || + routePrefix is null || + !requestUri.StartsWith(routePrefix) || + requestUri.Length < routePrefix.Length) + { + return new ResponseNotFound(); + } + + var url = requestUri.Length == routePrefix.Length + ? string.Empty + : requestUri[routePrefix.Length..]; var path = System.IO.Path.GetFullPath(RootDirectory + url); From 428abf14f4147ce1f491a1f0b77ebecf98ea80b1 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 1 Jun 2026 19:20:35 +0200 Subject: [PATCH 04/69] feat: general improvements and minor bugs --- README.md | 8 ++++---- docs/index.md | 4 ++-- docs/tutorials.md | 2 +- docs/user-guide.md | 6 +++--- .../WebApplication/Model/ApplicationDictionary.cs | 4 ++-- .../WebAsset/Model/AssetItemDictionary.cs | 4 ++-- src/WebExpress.WebCore/WebEvent/Model/EventDictionary.cs | 4 ++-- src/WebExpress.WebCore/WebExpress.WebCore.csproj | 4 ++-- .../WebFragment/Model/FragmentDictionary.cs | 2 +- src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs | 4 ++-- src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs | 2 +- .../WebResource/Model/ResourceDictionary.cs | 2 +- .../WebRestApi/Model/RestApiDictionary.cs | 2 +- .../WebSocket/Model/SocketDictionary.cs | 2 +- .../WebTheme/Model/ThemeItemDictionary.cs | 2 +- 15 files changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ae3401cc..6d9a8fc3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![WebExpress-Framework](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # WebExpress -`WebExpress` is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .net language (e.g. C#). Some advantages of `WebExpress` are: +`WebExpress` is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry Pi). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .NET language (e.g. C#). Some advantages of `WebExpress` are: - It is easy to use. - It offers a variety of features and tools that can help you build and manage your website. @@ -25,15 +25,15 @@ The current binaries are available for download [here](https://github.com/webexp # Start If you're looking to get started with `WebExpress`, we would recommend using the following documentation. It can help you understand the platform. -- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md) - [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) - [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) # Learning -The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you re interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. +The following tutorials illustrate the essential techniques of `WebExpress`. These tutorials are designed to assist you, as a developer, in understanding the various aspects of `WebExpress`. Each tutorial provides a detailed, step-by-step guide that you can work through using an example. If you're interested in beginning the development of `WebExpress` components, we would recommend you to complete some of these tutorials. - [HelloWorld](https://github.com/webexpress-framework/WebExpress.Tutorial.HelloWorld#readme) - [WebUI](https://github.com/webexpress-framework/WebExpress.Tutorial.WebUI#readme) diff --git a/docs/index.md b/docs/index.md index caf4880c..df85c6bb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # WebExpress -WebExpress is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry PI). By providing +WebExpress is a lightweight web server optimized for use in low-performance environments (e.g. Raspberry Pi). By providing a powerful plugin system and a comprehensive API, web applications can be easily and quickly integrated into a .NET language (e.g. C#). Some advantages of WebExpress are: diff --git a/docs/tutorials.md b/docs/tutorials.md index f7e2e418..0606f9f7 100644 --- a/docs/tutorials.md +++ b/docs/tutorials.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # Tutorials Welcome to the `WebExpress` Tutorials! Here, you'll find step-by-step guides and helpful resources to get the most out diff --git a/docs/user-guide.md b/docs/user-guide.md index 7f6360b5..fc406caa 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1,4 +1,4 @@ -![WebExpress](https://raw.githubusercontent.com/webexpress-framework/WebExpress/main/assets/banner.png) +![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png) # User guide Welcome to the `WebExpress.WebCore` User Guide. This guide will help you get started with `WebExpress.WebCore` and make the most out of its @@ -7,8 +7,8 @@ features. Follow the links below to begin your journey. # Getting started To get started with `WebExpress.WebCore`, use the following guides: -- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/installation_guide.md) -- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/doc/development_guide.md) +- [Installation Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/installation_guide.md) +- [Development Guide](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md) - [WebExpress.WebCore API Documentation](https://webexpress-framework.github.io/WebExpress.WebCore/) - [WebExpress.WebUI API Documentation](https://webexpress-framework.github.io/WebExpress.WebUI/) - [WebExpress.WebApp API Documentation](https://webexpress-framework.github.io/WebExpress.WebApp/) diff --git a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs index 7a8c4a22..c6dd0585 100644 --- a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs +++ b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs @@ -20,11 +20,11 @@ internal class ApplicationDictionary .Select(x => x.ApplicationContext); /// - /// Adds a application item to the dictionary. + /// Adds an application item to the dictionary. /// /// The plugin context. /// The application item. - /// True if the application item was added successfully, false if an element with the same status code already exists. + /// True if the application item was added successfully, false if an element with the same key already exists. public bool AddApplication(IPluginContext pluginContext, ApplicationItem applicationItem) { if (!_dict.TryGetValue(pluginContext, out var applicationDict)) diff --git a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs index dcc7774a..b78ee3d5 100644 --- a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs +++ b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs @@ -22,12 +22,12 @@ internal class AssetItemDictionary .SelectMany(x => x); /// - /// Adds a asset item to the dictionary. + /// Adds an asset item to the dictionary. /// /// The plugin context. /// The application context. /// The resource item. - /// True if the resource item was added successfully, false if an element with the same status code already exists. + /// True if the resource item was added successfully, false if an element with the same key already exists. public bool AddAssetItem(IPluginContext pluginContext, IApplicationContext applicationContext, AssetItem assetItem) { var type = assetItem.AssetClass; diff --git a/src/WebExpress.WebCore/WebEvent/Model/EventDictionary.cs b/src/WebExpress.WebCore/WebEvent/Model/EventDictionary.cs index 49c24125..9774a1ab 100644 --- a/src/WebExpress.WebCore/WebEvent/Model/EventDictionary.cs +++ b/src/WebExpress.WebCore/WebEvent/Model/EventDictionary.cs @@ -12,12 +12,12 @@ namespace WebExpress.WebCore.WebEvent.Model internal class EventDictionary : Dictionary>>> { /// - /// Adds a event item to the dictionary. + /// Adds an event item to the dictionary. /// /// The plugin context. /// The application context. /// The event item. - /// True if the event handler item was added successfully, false if an element with the same status code already exists. + /// True if the event handler item was added successfully, false if an element with the same key already exists. public bool AddEventItem(IPluginContext pluginContext, IApplicationContext applicationContext, EventItem eventItem) { var type = eventItem.EventClass; diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 3ffdc493..2fec11f6 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -3,8 +3,8 @@ Library WebExpress.WebCore - 0.0.11.0 - 0.0.11.0 + 2.0.0.0 + 2.0.0.0 net10.0 any https://github.com/webexpress-framework/WebExpress.git diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs index ee39aeb1..e8a7ae13 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentDictionary.cs @@ -32,7 +32,7 @@ internal class FragmentDictionary /// The plugin context. /// The application context. /// The fragment item. - /// True if the fragment item was added successfully, false if an element with the same status code already exists. + /// True if the fragment item was added successfully, false if an element with the same key already exists. public bool AddFragmentItem(IPluginContext pluginContext, IApplicationContext applicationContext, FragmentItem fragmentItem) { var type = fragmentItem.FragmentClass; diff --git a/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs b/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs index fb2298aa..d0e39566 100644 --- a/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs +++ b/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs @@ -15,12 +15,12 @@ namespace WebExpress.WebCore.WebJob.Model internal class ScheduleDictionary : Dictionary>>> { /// - /// Adds a event item to the dictionary. + /// Adds a schedule item to the dictionary. /// /// The plugin context. /// The application context. /// The schedule item. - /// True if the schedule item was added successfully, false if an element with the same status code already exists. + /// True if the schedule item was added successfully, false if an element with the same key already exists. public bool AddScheduleItem(IPluginContext pluginContext, IApplicationContext applicationContext, ScheduleItem scheduleItem) { var type = scheduleItem.JobClass; diff --git a/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs b/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs index d86656bb..0bbbf90a 100644 --- a/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs +++ b/src/WebExpress.WebCore/WebPage/Model/PageDictionary.cs @@ -29,7 +29,7 @@ internal class PageDictionary /// The plugin context. /// The application context. /// The page item. - /// True if the page item was added successfully, false if an element with the same status code already exists. + /// True if the page item was added successfully, false if an element with the same key already exists. public bool AddPageItem(IPluginContext pluginContext, IApplicationContext applicationContext, PageItem pageItem) { var type = pageItem.PageClass; diff --git a/src/WebExpress.WebCore/WebResource/Model/ResourceDictionary.cs b/src/WebExpress.WebCore/WebResource/Model/ResourceDictionary.cs index d0ca85d8..062c8308 100644 --- a/src/WebExpress.WebCore/WebResource/Model/ResourceDictionary.cs +++ b/src/WebExpress.WebCore/WebResource/Model/ResourceDictionary.cs @@ -18,7 +18,7 @@ internal class ResourceDictionary : DictionaryThe plugin context. /// The application context. /// The resource item. - /// True if the resource item was added successfully, false if an element with the same status code already exists. + /// True if the resource item was added successfully, false if an element with the same key already exists. public bool AddResourceItem(IPluginContext pluginContext, IApplicationContext applicationContext, ResourceItem resourceItem) { var type = resourceItem.ResourceClass; diff --git a/src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs b/src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs index f1fac46a..7c49e2ae 100644 --- a/src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs +++ b/src/WebExpress.WebCore/WebRestApi/Model/RestApiDictionary.cs @@ -18,7 +18,7 @@ internal class RestApiDictionary : DictionaryThe plugin context. /// The application context. /// The rest api item. - /// True if the rest api item was added successfully, false if an element with the same status code already exists. + /// True if the rest api item was added successfully, false if an element with the same key already exists. public bool AddRestApiItem(IPluginContext pluginContext, IApplicationContext applicationContext, RestApiItem restApiItem) { var type = restApiItem.RestApiClass; diff --git a/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs b/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs index bc1df9da..5c59a32b 100644 --- a/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs +++ b/src/WebExpress.WebCore/WebSocket/Model/SocketDictionary.cs @@ -30,7 +30,7 @@ internal class SocketDictionary /// The application context. /// The socket item. /// - /// True if the socket item was added successfully, false if an element with the same status code already exists. + /// True if the socket item was added successfully, false if an element with the same key already exists. /// public bool AddSocketItem(IPluginContext pluginContext, IApplicationContext applicationContext, SocketItem socketItem) { diff --git a/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs b/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs index 0db86a50..dfd2cd7c 100644 --- a/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs +++ b/src/WebExpress.WebCore/WebTheme/Model/ThemeItemDictionary.cs @@ -25,7 +25,7 @@ internal class ThemeItemDictionary /// The plugin context. /// The application context. /// The theme item. - /// True if the theme item was added successfully, false if an element with the same status code already exists. + /// True if the theme item was added successfully, false if an element with the same key already exists. public bool AddThemeItem(IPluginContext pluginContext, IApplicationContext applicationContext, ThemeItem themeItem) { var type = themeItem.ThemeClass; From 0df433f1a7f38ad46f5998bb3e99919d7ddae0c6 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 1 Jun 2026 22:28:36 +0200 Subject: [PATCH 05/69] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebAsset/Asset.cs | 22 ++++ .../WebAsset/AssetManager.cs | 113 +++++++++++++----- .../WebAsset/Model/AssetItemDictionary.cs | 41 +++++++ .../WebResource/ResourceAsset.cs | 31 +++++ 4 files changed, 180 insertions(+), 27 deletions(-) diff --git a/src/WebExpress.WebCore/WebAsset/Asset.cs b/src/WebExpress.WebCore/WebAsset/Asset.cs index 511b786e..5e8a3e67 100644 --- a/src/WebExpress.WebCore/WebAsset/Asset.cs +++ b/src/WebExpress.WebCore/WebAsset/Asset.cs @@ -71,11 +71,33 @@ public IResponse Process(IRequest request) response.Header.ContentType = "text/css"; break; case ".js": + case ".mjs": response.Header.ContentType = "application/javascript"; break; + case ".json": + response.Header.ContentType = "application/json"; + break; + case ".map": + response.Header.ContentType = "application/json"; + break; case ".xml": response.Header.ContentType = "text/xml"; break; + case ".woff": + response.Header.ContentType = "font/woff"; + break; + case ".woff2": + response.Header.ContentType = "font/woff2"; + break; + case ".ttf": + response.Header.ContentType = "font/ttf"; + break; + case ".eot": + response.Header.ContentType = "application/vnd.ms-fontobject"; + break; + case ".webp": + response.Header.ContentType = "image/webp"; + break; case ".html": case ".htm": response.Header.ContentType = "text/html"; diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 764bb956..68cce5b6 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAsset.Model; @@ -18,6 +19,11 @@ namespace WebExpress.WebCore.WebAsset /// public sealed class AssetManager : IAssetManager, ISystemComponent { + // synchronization root protecting _itemDictionary and related mutable state. + // the dictionary itself is not thread-safe, so every read (including the + // per-request resolution) and write must be performed under this lock. + private readonly Lock _guard = new(); + private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly AssetItemDictionary _itemDictionary = new(); @@ -35,7 +41,17 @@ public sealed class AssetManager : IAssetManager, ISystemComponent /// /// Gets all asset contexts. /// - public IEnumerable Assets => _itemDictionary.All.Select(x => x.AssetContext); + public IEnumerable Assets + { + get + { + // return a snapshot to avoid enumeration during concurrent modifications + lock (_guard) + { + return _itemDictionary.All.Select(x => x.AssetContext).ToList(); + } + } + } /// /// Initializes a new instance of the class. @@ -59,18 +75,22 @@ private AssetManager(IComponentHub componentHub, IHttpServerContext httpServerCo HandleRequest = (request, endpointContext) => { var assetContext = endpointContext as IAssetContext; - var asset = _itemDictionary.All - .FirstOrDefault - ( - x => - request.Uri - .ToString() - .ToLower() - .Replace("/", ".") - .EndsWith(x.AssetContext.Route.ToString().Replace("/", ".")) - ); - - if (asset is not null) + AssetItem asset; + + // resolve the asset under the lock so the lookup never enumerates + // the dictionary while it is being mutated by a (de)registration. + // prefer the endpoint already resolved by the sitemap; only fall + // back to a route match if it is not an asset context. + lock (_guard) + { + asset = _itemDictionary.GetByContext(assetContext) + ?? _itemDictionary.FindByRoute(request.Uri?.ToString()); + } + + // process outside the lock: Asset.Process only reads its immutable + // payload and builds a fresh response, so it must not block other + // asset requests. + if (asset?.Instance is not null) { return asset.Instance.Process(request); } @@ -98,9 +118,12 @@ private AssetManager(IComponentHub componentHub, IHttpServerContext httpServerCo /// The context of the plugin whose resources are to be associated. private void Register(IPluginContext pluginContext) { - if (_itemDictionary.ContainsPlugin(pluginContext)) + lock (_guard) { - return; + if (_itemDictionary.ContainsPlugin(pluginContext)) + { + return; + } } Register(pluginContext, _componentHub?.ApplicationManager.GetApplications(pluginContext)); @@ -114,7 +137,14 @@ private void Register(IApplicationContext applicationContext) { foreach (var pluginContext in _componentHub?.PluginManager?.GetPlugins(applicationContext)) { - if (_itemDictionary.ContainsApplication(pluginContext, applicationContext)) + bool alreadyRegistered; + + lock (_guard) + { + alreadyRegistered = _itemDictionary.ContainsApplication(pluginContext, applicationContext); + } + + if (alreadyRegistered) { continue; } @@ -130,6 +160,8 @@ private void Register(IApplicationContext applicationContext) /// The application context (optional). private void Register(IPluginContext pluginContext, IEnumerable applicationContexts) { + // assembly and reflection operations are per-plugin and read-only; only the + // mutation of _itemDictionary is synchronized. var assembly = pluginContext?.Assembly; var assemblName = assembly.GetName().Name; var embeddedResources = assembly.GetManifestResourceNames(); @@ -174,7 +206,14 @@ private void Register(IPluginContext pluginContext, IEnumerableThe context of the plugin that contains the resources to remove. internal void Remove(IPluginContext pluginContext) { - foreach (var assetContext in _itemDictionary.Remove(pluginContext)) + List removed; + + lock (_guard) + { + removed = _itemDictionary.Remove(pluginContext).ToList(); + } + + foreach (var assetContext in removed) { OnRemoveAsset(assetContext); } @@ -208,7 +254,14 @@ internal void Remove(IPluginContext pluginContext) /// The context of the application that contains the resources to remove. internal void Remove(IApplicationContext applicationContext) { - foreach (var assetContext in _itemDictionary.Remove(applicationContext)) + List removed; + + lock (_guard) + { + removed = _itemDictionary.Remove(applicationContext).ToList(); + } + + foreach (var assetContext in removed) { OnRemoveAsset(assetContext); } @@ -221,7 +274,10 @@ internal void Remove(IApplicationContext applicationContext) /// An enumeration of asset contexts. public IEnumerable GetAssets(IPluginContext pluginContext) { - return _itemDictionary.GetAssets(pluginContext); + lock (_guard) + { + return _itemDictionary.GetAssets(pluginContext).ToList(); + } } /// @@ -231,7 +287,10 @@ public IEnumerable GetAssets(IPluginContext pluginContext) /// An enumeration of asset contextes. public IEnumerable GetAssets(IApplicationContext applicationContext) { - return _itemDictionary.GetAssets(applicationContext); + lock (_guard) + { + return _itemDictionary.GetAssets(applicationContext).ToList(); + } } /// @@ -262,11 +321,11 @@ private void OnAddPlugin(object sender, IPluginContext e) Register(e); } - /// - /// Raises the event when a plugin is removed. - /// - /// The source of the event. - /// The context of the plugin being removed. + /// + /// Raises the event when a plugin is removed. + /// + /// The source of the event. + /// The context of the plugin being removed. private void OnRemovePlugin(object sender, IPluginContext e) { Remove(e); diff --git a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs index b78ee3d5..2dc36135 100644 --- a/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs +++ b/src/WebExpress.WebCore/WebAsset/Model/AssetItemDictionary.cs @@ -21,6 +21,47 @@ internal class AssetItemDictionary .SelectMany(x => x.Values) .SelectMany(x => x); + /// + /// Returns the asset item that matches the given asset context (by endpoint id). + /// Callers must synchronize access to this collection. + /// + /// The asset context resolved by the sitemap. + /// The matching asset item, or null when none matches. + public AssetItem GetByContext(IAssetContext assetContext) + { + if (assetContext is null) + { + return null; + } + + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x) + .FirstOrDefault(x => x.AssetContext?.EndpointId == assetContext.EndpointId); + } + + /// + /// Returns the asset item whose route matches the end of the given request uri. + /// This is a fallback for the rare case that the resolved endpoint context is + /// not an asset context. Callers must synchronize access to this collection. + /// + /// The request uri. + /// The matching asset item, or null when none matches. + public AssetItem FindByRoute(string requestUri) + { + if (string.IsNullOrEmpty(requestUri)) + { + return null; + } + + var normalized = requestUri.ToLower().Replace("/", "."); + + return _dictionary.Values + .SelectMany(x => x.Values) + .SelectMany(x => x) + .FirstOrDefault(x => normalized.EndsWith(x.AssetContext.Route.ToString().Replace("/", "."))); + } + /// /// Adds an asset item to the dictionary. /// diff --git a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs index e73b7ad2..bdb03d9f 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs @@ -73,9 +73,40 @@ public override IResponse Process(IRequest request) case ".css": response.Header.ContentType = "text/css"; break; + case ".js": + case ".mjs": + response.Header.ContentType = "application/javascript"; + break; + case ".json": + response.Header.ContentType = "application/json"; + break; + case ".map": + response.Header.ContentType = "application/json"; + break; case ".xml": response.Header.ContentType = "text/xml"; break; + case ".woff": + response.Header.ContentType = "font/woff"; + break; + case ".woff2": + response.Header.ContentType = "font/woff2"; + break; + case ".ttf": + response.Header.ContentType = "font/ttf"; + break; + case ".eot": + response.Header.ContentType = "application/vnd.ms-fontobject"; + break; + case ".webp": + response.Header.ContentType = "image/webp"; + break; + case ".mp3": + response.Header.ContentType = "audio/mpeg"; + break; + case ".mp4": + response.Header.ContentType = "video/mp4"; + break; case ".html": case ".htm": response.Header.ContentType = "text/html"; From c8569332e516e6416e085b5010cffd229b79adcd Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 2 Jun 2026 18:29:14 +0200 Subject: [PATCH 06/69] feat: general improvements and minor bugs --- src/WebExpress.WebCore/HttpServer.cs | 17 ++++--- .../WebComponent/ComponentHub.cs | 21 +++++---- .../WebEndpoint/EndpointManager.cs | 10 ++--- src/WebExpress.WebCore/WebHtml/HtmlElement.cs | 18 ++++---- .../WebHtml/HtmlElementMultimediaImg.cs | 4 +- .../WebHtml/HtmlElementMultimediaSvg.cs | 4 +- .../WebHtml/HtmlElementScriptingCanvas.cs | 4 +- .../WebJob/Model/ScheduleDictionary.cs | 2 +- .../WebSession/SessionManager.cs | 44 ++++++++----------- .../WebSitemap/SitemapManager.cs | 17 +++---- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 21 ++++----- 11 files changed, 77 insertions(+), 85 deletions(-) diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index dc97bb32..9f325b66 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -90,9 +90,9 @@ public class HttpServer : IHost, IHttpApplication private static readonly Lock _statLock = new(); // Variables for CPU usage calculation - private static DateTime _lastCpuTime = DateTime.UtcNow; - private static TimeSpan _lastProcessorTime = Process.GetCurrentProcess().TotalProcessorTime; private static readonly Process _currentProcess = Process.GetCurrentProcess(); + private static DateTime _lastCpuTime = DateTime.UtcNow; + private static TimeSpan _lastProcessorTime = _currentProcess.TotalProcessorTime; private static readonly Lock _cpuStatLock = new(); /// @@ -322,8 +322,8 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) if ( - !response.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && - !request.Header.Cookies.Where(x => x.Name.Equals("session")).Any() && + !response.Header.Cookies.Any(x => x.Name.Equals("session")) && + !request.Header.Cookies.Any(x => x.Name.Equals("session")) && request.Session is not null ) { @@ -640,8 +640,8 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) return; } - // no policies - if (!searchResult.EndpointContext.Policies?.Any() ?? false) + // no policies (null or empty) -> serve directly without an access check + if (!(searchResult.EndpointContext.Policies?.Any() ?? false)) { var response = HandleClient(httpContext, searchResult); await responseSender.SendAsync(httpContext, response); @@ -660,8 +660,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) return; } - // check again - if (!_componentHub.IdentityManager.CheckAccess(identity, searchResult.EndpointContext)) + // access is denied (the grant case returned above) - determine the appropriate response { // if the user is authenticated but lacks the required permissions, show the forbidden page if (identity is not null && searchResult.EndpointContext is IPageContext) @@ -723,7 +722,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) } } - // access is granted + // fallback: no specific denied-response (login prompt / forbidden) could be created { var response = HandleClient(httpContext, searchResult); await responseSender.SendAsync(httpContext, response); diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index 8c077b8f..deec77a6 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -312,7 +312,7 @@ private IComponentManager CreateInstance(Type componentType) { return null; } - else if (!componentType.GetInterfaces().Where(x => x == typeof(IComponentManager)).Any()) + else if (!componentType.GetInterfaces().Any(x => x == typeof(IComponentManager))) { _httpServerContext?.Log?.Warning ( @@ -439,8 +439,9 @@ internal void BootComponent(IPluginContext pluginContext) _applicationManager.Boot(pluginContext); foreach (var component in _dictionary.Values - .Where(x => x is IExecutableElements) - .Select(x => x as IExecutableElements)) + .SelectMany(x => x) + .Select(x => x.ComponentInstance) + .OfType()) { component.Boot(pluginContext); } @@ -493,8 +494,9 @@ internal void ShutDownComponent(IPluginContext pluginContext) _applicationManager.ShutDown(pluginContext); foreach (var component in _dictionary.Values - .Where(x => x is IExecutableElements) - .Select(x => x as IExecutableElements)) + .SelectMany(x => x) + .Select(x => x.ComponentInstance) + .OfType()) { component.ShutDown(pluginContext); } @@ -559,7 +561,10 @@ private void OnRemoveComponent(IComponentManager component) /// private void Log() { - if (_lastCounter == Managers.Count()) + // materialize the (relatively expensive) managers enumeration once + var managers = Managers.ToList(); + + if (_lastCounter == managers.Count) { return; } @@ -570,7 +575,7 @@ private void Log() _internationalizationManager.Translate("webexpress.webcore:componentmanager.component") }; - foreach (var manager in Managers) + foreach (var manager in managers) { output.Add ( @@ -580,7 +585,7 @@ private void Log() } _httpServerContext?.Log?.Info(string.Join(Environment.NewLine, output)); - _lastCounter = Managers.Count(); + _lastCounter = managers.Count; } /// diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 5f8bab6f..6ccde3bd 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -108,12 +108,12 @@ public IEnumerable GetEndpoints(Type endpointType, IApplicatio /// The response generated by the endpoint. public IResponse HandleRequest(IRequest request, IEndpointContext endpointContext) { - var registration = _registrations - .Where(x => x.Key == endpointContext?.GetType()) - .Select(x => x.Value) - .FirstOrDefault(); + if (endpointContext is null || !_registrations.TryGetValue(endpointContext.GetType(), out var registration)) + { + return null; + } - return registration?.HandleRequest(request, endpointContext); + return registration.HandleRequest(request, endpointContext); } /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs index 9a04c004..dfd452bc 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElement.cs @@ -276,11 +276,11 @@ protected void Clear(Func predicate) /// The value of the attribute. protected string GetAttribute(string name) { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + var a = _attributes.FirstOrDefault(x => x.Name == name); if (a is not null) { - return a is HtmlAttribute ? (a as HtmlAttribute).Value : string.Empty; + return a is HtmlAttribute attribute ? attribute.Value : string.Empty; } return string.Empty; @@ -293,7 +293,7 @@ protected string GetAttribute(string name) /// True if attribute exists, false otherwise. protected bool HasAttribute(string name) { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + var a = _attributes.FirstOrDefault(x => x.Name == name); return (a is not null); } @@ -305,7 +305,7 @@ protected bool HasAttribute(string name) /// The value of the attribute. protected void SetAttribute(string name, string value) { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + var a = _attributes.FirstOrDefault(x => x.Name == name); if (a is not null) { @@ -313,9 +313,9 @@ protected void SetAttribute(string name, string value) { _attributes.Remove(a); } - else if (a is HtmlAttribute) + else if (a is HtmlAttribute attribute) { - (a as HtmlAttribute).Value = value; + attribute.Value = value; } } else @@ -338,7 +338,7 @@ protected void SetAttribute(string name) return; } - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + var a = _attributes.FirstOrDefault(x => x.Name == name); if (a is null) { @@ -352,7 +352,7 @@ protected void SetAttribute(string name) /// The attribute name. protected void RemoveAttribute(string name) { - var a = _attributes.Where(x => x.Name == name).FirstOrDefault(); + var a = _attributes.FirstOrDefault(x => x.Name == name); if (a is not null) { @@ -367,7 +367,7 @@ protected void RemoveAttribute(string name) /// The element. protected HtmlElement GetElement(string name) { - var a = _elements.Where(x => x is HtmlElement && (x as HtmlElement).ElementName == name).FirstOrDefault(); + var a = _elements.FirstOrDefault(x => x is HtmlElement element && element.ElementName == name); return a as HtmlElement; } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs index 0ebe5821..d12df91d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs @@ -39,7 +39,7 @@ public string Src /// public int Width { - get => Convert.ToInt32(GetAttribute("width")); + get => int.TryParse(GetAttribute("width"), out var width) ? width : 0; set => SetAttribute("width", value.ToString()); } @@ -48,7 +48,7 @@ public int Width /// public int Height { - get => Convert.ToInt32(GetAttribute("height")); + get => int.TryParse(GetAttribute("height"), out var height) ? height : 0; set => SetAttribute("height", value.ToString()); } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaSvg.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaSvg.cs index 25445adb..5d06850f 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaSvg.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaSvg.cs @@ -18,7 +18,7 @@ public class HtmlElementMultimediaSvg : HtmlElement, IHtmlElementMultimedia /// public int Width { - get => Convert.ToInt32(GetAttribute("width")); + get => int.TryParse(GetAttribute("width"), out var width) ? width : 0; set => SetAttribute("width", value.ToString()); } @@ -27,7 +27,7 @@ public int Width /// public int Height { - get => Convert.ToInt32(GetAttribute("height")); + get => int.TryParse(GetAttribute("height"), out var height) ? height : 0; set => SetAttribute("height", value.ToString()); } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingCanvas.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingCanvas.cs index 72be82ac..52f51427 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingCanvas.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingCanvas.cs @@ -13,7 +13,7 @@ public class HtmlElementScriptingCanvas : HtmlElement, IHtmlElementScripting /// public int Width { - get => Convert.ToInt32(GetAttribute("width")); + get => int.TryParse(GetAttribute("width"), out var width) ? width : 0; set => SetAttribute("width", value.ToString()); } @@ -22,7 +22,7 @@ public int Width /// public int Height { - get => Convert.ToInt32(GetAttribute("height")); + get => int.TryParse(GetAttribute("height"), out var height) ? height : 0; set => SetAttribute("height", value.ToString()); } diff --git a/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs b/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs index d0e39566..b2afb455 100644 --- a/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs +++ b/src/WebExpress.WebCore/WebJob/Model/ScheduleDictionary.cs @@ -51,7 +51,7 @@ public bool AddScheduleItem(IPluginContext pluginContext, IApplicationContext ap var scheduleList = scheduleDict[type]; - if (scheduleList.Where(x => x.JobClass == type).Any()) + if (scheduleList.Any(x => x.JobClass == type)) { return false; // item with the same event handler already exists } diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index a11388f9..e4b96773 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -40,38 +40,26 @@ private SessionManager(IHttpServerContext context) /// The session. public Session GetSession(IRequest request) { - var session = default(Session); - // determine session var sessionCookie = request?.Header - .Cookies?.Where(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase)) - .FirstOrDefault(); + .Cookies?.FirstOrDefault(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase)); - var guid = Guid.NewGuid(); + // reuse the client-provided session id when it is a valid guid; otherwise allocate a new one + var guid = Guid.TryParse(sessionCookie?.Value, out var parsed) ? parsed : Guid.NewGuid(); - try - { - guid = Guid.Parse(sessionCookie?.Value); - } - catch + if (sessionCookie is not null && _dictionary.TryGetValue(guid, out Session value)) { + value.Updated = DateTime.Now; + return value; } - if (sessionCookie is not null && _dictionary.TryGetValue(guid, out Session value)) - { - session = value; - session.Updated = DateTime.Now; - } - else - { - // no or invalid session => assign new session - session = new Session(guid); + // no or invalid session => assign new session + var session = new Session(guid); - lock (_dictionary) - { - _dictionary[guid] = session; - } + lock (_dictionary) + { + _dictionary[guid] = session; } return session; @@ -105,13 +93,17 @@ public ISessionManager CleanUp(IApplicationContext applicationContext, int timeo var now = DateTime.Now; - // collect expired ids under lock to avoid concurrent modifications during enumeration - IEnumerable expiredIds; + // collect expired ids under lock to avoid concurrent modifications during enumeration. + // the query must be materialized (ToList) before removing - otherwise the deferred + // enumeration would mutate _dictionary.Values while iterating it (InvalidOperationException) + // and the subsequent logging loop would re-evaluate to an empty result. + List expiredIds; lock (_dictionary) { expiredIds = _dictionary.Values .Where(s => (now - s.Updated).TotalMinutes > timeoutMinutes) - .Select(s => s.Id); + .Select(s => s.Id) + .ToList(); // remove expired sessions under the same lock foreach (var id in expiredIds) diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 7e65da90..39ac0c7d 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -111,7 +111,6 @@ public void Refresh() /// The search result with the found resource or null public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) { - var variables = new Dictionary(); var result = SearchNode ( _root, @@ -120,16 +119,15 @@ public SearchResult SearchResource(Uri requestUri, SearchContext searchContext) searchContext ); - if (result is not null && result.EndpointContext is not null) + if (result?.EndpointContext is not null && + (!result.EndpointContext.Conditions.Any() || + result.EndpointContext.Conditions.All(x => x.Fulfillment(searchContext.HttpContext?.Request)))) { - if (!result.EndpointContext.Conditions.Any() || result.EndpointContext.Conditions.All(x => x.Fulfillment(searchContext.HttpContext?.Request))) - { - return result; - } + return result; } - // 404 - return result; + // 404 - not found or the endpoint's conditions are not fulfilled + return null; } /// @@ -216,7 +214,6 @@ public IEndpointContext GetEndpoint(IUri uri) return null; } - var variables = new Dictionary(); var result = SearchNode ( _root, @@ -425,8 +422,6 @@ SearchContext searchContext outPathSegments.Enqueue(node.PathSegment.Copy()); } - var type = node.EndpointContext?.GetType(); - if (nextPathSegment is null) { return new SearchResult() diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 8a8511a4..9c781624 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -141,7 +141,7 @@ public UriEndpoint(string uri) return; } - if (Enum.GetNames().Where(x => uri.StartsWith(x, StringComparison.OrdinalIgnoreCase)).Any()) + if (Enum.GetNames().Any(x => uri.StartsWith(x, StringComparison.OrdinalIgnoreCase))) { var match = UriRegex().Match(uri); @@ -291,14 +291,15 @@ public virtual IUri Concat(string segment) /// A new IUri instance representing the URI after concatenation. public virtual IUri Concat(params IUriPathSegment[] segments) { - if (segments.Length == 0) + if (segments is null || segments.Length == 0) { return this; } var copy = new UriEndpoint((IUri)this); copy.PathSegments = copy.PathSegments - .Select(x => x.Copy()); + .Select(x => x.Copy()) + .Concat(segments.Where(x => x is not null).Where(x => !x.IsEmpty)); return copy; } @@ -429,7 +430,7 @@ public IUri Skip(int count) /// true if successful, false otherwise. public virtual bool Contains(string segment) { - return PathSegments.Where(x => x.Value.Equals(segment, StringComparison.OrdinalIgnoreCase)).Any(); + return PathSegments.Any(x => x.Value.Equals(segment, StringComparison.OrdinalIgnoreCase)); } /// @@ -439,24 +440,24 @@ public virtual bool Contains(string segment) /// true if part of the uri, false otherwise. public bool StartsWith(IUri uri) { - var a = uri.PathSegments; - var b = PathSegments; + // materialize once to avoid repeated enumeration (Count/ElementAt would be O(n²)) + var a = uri.PathSegments as IReadOnlyList ?? uri.PathSegments.ToList(); + var b = PathSegments as IReadOnlyList ?? PathSegments.ToList(); - if (a.Count() > b.Count()) + if (a.Count > b.Count) { return false; } - for (int i = 0; i < a.Count(); i++) + for (int i = 0; i < a.Count; i++) { - if (!a.ElementAt(i).Value.Equals(b.ElementAt(i).Value, StringComparison.OrdinalIgnoreCase)) + if (!a[i].Value.Equals(b[i].Value, StringComparison.OrdinalIgnoreCase)) { return false; } } return true; - } /// From 4504615ee058c1180767635e159a30a7d71c43af Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 7 Jun 2026 12:04:36 +0200 Subject: [PATCH 07/69] feat: add context path --- src/WebExpress.WebCore/WebApplication/ApplicationContext.cs | 5 +++++ src/WebExpress.WebCore/WebApplication/ApplicationManager.cs | 1 + src/WebExpress.WebCore/WebApplication/IApplicationContext.cs | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs index 1ac4bf3f..3cb876ed 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs @@ -41,6 +41,11 @@ public class ApplicationContext : IApplicationContext /// public string DataPath { get; internal set; } + /// + /// Gets the context path. This is mounted in the route of the server. + /// + public string ContextPath { get; internal set; } + /// /// Gets the context path. This is mounted in the route of the server. /// diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index 99f46edf..db2358f8 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -135,6 +135,7 @@ private void Register(IPluginContext pluginContext) ApplicationId = id, ApplicationName = name, Description = description, + ContextPath = contextPath, AssetPath = Path.Combine(_httpServerContext?.AssetPath, assetPath), DataPath = Path.Combine(_httpServerContext?.DataPath, dataPath), Icon = RouteEndpoint.Combine(_httpServerContext?.Route, contextPath, icon), diff --git a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs index 4122f05b..9a5ae48e 100644 --- a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs @@ -40,6 +40,11 @@ public interface IApplicationContext : IContext /// string DataPath { get; } + /// + /// Gets the context path. This is mounted in the route of the server. + /// + string ContextPath { get; } + /// /// Gets the context path. This is mounted in the route of the server. /// From 23a4cabbef2148d0d3c40a3cb5ff29dca69803da Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 21 Jun 2026 21:29:27 +0200 Subject: [PATCH 08/69] feat: general improvements, extensions, and additional tests --- .../Config/UnitTestKestrelConfig.cs | 151 +++++++++ .../Html/UnitTestHtmlElementEditDel.cs | 23 ++ .../Html/UnitTestHtmlElementEditIns.cs | 23 ++ .../Html/UnitTestHtmlElementEmbeddedEmbed.cs | 23 ++ .../Html/UnitTestHtmlElementEmbeddedIframe.cs | 23 ++ .../Html/UnitTestHtmlElementEmbeddedObject.cs | 23 ++ .../Html/UnitTestHtmlElementEmbeddedParam.cs | 23 ++ .../UnitTestHtmlElementEmbeddedPicture.cs | 23 ++ .../Html/UnitTestHtmlElementEmbeddedSource.cs | 23 ++ .../Html/UnitTestHtmlElementFieldButton.cs | 23 ++ .../Html/UnitTestHtmlElementFieldInput.cs | 24 ++ .../Html/UnitTestHtmlElementFieldLegend.cs | 23 ++ .../Html/UnitTestHtmlElementFieldSelect.cs | 23 ++ .../Html/UnitTestHtmlElementFormDatalist.cs | 23 ++ .../Html/UnitTestHtmlElementFormFieldset.cs | 23 ++ .../Html/UnitTestHtmlElementFormForm.cs | 23 ++ .../Html/UnitTestHtmlElementFormKeygen.cs | 23 ++ .../Html/UnitTestHtmlElementFormMeter.cs | 23 ++ .../Html/UnitTestHtmlElementFormOptgroup.cs | 23 ++ .../Html/UnitTestHtmlElementFormOption.cs | 23 ++ .../Html/UnitTestHtmlElementFormOutput.cs | 23 ++ .../Html/UnitTestHtmlElementFormProgress.cs | 23 ++ .../Html/UnitTestHtmlElementFormTextarea.cs | 23 ++ .../UnitTestHtmlElementInteractiveCommand.cs | 23 ++ .../UnitTestHtmlElementInteractiveDetails.cs | 23 ++ .../UnitTestHtmlElementInteractiveDialog.cs | 23 ++ .../UnitTestHtmlElementInteractiveMenu.cs | 23 ++ .../UnitTestHtmlElementInteractiveSummary.cs | 23 ++ .../Html/UnitTestHtmlElementMetadataHead.cs | 23 ++ .../Html/UnitTestHtmlElementMetadataLink.cs | 23 ++ .../Html/UnitTestHtmlElementMetadataMeta.cs | 24 ++ .../Html/UnitTestHtmlElementMetadataStyle.cs | 23 ++ .../Html/UnitTestHtmlElementMetadataTitle.cs | 23 ++ .../Html/UnitTestHtmlElementMultimediaArea.cs | 23 ++ .../UnitTestHtmlElementMultimediaAudio.cs | 23 ++ .../Html/UnitTestHtmlElementMultimediaMap.cs | 23 ++ .../Html/UnitTestHtmlElementMultimediaMath.cs | 23 ++ .../Html/UnitTestHtmlElementMultimediaSvg.cs | 23 ++ .../UnitTestHtmlElementMultimediaTrack.cs | 23 ++ .../UnitTestHtmlElementMultimediaVideo.cs | 23 ++ .../UnitTestHtmlElementScriptingCanvas.cs | 23 ++ .../UnitTestHtmlElementScriptingNoscript.cs | 23 ++ .../UnitTestHtmlElementScriptingScript.cs | 23 ++ .../Html/UnitTestHtmlElementSectionAddress.cs | 23 ++ .../Html/UnitTestHtmlElementSectionArticle.cs | 23 ++ .../Html/UnitTestHtmlElementSectionAside.cs | 23 ++ .../Html/UnitTestHtmlElementSectionBody.cs | 23 ++ .../Html/UnitTestHtmlElementSectionFooter.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH1.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH2.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH3.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH4.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH5.cs | 23 ++ .../Html/UnitTestHtmlElementSectionH6.cs | 23 ++ .../Html/UnitTestHtmlElementSectionHeader.cs | 23 ++ .../Html/UnitTestHtmlElementSectionHgroup.cs | 23 ++ .../Html/UnitTestHtmlElementSectionMain.cs | 23 ++ .../Html/UnitTestHtmlElementSectionNav.cs | 23 ++ .../Html/UnitTestHtmlElementSectionSearch.cs | 23 ++ .../Html/UnitTestHtmlElementSectionSection.cs | 23 ++ .../Html/UnitTestHtmlElementTableCaption.cs | 23 ++ .../Html/UnitTestHtmlElementTableCol.cs | 23 ++ .../Html/UnitTestHtmlElementTableColgroup.cs | 23 ++ .../Html/UnitTestHtmlElementTableTable.cs | 23 ++ .../Html/UnitTestHtmlElementTableTbody.cs | 23 ++ .../Html/UnitTestHtmlElementTableTd.cs | 23 ++ .../Html/UnitTestHtmlElementTableTfoot.cs | 23 ++ .../Html/UnitTestHtmlElementTableTh.cs | 23 ++ .../Html/UnitTestHtmlElementTableThead.cs | 23 ++ .../Html/UnitTestHtmlElementTableTr.cs | 23 ++ ...nitTestHtmlElementTextContentBlockquote.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentDd.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentDiv.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentDl.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentDt.cs | 23 ++ ...nitTestHtmlElementTextContentFigcaption.cs | 23 ++ .../UnitTestHtmlElementTextContentFigure.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentHr.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentLi.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentOl.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentPre.cs | 23 ++ .../Html/UnitTestHtmlElementTextContentUl.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsA.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsAbbr.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsB.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsBdi.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsBdo.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsBr.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsCite.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsCode.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsData.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsDfn.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsEm.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsI.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsKdb.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsMark.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsQ.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsRp.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsRt.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsRuby.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsS.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsSamp.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsSmall.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsSpan.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsStrong.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsSub.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsSup.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsTime.cs | 23 ++ .../Html/UnitTestHtmlElementTextSemanticsU.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsVar.cs | 23 ++ .../UnitTestHtmlElementTextSemanticsWbr.cs | 23 ++ .../UnitTestHtmlElementWebFragmentsSlot.cs | 23 ++ ...UnitTestHtmlElementWebFragmentsTemplate.cs | 23 ++ .../Message/UnitTestContentType.cs | 290 ++++++++++++++++++ .../Message/UnitTestResponseAccepted.cs | 36 +++ .../Message/UnitTestResponseBadGateway.cs | 37 +++ .../Message/UnitTestResponseBadRequest.cs | 37 +++ .../Message/UnitTestResponseConflict.cs | 37 +++ .../Message/UnitTestResponseCreated.cs | 36 +++ .../Message/UnitTestResponseForbidden.cs | 37 +++ .../Message/UnitTestResponseGatewayTimeout.cs | 37 +++ .../Message/UnitTestResponseGone.cs | 37 +++ ...UnitTestResponseHttpVersionNotSupported.cs | 37 +++ .../UnitTestResponseInternalServerError.cs | 37 +++ .../Message/UnitTestResponseLengthRequired.cs | 37 +++ .../UnitTestResponseMethodNotAllowed.cs | 37 +++ .../UnitTestResponseMovedPermanently.cs | 40 +++ .../UnitTestResponseMovedTemporarily.cs | 40 +++ .../Message/UnitTestResponseNoContent.cs | 36 +++ .../Message/UnitTestResponseNotAcceptable.cs | 37 +++ .../Message/UnitTestResponseNotFound.cs | 37 +++ .../Message/UnitTestResponseNotImplemented.cs | 37 +++ .../Message/UnitTestResponseNotModified.cs | 36 +++ .../Message/UnitTestResponseOK.cs | 36 +++ .../Message/UnitTestResponsePartialContent.cs | 36 +++ .../UnitTestResponsePayloadTooLarge.cs | 37 +++ .../UnitTestResponsePermanentRedirect.cs | 40 +++ .../UnitTestResponsePreconditionRequired.cs | 37 +++ .../Message/UnitTestResponseRequestTimeout.cs | 37 +++ .../Message/UnitTestResponseSeeOther.cs | 40 +++ .../UnitTestResponseServiceUnavailable.cs | 37 +++ .../UnitTestResponseSwitchingProtocols.cs | 38 +++ .../UnitTestResponseTemporaryRedirect.cs | 40 +++ .../UnitTestResponseTooManyRequests.cs | 37 +++ .../Message/UnitTestResponseUnauthorized.cs | 36 +++ .../UnitTestResponseUnprocessableEntity.cs | 37 +++ .../UnitTestResponseUnsupportedMediaType.cs | 37 +++ .../UnitTestResponseUpgradeRequired.cs | 37 +++ .../Config/HttpServerConfig.cs | 8 +- .../Config/KestrelConfig.cs | 103 +++++++ src/WebExpress.WebCore/Config/LimitConfig.cs | 37 --- src/WebExpress.WebCore/HttpServer.cs | 58 +++- .../WebHtml/HtmlElementInteractiveDialog.cs | 42 +++ .../WebHtml/HtmlElementSectionHgroup.cs | 33 ++ .../WebHtml/HtmlElementSectionSearch.cs | 33 ++ .../WebMessage/ContentType.cs | 168 +++++++++- .../WebMessage/ResponseAccepted.cs | 19 ++ .../WebMessage/ResponseBadGateway.cs | 34 ++ .../WebMessage/ResponseConflict.cs | 34 ++ .../WebMessage/ResponseGatewayTimeout.cs | 34 ++ .../WebMessage/ResponseGone.cs | 34 ++ .../ResponseHttpVersionNotSupported.cs | 34 ++ .../WebMessage/ResponseLengthRequired.cs | 34 ++ .../WebMessage/ResponseMethodNotAllowed.cs | 34 ++ .../WebMessage/ResponseNotAcceptable.cs | 34 ++ .../WebMessage/ResponseNotImplemented.cs | 34 ++ .../WebMessage/ResponseNotModified.cs | 19 ++ .../WebMessage/ResponsePartialContent.cs | 19 ++ .../WebMessage/ResponsePermanentRedirect.cs | 29 ++ .../ResponsePreconditionRequired.cs | 34 ++ .../WebMessage/ResponseRequestTimeout.cs | 34 ++ .../WebMessage/ResponseSeeOther.cs | 29 ++ .../WebMessage/ResponseServiceUnavailable.cs | 34 ++ .../WebMessage/ResponseTemporaryRedirect.cs | 29 ++ .../WebMessage/ResponseTooManyRequests.cs | 34 ++ .../ResponseUnsupportedMediaType.cs | 34 ++ 176 files changed, 5328 insertions(+), 60 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMap.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaMath.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaSvg.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaTrack.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaVideo.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingCanvas.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingNoscript.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementScriptingScript.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAddress.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionArticle.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionAside.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionBody.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionFooter.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH1.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH2.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH3.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH4.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH5.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionH6.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHeader.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionHgroup.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionMain.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionNav.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSearch.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementSectionSection.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCaption.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableCol.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableColgroup.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTable.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTbody.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTd.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTfoot.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTh.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableThead.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTableTr.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentBlockquote.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDd.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDiv.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDl.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentDt.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigcaption.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentFigure.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentHr.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentLi.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentOl.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentPre.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextContentUl.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsA.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsAbbr.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsB.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdi.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBdo.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsBr.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCite.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsCode.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsData.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsDfn.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsEm.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsI.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsKdb.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsMark.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsQ.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRp.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRt.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsRuby.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsS.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSamp.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSmall.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSpan.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsStrong.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSub.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsSup.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsTime.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsU.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsVar.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementTextSemanticsWbr.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsSlot.cs create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementWebFragmentsTemplate.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseAccepted.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseBadGateway.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseBadRequest.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseConflict.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseCreated.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseForbidden.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseGatewayTimeout.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseGone.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseHttpVersionNotSupported.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseInternalServerError.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseLengthRequired.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseMethodNotAllowed.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedPermanently.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseMovedTemporarily.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseNoContent.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseNotAcceptable.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseNotFound.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseNotImplemented.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseNotModified.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseOK.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponsePartialContent.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponsePayloadTooLarge.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponsePermanentRedirect.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponsePreconditionRequired.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseRequestTimeout.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseSeeOther.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseServiceUnavailable.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseSwitchingProtocols.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseTemporaryRedirect.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseTooManyRequests.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseUnauthorized.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseUnprocessableEntity.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseUnsupportedMediaType.cs create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseUpgradeRequired.cs create mode 100644 src/WebExpress.WebCore/Config/KestrelConfig.cs delete mode 100644 src/WebExpress.WebCore/Config/LimitConfig.cs create mode 100644 src/WebExpress.WebCore/WebHtml/HtmlElementInteractiveDialog.cs create mode 100644 src/WebExpress.WebCore/WebHtml/HtmlElementSectionHgroup.cs create mode 100644 src/WebExpress.WebCore/WebHtml/HtmlElementSectionSearch.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseAccepted.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseBadGateway.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseConflict.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseGatewayTimeout.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseGone.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseHttpVersionNotSupported.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseLengthRequired.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseMethodNotAllowed.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseNotAcceptable.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseNotImplemented.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseNotModified.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponsePartialContent.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponsePermanentRedirect.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponsePreconditionRequired.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseRequestTimeout.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseSeeOther.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseServiceUnavailable.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseTemporaryRedirect.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseTooManyRequests.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ResponseUnsupportedMediaType.cs diff --git a/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs b/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs new file mode 100644 index 00000000..a6b6754d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs @@ -0,0 +1,151 @@ +using System.IO; +using System.Xml.Serialization; +using WebExpress.WebCore.Config; + +namespace WebExpress.WebCore.Test.Config +{ + /// + /// Unit tests for the optional Kestrel configuration block and its deserialization, + /// mirroring how the server loads its configuration via XmlSerializer. + /// + public class UnitTestKestrelConfig + { + /// + /// Deserializes the given configuration document into an HttpServerConfig instance. + /// + /// The configuration document. + /// The deserialized configuration. + private static HttpServerConfig Deserialize(string xml) + { + var serializer = new XmlSerializer(typeof(HttpServerConfig)); + using var reader = new StringReader(xml); + + return serializer.Deserialize(reader) as HttpServerConfig; + } + + /// + /// Tests that a configuration without a kestrel block leaves the property null so the + /// server keeps its built-in defaults. + /// + [Fact] + public void MissingBlockIsNull() + { + // arrange + var xml = ""; + + // act + var config = Deserialize(xml); + + // validation + Assert.Null(config.Kestrel); + } + + /// + /// Tests that all kestrel settings are read when present. + /// + [Fact] + public void FullBlockIsDeserialized() + { + // arrange + var xml = + "" + + " " + + " " + + " 300" + + " 3000000000" + + " 65536" + + " false" + + " false" + + " false" + + " 1000" + + " 2097152" + + " 131072" + + " 16384" + + " 90" + + " 15" + + " " + + ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.NotNull(kestrel); + Assert.Equal(300, kestrel.MaxConcurrentConnections); + Assert.Equal(3000000000, kestrel.MaxRequestBodySize); + Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); + Assert.False(kestrel.AllowSynchronousIO); + Assert.False(kestrel.AllowResponseHeaderCompression); + Assert.False(kestrel.AddServerHeader); + Assert.Equal(1000, kestrel.MaxConcurrentUpgradedConnections); + Assert.Equal(2097152, kestrel.MaxRequestBufferSize); + Assert.Equal(131072, kestrel.MaxResponseBufferSize); + Assert.Equal(16384, kestrel.MaxRequestLineSize); + Assert.Equal(90, kestrel.KeepAliveTimeout); + Assert.Equal(15, kestrel.RequestHeadersTimeout); + } + + /// + /// Tests that individual settings are independently optional: elements that are not present + /// remain null, so only explicitly configured values override the defaults. + /// + [Fact] + public void OmittedElementsRemainNull() + { + // arrange + var xml = + "" + + " " + + " " + + " false" + + " " + + ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.NotNull(kestrel); + Assert.False(kestrel.AddServerHeader); + Assert.Null(kestrel.AllowSynchronousIO); + Assert.Null(kestrel.AllowResponseHeaderCompression); + Assert.Null(kestrel.MaxConcurrentConnections); + Assert.Null(kestrel.MaxRequestBodySize); + Assert.Null(kestrel.MaxRequestHeadersTotalSize); + Assert.Null(kestrel.MaxConcurrentUpgradedConnections); + Assert.Null(kestrel.MaxRequestBufferSize); + Assert.Null(kestrel.MaxResponseBufferSize); + Assert.Null(kestrel.MaxRequestLineSize); + Assert.Null(kestrel.KeepAliveTimeout); + Assert.Null(kestrel.RequestHeadersTimeout); + } + + /// + /// Tests that the request limits, which were previously configured through a separate limit + /// block, are read from the consolidated kestrel block. + /// + [Fact] + public void RequestLimitsAreDeserialized() + { + // arrange + var xml = + "" + + " " + + " " + + " 300" + + " 3000000000" + + " 65536" + + " " + + ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.NotNull(kestrel); + Assert.Equal(300, kestrel.MaxConcurrentConnections); + Assert.Equal(3000000000, kestrel.MaxRequestBodySize); + Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs new file mode 100644 index 00000000..89bc015b --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditDel.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEditDel class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEditDel + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEditDel(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs new file mode 100644 index 00000000..96d8d848 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEditIns.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEditIns class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEditIns + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEditIns(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs new file mode 100644 index 00000000..7e4c511c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedEmbed.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedEmbed class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedEmbed + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedEmbed(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs new file mode 100644 index 00000000..9c53ad16 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedIframe.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedIframe class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedIframe + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedIframe(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs new file mode 100644 index 00000000..e5037ee4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedObject.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedObject class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedObject + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedObject(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs new file mode 100644 index 00000000..f5b74c2f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedParam.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedParam class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedParam + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedParam(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs new file mode 100644 index 00000000..979f04f7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedPicture.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedPicture class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedPicture + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedPicture(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs new file mode 100644 index 00000000..806e220d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementEmbeddedSource.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementEmbeddedSource class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementEmbeddedSource + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementEmbeddedSource(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs new file mode 100644 index 00000000..71568396 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldButton.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldButton class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldButton + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldButton(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs new file mode 100644 index 00000000..59fd0cad --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldInput.cs @@ -0,0 +1,24 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldInput class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldInput + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldInput(); + + // validation + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs new file mode 100644 index 00000000..4cd19697 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldLegend.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldLegend class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldLegend + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldLegend(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs new file mode 100644 index 00000000..1bf36520 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFieldSelect.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFieldSelect class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFieldSelect + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFieldSelect(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs new file mode 100644 index 00000000..7a11ba00 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormDatalist.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormDatalist class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormDatalist + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormDatalist(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs new file mode 100644 index 00000000..61478b52 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormFieldset.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormFieldset class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormFieldset + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormFieldset(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs new file mode 100644 index 00000000..aa086fb3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormForm.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormForm class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormForm + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormForm(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs new file mode 100644 index 00000000..1b6eb7e4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormKeygen.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormKeygen class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormKeygen + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormKeygen(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs new file mode 100644 index 00000000..a9af3910 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormMeter.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormMeter class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormMeter + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormMeter(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs new file mode 100644 index 00000000..4aae7c76 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOptgroup.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOptgroup class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOptgroup + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOptgroup(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs new file mode 100644 index 00000000..3a36d4b7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOption.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOption class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOption + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOption(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs new file mode 100644 index 00000000..84dd138c --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormOutput.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormOutput class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormOutput + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormOutput(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs new file mode 100644 index 00000000..cdde1f6a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormProgress.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormProgress class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormProgress + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormProgress(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs new file mode 100644 index 00000000..655d6e34 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementFormTextarea.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementFormTextarea class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementFormTextarea + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementFormTextarea(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs new file mode 100644 index 00000000..98e44409 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveCommand.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveCommand class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveCommand + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveCommand(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs new file mode 100644 index 00000000..9e5944d7 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDetails.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveDetails class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveDetails + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveDetails(); + + Assert.Equal(@"
", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs new file mode 100644 index 00000000..a175512d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveDialog.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveDialog class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveDialog + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveDialog(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs new file mode 100644 index 00000000..1eaf7b8d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveMenu.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveMenu class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveMenu + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveMenu(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs new file mode 100644 index 00000000..79701f70 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementInteractiveSummary.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementInteractiveSummary class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementInteractiveSummary + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementInteractiveSummary(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs new file mode 100644 index 00000000..a96a656e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataHead.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataHead class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataHead + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataHead(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs new file mode 100644 index 00000000..81750245 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataLink.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataLink class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataLink + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataLink(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs new file mode 100644 index 00000000..ad2b7730 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs @@ -0,0 +1,24 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataMeta class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataMeta + { + /// + /// Tests a tag built from a key and a value. Meta is rendered as a single + /// key/value attribute, so the empty constructor has no meaningful output. + /// + [Fact] + public void KeyValue() + { + // act + var html = new HtmlElementMetadataMeta("charset", "utf-8"); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs new file mode 100644 index 00000000..6d3fd09e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataStyle.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataStyle class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataStyle + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataStyle(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs new file mode 100644 index 00000000..57b35d77 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataTitle.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMetadataTitle class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMetadataTitle + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMetadataTitle(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs new file mode 100644 index 00000000..7b4205bf --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaArea.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaArea class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaArea + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaArea(); + + Assert.Equal(@"", html.Trim()); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs new file mode 100644 index 00000000..53167fc0 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMultimediaAudio.cs @@ -0,0 +1,23 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the HtmlElementMultimediaAudio class. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlElementMultimediaAudio + { + /// + /// Tests an empty tag. + /// + [Fact] + public void Empty() + { + // act + var html = new HtmlElementMultimediaAudio(); + + Assert.Equal(@"
public string TimePattern { set; get; } + /// + /// Gets or sets the maximum number of recent log entries retained in memory for live + /// inspection. The retained entries are independent of whether a log file is written. + /// + public int RecentCapacity { get; set; } + + /// + /// Occurs immediately after a log entry has been recorded. Handlers run on the calling + /// (logging) thread and must therefore be fast and must not throw; a faulty handler is + /// isolated so it cannot break logging. + /// + public event EventHandler EntryLogged; + + /// + /// Returns a snapshot of the most recent log entries currently retained in memory, oldest first. + /// + /// A point-in-time copy that is safe to enumerate without further locking. + public IReadOnlyList GetRecentEntries(); + /// /// Starts logging /// diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index 4baa20e4..f5b1376f 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -12,14 +12,14 @@ namespace WebExpress.WebCore.WebLog { /// /// Class for logging events to your log file - /// - /// The program writes a variety of information to an event log file. The log - /// is stored in the log directory. The name consists of the date and the ending ".log". + /// + /// The program writes a variety of information to an event log file. The log + /// is stored in the log directory. The name consists of the date and the ending ".log". /// The structure is designed in such a way that the log file can be read and analyzed with a text editor. - /// Error messages and notes are made available persistently in the log, so the event log files - /// are suitable for error analysis and for checking the correct functioning of the program. The minutes - /// are organized in tabular form. In the first column, the primeval time is indicated. The second - /// column defines the level of the log entry. The third column lists the function that produced the entry. + /// Error messages and notes are made available persistently in the log, so the event log files + /// are suitable for error analysis and for checking the correct functioning of the program. The minutes + /// are organized in tabular form. In the first column, the primeval time is indicated. The second + /// column defines the level of the log entry. The third column lists the function that produced the entry. /// The last column indicates a note or error description. /// /// @@ -34,12 +34,24 @@ namespace WebExpress.WebCore.WebLog public class Log : ILog { private readonly Queue _queue = new(); + private readonly Queue _recent = new(); private string _path; private Thread _workerThread; private const int _separatorWidth = 260; - private bool _done = false; + private volatile bool _done = false; private readonly int _width = 250; + // dedicated lock objects; the previous implementation locked on the _path string, which is + // an interned, shared instance and is null until Begin has run - both are unsafe. + private readonly Lock _fileLock = new(); + private readonly Lock _recentLock = new(); + private static readonly Lock _consoleLock = new(); + + // counters are touched from arbitrary caller threads, so they are updated atomically. + private int _errorCount; + private int _warningCount; + private int _exceptionCount; + /// /// Gets or sets the encoding. /// @@ -58,17 +70,17 @@ public class Log : ILog /// /// Gets the number of exceptions. /// - public int ExceptionCount { get; protected set; } + public int ExceptionCount => _exceptionCount; /// /// Gets the number of errors (errors + exceptions). /// - public int ErrorCount { get; protected set; } + public int ErrorCount => _errorCount; /// /// Gets the number of warnings. /// - public int WarningCount { get; protected set; } + public int WarningCount => _warningCount; /// /// Checks if the log has been opened for writing. @@ -95,6 +107,19 @@ public class Log : ILog /// public string TimePattern { set; get; } + /// + /// Gets or sets the maximum number of recent log entries retained in memory for live + /// inspection. The retained entries are independent of whether a log file is written. + /// + public int RecentCapacity { get; set; } = 1000; + + /// + /// Occurs immediately after a log entry has been recorded. Handlers run on the calling + /// (logging) thread and must therefore be fast and must not throw; a faulty handler is + /// isolated so it cannot break logging. + /// + public event EventHandler EntryLogged; + /// /// Initializes a new instance of the class. /// @@ -135,15 +160,21 @@ public void Begin(string path, string name) } } - // create thread - _workerThread = new Thread(new ThreadStart(ThreadProc)) - { + // reset the stop flag so logging can be restarted after a previous Close + _done = false; - // Background thread - IsBackground = true - }; + // only a single worker thread is started; calling Begin again just reconfigures the target + if (_workerThread is null) + { + _workerThread = new Thread(new ThreadStart(ThreadProc)) + { + // Background thread + IsBackground = true, + Name = "WebExpress.Log" + }; - _workerThread.Start(); + _workerThread.Start(); + } } /// @@ -162,9 +193,27 @@ public void Begin(string path) public void Begin(SettingLogItem settings) { Filename = settings.Filename; - LogMode = Enum.Parse(settings.Modus); - Encoding = Encoding.GetEncoding(settings.Encoding); - TimePattern = settings.Timepattern; + + // a malformed configuration value must not crash startup; fall back to the current value + if (Enum.TryParse(settings.Modus, true, out var mode)) + { + LogMode = mode; + } + + try + { + Encoding = Encoding.GetEncoding(settings.Encoding); + } + catch + { + Encoding = Encoding.UTF8; + } + + if (!string.IsNullOrWhiteSpace(settings.Timepattern)) + { + TimePattern = settings.Timepattern; + } + DebugMode = settings.Debug; Begin(settings.Path, Filename); @@ -182,31 +231,24 @@ protected virtual void Add(LogLevel level, string message, [CallerMemberName] st { try { - foreach (var l in message?.Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)) + // split multi-line messages so every line is rendered as its own tabular entry + foreach (var l in (message ?? string.Empty).Split([Environment.NewLine], StringSplitOptions.RemoveEmptyEntries)) { - lock (_queue) + var item = new LogItem(level, instance, l, TimePattern); + + WriteToConsole(level, item.ToString() ?? string.Empty); + + // only buffer for the file when a log file is actually written; otherwise the + // queue would grow unbounded whenever logging is switched off. + if (LogMode != LogMode.Off) { - var item = new LogItem(level, instance, l, TimePattern); - var text = item.ToString() ?? string.Empty; - switch (level) + lock (_queue) { - case LogLevel.Error: - case LogLevel.FatalError: - case LogLevel.Exception: - Console.ForegroundColor = ConsoleColor.Red; - break; - case LogLevel.Warning: - Console.ForegroundColor = ConsoleColor.Yellow; - break; - default: - break; + _queue.Enqueue(item); } - - Console.WriteLine(text.Length > _separatorWidth ? string.Concat(text.AsSpan(0, _separatorWidth - 3), "...") : text.PadRight(_width, ' ')); - Console.ResetColor(); - - _queue.Enqueue(item); } + + Record(level, instance, l, item.Timestamp); } } catch (Exception ex) @@ -215,6 +257,79 @@ protected virtual void Add(LogLevel level, string message, [CallerMemberName] st } } + /// + /// Writes a single, color-coded entry to the console. Serialized so concurrent callers + /// cannot interleave color changes with each other. + /// + /// The level of the entry, which selects the console color. + /// The fully formatted entry text. + private void WriteToConsole(LogLevel level, string text) + { + lock (_consoleLock) + { + switch (level) + { + case LogLevel.Error: + case LogLevel.FatalError: + case LogLevel.Exception: + Console.ForegroundColor = ConsoleColor.Red; + break; + case LogLevel.Warning: + Console.ForegroundColor = ConsoleColor.Yellow; + break; + default: + break; + } + + Console.WriteLine(text.Length > _separatorWidth ? string.Concat(text.AsSpan(0, _separatorWidth - 3), "...") : text.PadRight(_width, ' ')); + Console.ResetColor(); + } + } + + /// + /// Adds an entry to the bounded in-memory ring buffer and notifies subscribers. + /// + /// The level of the entry. + /// The originating location. + /// The log message. + /// The timestamp of the entry. + private void Record(LogLevel level, string instance, string message, DateTime timestamp) + { + var entry = new LogEntry(timestamp, level, instance, message); + + lock (_recentLock) + { + _recent.Enqueue(entry); + + // honor a capacity that may have been lowered at runtime + while (_recent.Count > RecentCapacity && _recent.Count > 0) + { + _recent.Dequeue(); + } + } + + try + { + EntryLogged?.Invoke(this, entry); + } + catch + { + // a faulty subscriber must never break logging + } + } + + /// + /// Returns a snapshot of the most recent log entries currently retained in memory, oldest first. + /// + /// A point-in-time copy that is safe to enumerate without further locking. + public IReadOnlyList GetRecentEntries() + { + lock (_recentLock) + { + return new List(_recent); + } + } + /// /// A dividing line with * characters /// @@ -229,7 +344,7 @@ public void Separator() /// The separator. public void Separator(char sepChar) { - Add(LogLevel.Seperartor, "".PadRight(_separatorWidth, sepChar)); + Add(LogLevel.Separator, "".PadRight(_separatorWidth, sepChar)); } /// @@ -297,7 +412,7 @@ public void Warning(string message, [CallerMemberName] string instance = null, [ { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - WarningCount++; + Interlocked.Increment(ref _warningCount); } /// @@ -321,7 +436,7 @@ public void Warning(string message, [CallerMemberName] string instance = null, [ { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - WarningCount++; + Interlocked.Increment(ref _warningCount); } /// @@ -344,7 +459,7 @@ public void Error(string message, [CallerMemberName] string instance = null, [Ca { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - ErrorCount++; + Interlocked.Increment(ref _errorCount); } /// @@ -368,7 +483,7 @@ public void Error(string message, [CallerMemberName] string instance = null, [Ca { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - ErrorCount++; + Interlocked.Increment(ref _errorCount); } /// @@ -382,17 +497,16 @@ public void FatalError(string message, [CallerMemberName] string instance = null { try { - var methodInfo = new StackTrace()?.GetFrame(1).GetMethod(); + var methodInfo = new StackTrace().GetFrame(1)?.GetMethod(); var className = methodInfo?.ReflectedType.Name; Add(LogLevel.FatalError, message, $"{className}.{instance}", line, file); - } catch (Exception ex) { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - ErrorCount++; + Interlocked.Increment(ref _errorCount); } /// @@ -411,13 +525,12 @@ public void FatalError(string message, [CallerMemberName] string instance = null var className = methodInfo?.ReflectedType.Name; Add(LogLevel.FatalError, string.Format(message, args), $"{className}.{instance}", line, file); - } catch (Exception ex) { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - ErrorCount++; + Interlocked.Increment(ref _errorCount); } /// @@ -434,21 +547,17 @@ public void Exception(Exception exception, [CallerMemberName] string instance = var methodInfo = new StackTrace().GetFrame(1)?.GetMethod(); var className = methodInfo?.ReflectedType.Name; - lock (_queue) - { - Add(LogLevel.Exception, exception?.Message.Trim(), $"{className}.{instance}", line, file); - Add(LogLevel.Exception, exception?.StackTrace is not null - ? exception?.StackTrace.Trim() - : exception?.Message.Trim(), $"{className}.{instance}", line, file); - - } + Add(LogLevel.Exception, exception?.Message.Trim(), $"{className}.{instance}", line, file); + Add(LogLevel.Exception, exception?.StackTrace is not null + ? exception?.StackTrace.Trim() + : exception?.Message.Trim(), $"{className}.{instance}", line, file); } catch (Exception ex) { Console.WriteLine($"Debug-Logging failed: {ex.Message}"); } - ExceptionCount++; - ErrorCount++; + Interlocked.Increment(ref _exceptionCount); + Interlocked.Increment(ref _errorCount); } /// @@ -503,17 +612,18 @@ public void Debug(string message, [CallerMemberName] string instance = null, [Ca } /// - /// Stops logging. + /// Stops logging. Signals the worker thread, waits briefly for it to finish and writes any + /// entries that are still pending. /// public void Close() { _done = true; - // protect file writing from concurrent access - lock (_path) - { - Flush(); - } + var worker = _workerThread; + worker?.Join(TimeSpan.FromSeconds(6)); + _workerThread = null; + + Flush(); } /// @@ -521,9 +631,9 @@ public void Close() /// public void Clear() { - ErrorCount = 0; - WarningCount = 0; - ExceptionCount = 0; + Interlocked.Exchange(ref _errorCount, 0); + Interlocked.Exchange(ref _warningCount, 0); + Interlocked.Exchange(ref _exceptionCount, 0); } /// @@ -540,20 +650,29 @@ public void Flush() _queue.Clear(); } - // protect file writing from concurrent access - if (list.Count > 0 && LogMode != LogMode.Off) + if (list.Count == 0 || LogMode == LogMode.Off || string.IsNullOrEmpty(Filename)) + { + return; + } + + // protect file writing from concurrent access; a transient IO failure must not take down + // the background worker thread. + try { - lock (_path) + lock (_fileLock) { using var fs = new FileStream(Filename, FileMode.Append); using var w = new StreamWriter(fs, Encoding); foreach (var item in list) { - var str = item.ToString(); - w.WriteLine(str); + w.WriteLine(item.ToString()); } } } + catch (Exception ex) + { + Console.WriteLine($"Writing the log file failed: {ex.Message}"); + } } /// @@ -563,16 +682,14 @@ private void ThreadProc() { while (!_done) { - Thread.Sleep(5000); - - // protect file writing from concurrent access - lock (_path) + // poll in small steps so Close responds quickly instead of waiting a full interval + for (var i = 0; i < 10 && !_done; i++) { - Flush(); + Thread.Sleep(500); } - } - _workerThread = null; + Flush(); + } } /// diff --git a/src/WebExpress.WebCore/WebLog/LogEntry.cs b/src/WebExpress.WebCore/WebLog/LogEntry.cs new file mode 100644 index 00000000..7b4e2741 --- /dev/null +++ b/src/WebExpress.WebCore/WebLog/LogEntry.cs @@ -0,0 +1,47 @@ +using System; + +namespace WebExpress.WebCore.WebLog +{ + /// + /// An immutable snapshot of a single log entry. It exists so consumers (e.g. an admin log viewer + /// or live monitoring) can read recent log activity without touching the internal, mutable write + /// buffer of the logger. + /// + public sealed class LogEntry + { + /// + /// Gets the point in time at which the entry was recorded. + /// + public DateTime Timestamp { get; } + + /// + /// Gets the severity level of the entry. + /// + public LogLevel Level { get; } + + /// + /// Gets the originating location (typically "ClassName.MethodName"). + /// + public string Instance { get; } + + /// + /// Gets the log message. + /// + public string Message { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The point in time at which the entry was recorded. + /// The severity level of the entry. + /// The originating location. + /// The log message. + public LogEntry(DateTime timestamp, LogLevel level, string instance, string message) + { + Timestamp = timestamp; + Level = level; + Instance = instance; + Message = message; + } + } +} diff --git a/src/WebExpress.WebCore/WebLog/LogFrame.cs b/src/WebExpress.WebCore/WebLog/LogFrame.cs index ff6133c0..2455c041 100644 --- a/src/WebExpress.WebCore/WebLog/LogFrame.cs +++ b/src/WebExpress.WebCore/WebLog/LogFrame.cs @@ -45,6 +45,8 @@ public class LogFrame : IDisposable public LogFrame(ILog log, string name, string additionalHeading = null, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null) { Instance = instance; + Line = line ?? 0; + File = file; Status = string.Format("{0} completed. ", name); Log = log; diff --git a/src/WebExpress.WebCore/WebLog/LogItem.cs b/src/WebExpress.WebCore/WebLog/LogItem.cs index 6d00a0b5..a3734954 100644 --- a/src/WebExpress.WebCore/WebLog/LogItem.cs +++ b/src/WebExpress.WebCore/WebLog/LogItem.cs @@ -49,7 +49,7 @@ public LogItem(LogLevel level, string instance, string message, string timePatte /// The log entry as a string public override string ToString() { - if (m_level != LogLevel.Seperartor) + if (m_level != LogLevel.Separator) { return m_timestamp.ToString(TimePattern) + " " + m_level.ToString().PadRight(9, ' ') + " " + m_instance.PadRight(19, ' ')[..19] + " " + m_message; } diff --git a/src/WebExpress.WebCore/WebLog/LogLevel.cs b/src/WebExpress.WebCore/WebLog/LogLevel.cs index 34061976..16520501 100644 --- a/src/WebExpress.WebCore/WebLog/LogLevel.cs +++ b/src/WebExpress.WebCore/WebLog/LogLevel.cs @@ -38,6 +38,6 @@ public enum LogLevel /// /// A separator message. This is used to separate groups of messages in the logging output. /// - Seperartor + Separator } } From 3bc9c4605b7d81e84c95f9547083a3cd5f418c3e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 21 Jun 2026 22:25:17 +0200 Subject: [PATCH 10/69] feat: webasset improvements and minor bugs --- .../Manager/UnitTestAssetManager.cs | 98 ++++++++++++ .../Message/UnitTestContentType.cs | 6 + src/WebExpress.WebCore/WebAsset/Asset.cs | 140 +++++++----------- .../WebMessage/ContentType.cs | 21 ++- .../WebMessage/RequestHeaderFields.cs | 11 +- .../WebResource/ResourceAsset.cs | 106 ++----------- .../WebResource/ResourceBinary.cs | 85 ++++++++++- .../WebResource/ResourceFile.cs | 75 +++------- 8 files changed, 303 insertions(+), 239 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index 4549eaf5..a4d52c6e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -134,6 +134,104 @@ public void Request(string uri, string resource) Assert.Equal(embeddedResource, Encoding.UTF8.GetString(response.Content as byte[])); } + /// + /// Tests that the asset response carries the content type derived from the file extension. + /// + [Theory] + [InlineData("http://localhost:8080/server/appa/assets/css/mycss.css", "text/css")] + [InlineData("http://localhost:8080/server/appa/assets/js/myjavascript.js", "text/javascript")] + public void ContentType(string uri, string expectedContentType) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act + var response = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + + // validation + Assert.Equal(expectedContentType, response.Header.ContentType); + } + + /// + /// Tests that the asset response exposes a non-empty ETag for cache validation. + /// + [Fact] + public void ETagIsSet() + { + // arrange + var uri = "http://localhost:8080/server/appa/assets/css/mycss.css"; + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act + var response = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + + // validation + Assert.True(response.Header.CustomHeader.ContainsKey("ETag")); + Assert.False(string.IsNullOrEmpty(response.Header.CustomHeader["ETag"])); + } + + /// + /// Tests that a request whose If-None-Match matches the current ETag is answered with + /// 304 Not Modified instead of resending the payload. + /// + [Fact] + public void ConditionalRequestReturnsNotModified() + { + // arrange + var uri = "http://localhost:8080/server/appa/assets/css/mycss.css"; + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var context = UnitTestFixture.CreateHttpContextMock(); + componentHub.SitemapManager.Refresh(); + + var searchResult = componentHub.SitemapManager.SearchResource(new System.Uri(uri), new SearchContext() + { + HttpServerContext = httpServerContext, + Culture = httpServerContext.Culture, + HttpContext = context + }); + + // act - first request returns the payload together with its ETag + var first = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock("", uri), searchResult.EndpointContext); + var eTag = first.Header.CustomHeader["ETag"]; + + // act - second request presents the ETag via If-None-Match + var conditional = $"GET {uri} HTTP/1.1\nIf-None-Match: {eTag}\n\n"; + var second = componentHub + .EndpointManager + .HandleRequest(UnitTestFixture.CreateRequestMock(conditional, uri), searchResult.EndpointContext); + + // validation + Assert.IsType(first); + Assert.IsType(second); + } + /// /// Tests whether the asset manager implements interface IComponentManager. /// diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs b/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs index ce3fa38b..f99abd84 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestContentType.cs @@ -15,6 +15,9 @@ public class UnitTestContentType [InlineData(".txt", ContentType.Txt)] [InlineData(".css", ContentType.Css)] [InlineData(".js", ContentType.Js)] + [InlineData(".mjs", ContentType.Js)] + [InlineData(".map", ContentType.Json)] + [InlineData(".eot", ContentType.Eot)] [InlineData(".xml", ContentType.Xml)] [InlineData(".html", ContentType.Html)] [InlineData(".htm", ContentType.Htm)] @@ -124,6 +127,7 @@ public void ToContentTypeReturnsUnknown(string extension) [InlineData("font/woff2", ContentType.Woff2)] [InlineData("font/ttf", ContentType.Ttf)] [InlineData("font/otf", ContentType.Otf)] + [InlineData("application/vnd.ms-fontobject", ContentType.Eot)] [InlineData("application/gzip", ContentType.Gz)] [InlineData("application/x-tar", ContentType.Tar)] [InlineData("application/x-7z-compressed", ContentType.SevenZip)] @@ -199,6 +203,7 @@ public void ToContentTypeFromMimeReturnsUnknown(string mimeType) [InlineData(ContentType.Woff2, "font/woff2")] [InlineData(ContentType.Ttf, "font/ttf")] [InlineData(ContentType.Otf, "font/otf")] + [InlineData(ContentType.Eot, "application/vnd.ms-fontobject")] [InlineData(ContentType.Gz, "application/gzip")] [InlineData(ContentType.Tar, "application/x-tar")] [InlineData(ContentType.SevenZip, "application/x-7z-compressed")] @@ -270,6 +275,7 @@ public void OpenXmlOfficeTypesRoundTrip(ContentType contentType) [InlineData(ContentType.Woff2, "*.woff2")] [InlineData(ContentType.Ttf, "*.ttf")] [InlineData(ContentType.Otf, "*.otf")] + [InlineData(ContentType.Eot, "*.eot")] [InlineData(ContentType.Gz, "*.gz")] [InlineData(ContentType.Tar, "*.tar")] [InlineData(ContentType.SevenZip, "*.7z")] diff --git a/src/WebExpress.WebCore/WebAsset/Asset.cs b/src/WebExpress.WebCore/WebAsset/Asset.cs index 5e8a3e67..96c3bf12 100644 --- a/src/WebExpress.WebCore/WebAsset/Asset.cs +++ b/src/WebExpress.WebCore/WebAsset/Asset.cs @@ -1,5 +1,7 @@ -using System.IO; +using System; +using System.IO; using System.Reflection; +using System.Security.Cryptography; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebMessage; @@ -15,6 +17,8 @@ public class Asset : IAsset private readonly IAssetContext _assetContext; private readonly IHttpServerContext _httpServerContext; private readonly string _embeddedResource; + private readonly ContentType _contentType; + private readonly string _eTag; private byte[] _data; /// @@ -38,6 +42,11 @@ public Asset(IComponentHub componentHub, IAssetContext assetContext, IHttpServer var assembly = _assetContext.PluginContext.Assembly; _data = GetData(assembly); + + // an asset is immutable embedded content, so its content type and entity tag can be + // resolved once at construction time instead of on every request. + _contentType = ContentTypeExtensions.ToContentType(Path.GetExtension(_assetContext.EndpointId?.ToString())); + _eTag = ComputeETag(_data); } /// @@ -52,96 +61,32 @@ public IResponse Process(IRequest request) return new ResponseNotFound(); } - var extension = Path.GetExtension(_assetContext.EndpointId.ToString())?.ToLower() ?? ""; - var response = new ResponseOK(); + // conditional request: when the client already holds the current version, skip the body + // and answer with 304 Not Modified so the cached copy is reused. + if (!string.IsNullOrEmpty(_eTag) && string.Equals(request?.Header?.IfNoneMatch, _eTag, StringComparison.Ordinal)) + { + var notModified = new ResponseNotModified(); + notModified.Header.CacheControl = "public, max-age=31536000"; + notModified.Header.AddCustomHeader("ETag", _eTag); + return notModified; + } + + var response = new ResponseOK(); response.Header.CacheControl = "public, max-age=31536000"; response.Header.ContentLength = _data.Length; + response.Header.ContentType = _contentType.GetMimeType(); response.Content = _data; - switch (extension) + if (!string.IsNullOrEmpty(_eTag)) { - case ".pdf": - response.Header.ContentType = "application/pdf"; - break; - case ".txt": - response.Header.ContentType = "text/plain"; - break; - case ".css": - response.Header.ContentType = "text/css"; - break; - case ".js": - case ".mjs": - response.Header.ContentType = "application/javascript"; - break; - case ".json": - response.Header.ContentType = "application/json"; - break; - case ".map": - response.Header.ContentType = "application/json"; - break; - case ".xml": - response.Header.ContentType = "text/xml"; - break; - case ".woff": - response.Header.ContentType = "font/woff"; - break; - case ".woff2": - response.Header.ContentType = "font/woff2"; - break; - case ".ttf": - response.Header.ContentType = "font/ttf"; - break; - case ".eot": - response.Header.ContentType = "application/vnd.ms-fontobject"; - break; - case ".webp": - response.Header.ContentType = "image/webp"; - break; - case ".html": - case ".htm": - response.Header.ContentType = "text/html"; - break; - case ".zip": - response.Header.ContentDisposition = "attatchment; filename=" + _assetContext.EndpointId + "; size=" + _data.LongLength; - response.Header.ContentType = "application/zip"; - break; - case ".doc": - case ".docx": - response.Header.ContentType = "application/msword"; - break; - case ".xls": - case ".xlx": - response.Header.ContentType = "application/vnd.ms-excel"; - break; - case ".ppt": - response.Header.ContentType = "application/vnd.ms-powerpoint"; - break; - case ".gif": - response.Header.ContentType = "image/gif"; - break; - case ".png": - response.Header.ContentType = "image/png"; - break; - case ".svg": - response.Header.ContentType = "image/svg+xml"; - break; - case ".jpeg": - case ".jpg": - response.Header.ContentType = "image/jpg"; - break; - case ".ico": - response.Header.ContentType = "image/x-icon"; - break; - case ".mp3": - response.Header.ContentType = "audio/mpeg"; - break; - case ".mp4": - response.Header.ContentType = "video/mp4"; - break; - default: - response.Header.ContentType = "binary/octet-stream"; - break; + response.Header.AddCustomHeader("ETag", _eTag); + } + + // archives are offered as a download rather than rendered inline + if (_contentType == ContentType.Zip) + { + response.Header.ContentDisposition = $"attachment; filename={_assetContext.EndpointId}; size={_data.LongLength}"; } _httpServerContext?.Log?.Debug(I18N.Translate @@ -166,12 +111,35 @@ private byte[] GetData(Assembly assembly) } using var stream = assembly.GetManifestResourceStream(_embeddedResource); + + if (stream is null) + { + return []; + } + using var memoryStream = new MemoryStream(); stream.CopyTo(memoryStream); return memoryStream.ToArray(); } + /// + /// Computes a content-based entity tag (ETag) for conditional requests. + /// + /// The asset payload. + /// A quoted ETag value, or null when there is no payload. + private static string ComputeETag(byte[] data) + { + if (data is null || data.Length == 0) + { + return null; + } + + // a content hash is used so the tag changes if and only if the payload changes; SHA-1 is + // chosen for speed because the value is a cache validator, not a security token. + return "\"" + Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant() + "\""; + } + /// /// Performs application-specific tasks related to sharing, returning, or resetting unmanaged resources. /// @@ -180,4 +148,4 @@ public void Dispose() _data = null; } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebMessage/ContentType.cs b/src/WebExpress.WebCore/WebMessage/ContentType.cs index 6d422035..2f69bfb7 100644 --- a/src/WebExpress.WebCore/WebMessage/ContentType.cs +++ b/src/WebExpress.WebCore/WebMessage/ContentType.cs @@ -204,7 +204,17 @@ public enum ContentType /// /// 7-Zip compressed archive (.7z). /// - SevenZip + SevenZip, + + /// + /// Embedded OpenType font (.eot). + /// + Eot, + + /// + /// Executable file (.exe). Served as a generic binary download. + /// + Exe } /// @@ -239,6 +249,8 @@ public static ContentType ToContentType(string extension) ".txt" => ContentType.Txt, ".css" => ContentType.Css, ".js" => ContentType.Js, + ".mjs" => ContentType.Js, + ".map" => ContentType.Json, ".xml" => ContentType.Xml, ".html" => ContentType.Html, ".htm" => ContentType.Htm, @@ -271,9 +283,11 @@ public static ContentType ToContentType(string extension) ".woff2" => ContentType.Woff2, ".ttf" => ContentType.Ttf, ".otf" => ContentType.Otf, + ".eot" => ContentType.Eot, ".gz" => ContentType.Gz, ".tar" => ContentType.Tar, ".7z" => ContentType.SevenZip, + ".exe" => ContentType.Exe, _ => ContentType.Unknown, }; } @@ -328,6 +342,7 @@ public static ContentType ToContentTypeFromMime(string mimeType) "font/woff2" => ContentType.Woff2, "font/ttf" => ContentType.Ttf, "font/otf" => ContentType.Otf, + "application/vnd.ms-fontobject" => ContentType.Eot, "application/gzip" => ContentType.Gz, "application/x-tar" => ContentType.Tar, "application/x-7z-compressed" => ContentType.SevenZip, @@ -383,9 +398,11 @@ public static string GetMimeType(this ContentType extension) ContentType.Woff2 => "font/woff2", ContentType.Ttf => "font/ttf", ContentType.Otf => "font/otf", + ContentType.Eot => "application/vnd.ms-fontobject", ContentType.Gz => "application/gzip", ContentType.Tar => "application/x-tar", ContentType.SevenZip => "application/x-7z-compressed", + ContentType.Exe => "application/octet-stream", _ => "application/octet-stream", }; } @@ -439,9 +456,11 @@ public static string GetFilePattern(this ContentType extension) ContentType.Woff2 => "*.woff2", ContentType.Ttf => "*.ttf", ContentType.Otf => "*.otf", + ContentType.Eot => "*.eot", ContentType.Gz => "*.gz", ContentType.Tar => "*.tar", ContentType.SevenZip => "*.7z", + ContentType.Exe => "*.exe", _ => "*.*", }; } diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs index 854d2ebf..f80cb9b6 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs @@ -73,12 +73,18 @@ public class RequestHeaderFields public IEnumerable Cookies { get; } = []; /// - /// Gets the referer. The referer header echoes the absolute or partial address from - /// which a resource was requested. The Referer header allows a server to identify referring + /// Gets the referer. The referer header echoes the absolute or partial address from + /// which a resource was requested. The Referer header allows a server to identify referring /// pages from which people visit or where requested resources are used. /// public string Referer { get; private set; } + /// + /// Gets the If-None-Match header value. It carries the entity tag (ETag) the client already + /// holds and is used for conditional requests so unchanged resources can be answered with 304. + /// + public string IfNoneMatch { get; private set; } + /// /// Gets the upgrade header (e.g. "websocket" for WebSocket upgrades). /// @@ -118,6 +124,7 @@ internal RequestHeaderFields(IFeatureCollection contextFeatures) AcceptLanguage = requestFeature.Headers.AcceptLanguage.SelectMany(x => x.Split(';', StringSplitOptions.RemoveEmptyEntries)); UserAgent = requestFeature.Headers.UserAgent; Referer = requestFeature.Headers.Referer; + IfNoneMatch = requestFeature.Headers.IfNoneMatch; Upgrade = requestFeature.Headers.Upgrade; SecWebSocketKey = requestFeature.Headers.SecWebSocketKey; SecWebSocketProtocol = requestFeature.Headers.SecWebSocketProtocol; diff --git a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs index bdb03d9f..b5a93abd 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceAsset.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceAsset.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; @@ -43,8 +44,7 @@ public override IResponse Process(IRequest request) lock (Gard) { var assembly = ResourceContext.PluginContext.Assembly; - var buf = assembly.GetManifestResourceNames().ToList(); - var resources = assembly.GetManifestResourceNames().Where(x => x.StartsWith(AssetDirectory, System.StringComparison.OrdinalIgnoreCase)); + var resources = assembly.GetManifestResourceNames().Where(x => x.StartsWith(AssetDirectory, StringComparison.OrdinalIgnoreCase)); var url = request.Uri.ToString(); var fileName = Path.GetFileName(url); var file = string.Join('.', AssetDirectory.Trim('.'), "assets", url.Replace("/", ".").Trim('.')); @@ -56,96 +56,22 @@ public override IResponse Process(IRequest request) return new ResponseNotFound(); } + // conditional request: serve 304 when the client already holds the current version + var eTag = ComputeETag(Data); + if (IsNotModified(request, eTag)) + { + return CreateNotModifiedResponse(eTag); + } + var response = base.Process(request); response.Header.CacheControl = "public, max-age=31536000"; - var extension = Path.GetExtension(fileName); - extension = !string.IsNullOrWhiteSpace(extension) ? extension.ToLower() : ""; + // content type and download handling are resolved through the shared logic + ApplyContentType(response, fileName); - switch (extension) + if (!string.IsNullOrEmpty(eTag)) { - case ".pdf": - response.Header.ContentType = "application/pdf"; - break; - case ".txt": - response.Header.ContentType = "text/plain"; - break; - case ".css": - response.Header.ContentType = "text/css"; - break; - case ".js": - case ".mjs": - response.Header.ContentType = "application/javascript"; - break; - case ".json": - response.Header.ContentType = "application/json"; - break; - case ".map": - response.Header.ContentType = "application/json"; - break; - case ".xml": - response.Header.ContentType = "text/xml"; - break; - case ".woff": - response.Header.ContentType = "font/woff"; - break; - case ".woff2": - response.Header.ContentType = "font/woff2"; - break; - case ".ttf": - response.Header.ContentType = "font/ttf"; - break; - case ".eot": - response.Header.ContentType = "application/vnd.ms-fontobject"; - break; - case ".webp": - response.Header.ContentType = "image/webp"; - break; - case ".mp3": - response.Header.ContentType = "audio/mpeg"; - break; - case ".mp4": - response.Header.ContentType = "video/mp4"; - break; - case ".html": - case ".htm": - response.Header.ContentType = "text/html"; - break; - case ".exe": - response.Header.ContentDisposition = "attatchment; filename=" + fileName + "; size=" + Data.LongLength; - response.Header.ContentType = "application/octet-stream"; - break; - case ".zip": - response.Header.ContentDisposition = "attatchment; filename=" + fileName + "; size=" + Data.LongLength; - response.Header.ContentType = "application/zip"; - break; - case ".doc": - case ".docx": - response.Header.ContentType = "application/msword"; - break; - case ".xls": - case ".xlx": - response.Header.ContentType = "application/vnd.ms-excel"; - break; - case ".ppt": - response.Header.ContentType = "application/vnd.ms-powerpoint"; - break; - case ".gif": - response.Header.ContentType = "image/gif"; - break; - case ".png": - response.Header.ContentType = "image/png"; - break; - case ".svg": - response.Header.ContentType = "image/svg+xml"; - break; - case ".jpeg": - case ".jpg": - response.Header.ContentType = "image/jpg"; - break; - case ".ico": - response.Header.ContentType = "image/x-icon"; - break; + response.Header.AddCustomHeader("ETag", eTag); } request.HttpServerContext.Log?.Debug(I18N.Translate @@ -167,7 +93,7 @@ public override IResponse Process(IRequest request) /// A byte array containing the resource data, or null if the resource is not found. private static byte[] GetData(string file, Assembly assembly, IEnumerable resources) { - var item = resources.Where(x => x.Equals(file, System.StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + var item = resources.Where(x => x.Equals(file, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); if (item is null) { return null; @@ -188,4 +114,4 @@ public override void Dispose() } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs index fbac5750..f422b1a3 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs @@ -1,4 +1,7 @@ -using WebExpress.WebCore.WebMessage; +using System; +using System.IO; +using System.Security.Cryptography; +using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebResource { @@ -30,11 +33,89 @@ public override IResponse Process(IRequest request) { var response = new ResponseOK(); response.Header.ContentLength = Data is not null ? Data.Length : 0; - response.Header.ContentType = "binary/octet-stream"; + response.Header.ContentType = ContentType.Unknown.GetMimeType(); response.Content = Data; return response; } + + /// + /// Applies the content type for the given file to the response. The mapping is resolved through + /// the shared so every resource uses the same single source + /// of truth. Archives and executables are additionally offered as a download. + /// + /// The response to decorate. + /// The file name (or path) the content type is derived from. + protected void ApplyContentType(IResponse response, string fileName) + { + var contentType = ContentTypeExtensions.ToContentType(Path.GetExtension(fileName)); + response.Header.ContentType = contentType.GetMimeType(); + + if (contentType == ContentType.Zip || contentType == ContentType.Exe) + { + response.Header.ContentDisposition = $"attachment; filename={Path.GetFileName(fileName)}; size={(Data?.LongLength ?? 0)}"; + } + } + + /// + /// Computes a content-based entity tag (ETag) for conditional requests. + /// + /// The payload. + /// A quoted ETag value, or null when there is no payload. + protected static string ComputeETag(byte[] data) + { + if (data is null || data.Length == 0) + { + return null; + } + + // a content hash is used so the tag changes if and only if the payload changes; SHA-1 is + // chosen for speed because the value is a cache validator, not a security token. + return "\"" + Convert.ToHexString(SHA1.HashData(data)).ToLowerInvariant() + "\""; + } + + /// + /// Computes an entity tag (ETag) from file metadata. This avoids reading the file content, + /// so a conditional request can be answered without touching the disk. + /// + /// The file to derive the tag from. + /// A quoted ETag value, or null when the file does not exist. + protected static string ComputeETag(FileInfo info) + { + if (info is null || !info.Exists) + { + return null; + } + + // length and last-write time change together with the content for practical purposes + return "\"" + info.Length.ToString("x") + "-" + info.LastWriteTimeUtc.Ticks.ToString("x") + "\""; + } + + /// + /// Determines whether the request already holds the current version of the resource and can be + /// answered with 304 Not Modified. + /// + /// The request. + /// The current entity tag of the resource. + /// True when the client's If-None-Match matches the given tag; otherwise false. + protected static bool IsNotModified(IRequest request, string eTag) + { + return !string.IsNullOrEmpty(eTag) && string.Equals(request?.Header?.IfNoneMatch, eTag, StringComparison.Ordinal); + } + + /// + /// Builds a 304 Not Modified response that re-advertises the cache headers. + /// + /// The current entity tag of the resource. + /// The 304 response. + protected static IResponse CreateNotModifiedResponse(string eTag) + { + var response = new ResponseNotModified(); + response.Header.CacheControl = "public, max-age=31536000"; + response.Header.AddCustomHeader("ETag", eTag); + + return response; + } } } diff --git a/src/WebExpress.WebCore/WebResource/ResourceFile.cs b/src/WebExpress.WebCore/WebResource/ResourceFile.cs index 4e84b2ef..c16958ba 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceFile.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceFile.cs @@ -1,4 +1,5 @@ -using WebExpress.WebCore.Internationalization; +using System.IO; +using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebMessage; namespace WebExpress.WebCore.WebResource @@ -52,74 +53,32 @@ routePrefix is null || ? string.Empty : requestUri[routePrefix.Length..]; - var path = System.IO.Path.GetFullPath(RootDirectory + url); + var path = Path.GetFullPath(RootDirectory + url); - if (!System.IO.File.Exists(path)) + if (!File.Exists(path)) { return new ResponseNotFound(); } - Data = System.IO.File.ReadAllBytes(path); + // derive the ETag from file metadata so an unchanged file can be answered with 304 + // without reading its content from disk. + var eTag = ComputeETag(new FileInfo(path)); + if (IsNotModified(request, eTag)) + { + return CreateNotModifiedResponse(eTag); + } + + Data = File.ReadAllBytes(path); var response = base.Process(request); response.Header.CacheControl = "public, max-age=31536000"; - var extension = System.IO.Path.GetExtension(path); - extension = !string.IsNullOrWhiteSpace(extension) ? extension.ToLower() : ""; + // content type and download handling are resolved through the shared logic + ApplyContentType(response, path); - switch (extension) + if (!string.IsNullOrEmpty(eTag)) { - case ".pdf": - response.Header.ContentType = "application/pdf"; - break; - case ".txt": - response.Header.ContentType = "text/plain"; - break; - case ".css": - response.Header.ContentType = "text/css"; - break; - case ".xml": - response.Header.ContentType = "text/xml"; - break; - case ".html": - case ".htm": - response.Header.ContentType = "text/html"; - break; - case ".exe": - response.Header.ContentDisposition = "attatchment; filename=" + System.IO.Path.GetFileName(path) + "; size=" + Data.LongLength; - response.Header.ContentType = "application/octet-stream"; - break; - case ".zip": - response.Header.ContentDisposition = "attatchment; filename=" + System.IO.Path.GetFileName(path) + "; size=" + Data.LongLength; - response.Header.ContentType = "application/zip"; - break; - case ".doc": - case ".docx": - response.Header.ContentType = "application/msword"; - break; - case ".xls": - case ".xlx": - response.Header.ContentType = "application/vnd.ms-excel"; - break; - case ".ppt": - response.Header.ContentType = "application/vnd.ms-powerpoint"; - break; - case ".gif": - response.Header.ContentType = "image/gif"; - break; - case ".png": - response.Header.ContentType = "image/png"; - break; - case ".svg": - response.Header.ContentType = "image/svg+xml"; - break; - case ".jpeg": - case ".jpg": - response.Header.ContentType = "image/jpg"; - break; - case ".ico": - response.Header.ContentType = "image/x-icon"; - break; + response.Header.AddCustomHeader("ETag", eTag); } request.HttpServerContext.Log?.Debug(I18N.Translate("webexpress.webcore:resource.file", request.RemoteEndPoint, request.Uri)); From f9d5584c5fdcb19991145ed6ac1cf37071359039 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 22 Jun 2026 06:33:51 +0200 Subject: [PATCH 11/69] feat: uri improvements and minor bugs --- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 141 ++++++++---------- .../WebUri/UriPathSegmentVariable.cs | 33 +++- .../WebUri/UriPathSegmentVariableDouble.cs | 2 +- .../WebUri/UriPathSegmentVariableGuid.cs | 5 +- .../WebUri/UriPathSegmentVariableInt.cs | 2 +- .../WebUri/UriPathSegmentVariableUInt.cs | 2 +- src/WebExpress.WebCore/WebUri/UriScheme.cs | 33 +++- 7 files changed, 125 insertions(+), 93 deletions(-) diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index 9c781624..f015aa55 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -34,6 +34,12 @@ public partial class UriEndpoint : IUri [GeneratedRegex(@"^(\/([a-zA-Z0-9-+*%()=._/$]*))?(\?([a-zA-Z0-9-+*%()=._/$&]*))?(#([a-zA-Z0-9-+*%()=._/$]*))?$")] private static partial Regex RelativeUriRegex(); + /// + /// Cached scheme tokens used to cheaply pre-filter whether a string looks like an + /// absolute uri before running the more expensive absolute-uri regular expression. + /// + private static readonly string[] SchemeNames = Enum.GetNames(); + /// /// The scheme (e.g. Http, FTP). /// @@ -96,12 +102,10 @@ public IDictionary Parameters foreach (var path in PathSegments) { - if (path is IUriPathSegmentVariable variable) + // only variable segments contribute parameters; a null/blank name cannot be a dictionary key + if (path is IUriPathSegmentVariable variable && !string.IsNullOrEmpty(variable.VariableName)) { - if (!dic.ContainsKey(variable.VariableName?.ToLower())) - { - dic.Add(variable.VariableName?.ToLower(), variable.Value); - } + dic.TryAdd(variable.VariableName.ToLowerInvariant(), variable.Value); } } @@ -141,7 +145,7 @@ public UriEndpoint(string uri) return; } - if (Enum.GetNames().Any(x => uri.StartsWith(x, StringComparison.OrdinalIgnoreCase))) + if (SchemeNames.Any(x => uri.StartsWith(x, StringComparison.OrdinalIgnoreCase))) { var match = UriRegex().Match(uri); @@ -158,25 +162,32 @@ public UriEndpoint(string uri) { User = match.Groups[2].Success ? match.Groups[2].Value : null, Host = match.Groups[3].Success ? match.Groups[3].Value : null, - Port = match.Groups[4].Success ? Convert.ToInt32(match.Groups[4].Value) : null + // an empty port group (e.g. "host:/") must not throw a format exception + Port = int.TryParse(match.Groups[4].Value, out var port) ? port : null }; uri = match.Groups[5].Value; - } var relativeMatch = RelativeUriRegex().Match(uri); + var segments = new List(PathSegments); foreach (var p in relativeMatch.Groups[2].Value.Split('/', StringSplitOptions.RemoveEmptyEntries)) { - PathSegments = PathSegments.Concat([new UriPathSegmentConstant(p)]); + segments.Add(new UriPathSegmentConstant(p)); } + PathSegments = segments; - foreach (var q in relativeMatch.Groups[4].Success ? relativeMatch.Groups[4].Value?.Split('&') : []) + if (relativeMatch.Groups[4].Success) { - var item = q.Split('='); - - Query = Query.Concat([new UriQuery(item[0], item.Length > 1 ? item[1] : null)]); + var query = new List(); + foreach (var q in relativeMatch.Groups[4].Value.Split('&')) + { + // split on the first '=' only so values may legitimately contain '=' + var item = q.Split('=', 2); + query.Add(new UriQuery(item[0], item.Length > 1 ? item[1] : null)); + } + Query = query; } Fragment = relativeMatch.Groups[6].Success ? relativeMatch.Groups[6].Value : null; @@ -190,8 +201,8 @@ public UriEndpoint(IUri uri) { Scheme = uri?.Scheme ?? UriScheme.Http; Authority = uri?.Authority; - PathSegments = uri?.PathSegments.Select(x => x.Copy()) ?? []; - Query = uri?.Query.Select(x => new UriQuery(x.Key, x.Value)) ?? []; + PathSegments = [.. uri?.PathSegments.Select(x => x.Copy()) ?? []]; + Query = [.. uri?.Query.Select(x => new UriQuery(x.Key, x.Value)) ?? []]; Fragment = uri?.Fragment; } @@ -201,12 +212,8 @@ public UriEndpoint(IUri uri) /// The path segments. public UriEndpoint(params IUriPathSegment[] segments) { - PathSegments = PathSegments.Concat([new UriPathSegmentRoot()]); - - foreach (var segment in segments.Where(x => x is not UriPathSegmentRoot)) - { - PathSegments = PathSegments.Concat([segment]); - } + // PathSegments is already seeded with the single root; only non-root segments are appended + PathSegments = [.. PathSegments.Concat(segments?.Where(x => x is not UriPathSegmentRoot) ?? [])]; } /// @@ -244,8 +251,8 @@ public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IE { Scheme = scheme; Authority = authority; - PathSegments = PathSegments.Concat(segments?.Where(x => x is not UriPathSegmentRoot).Select(x => x.Copy()) ?? []); - Query = query.Select(x => new UriQuery(x.Key, x.Value)); + PathSegments = [.. PathSegments.Concat(segments?.Where(x => x is not UriPathSegmentRoot).Select(x => x.Copy()) ?? [])]; + Query = [.. query?.Select(x => new UriQuery(x.Key, x.Value)) ?? []]; Fragment = fragment; } @@ -259,7 +266,7 @@ public UriEndpoint(UriScheme scheme, UriAuthority authority, string fragment, IE /// The current instance for method chaining. public virtual IUri Add(params IUriQuery[] query) { - Query = Query.Concat(query.Where(x => x is not null)); + Query = [.. Query.Concat(query?.Where(x => x is not null) ?? [])]; return this; } @@ -277,9 +284,9 @@ public virtual IUri Concat(string segment) } var copy = new UriEndpoint((IUri)this); - copy.PathSegments = copy.PathSegments + copy.PathSegments = [.. copy.PathSegments .Concat(segment.Split('/', StringSplitOptions.RemoveEmptyEntries) - .Select(x => new UriPathSegmentConstant(x))); + .Select(x => new UriPathSegmentConstant(x)))]; return copy; } @@ -296,10 +303,10 @@ public virtual IUri Concat(params IUriPathSegment[] segments) return this; } + // the copy constructor already deep-copies the existing segments, so they are not re-copied here var copy = new UriEndpoint((IUri)this); - copy.PathSegments = copy.PathSegments - .Select(x => x.Copy()) - .Concat(segments.Where(x => x is not null).Where(x => !x.IsEmpty)); + copy.PathSegments = [.. copy.PathSegments + .Concat(segments.Where(x => x is not null && !x.IsEmpty))]; return copy; } @@ -318,7 +325,7 @@ public virtual IUri Concat(params IUriPathSegment[] segments) public IUri Concat(params IUriQuery[] query) { var copy = new UriEndpoint((IUri)this); - copy.Query = copy.Query.Concat(query.Where(x => x is not null)); + copy.Query = [.. copy.Query.Concat(query?.Where(x => x is not null) ?? [])]; return copy; } @@ -333,21 +340,21 @@ public IUri Concat(params IUriQuery[] query) /// The sub uri with the specified number of elements. public virtual IUri Take(int count) { - var copy = new UriEndpoint((IUri)this); - var path = copy.PathSegments.ToList(); - copy.PathSegments = []; - if (count == 0) { return new UriEndpoint(); } - else if (count > 0) + + var copy = new UriEndpoint((IUri)this); + var path = copy.PathSegments.ToList(); + + if (count > 0) { - copy.PathSegments = copy.PathSegments.Concat(path.Take(count)); + copy.PathSegments = [.. path.Take(count)]; } - else if (count < 0 && Math.Abs(count) < path.Count) + else if (Math.Abs(count) < path.Count) { - copy.PathSegments = copy.PathSegments.Concat(path.Take(path.Count + count)); + copy.PathSegments = [.. path.Take(path.Count + count)]; } else { @@ -410,17 +417,14 @@ public IUri Skip(int count) { return null; } + + var copy = new UriEndpoint((IUri)this); if (count > 0) { - var copy = new UriEndpoint((IUri)this); - var path = copy.PathSegments.ToList(); - copy.PathSegments = []; - copy.PathSegments = copy.PathSegments.Concat(path.Skip(count)); - - return copy; + copy.PathSegments = [.. copy.PathSegments.Skip(count)]; } - return new UriEndpoint((IUri)this); + return copy; } /// @@ -501,7 +505,7 @@ public virtual IUri BindParameters(params IParameter[] parameters) var boundQuery = new List(); foreach (var query in Query) { - var parameter = parameters + var parameter = (parameters ?? []) .Select(x => { var key = x switch @@ -512,21 +516,15 @@ public virtual IUri BindParameters(params IParameter[] parameters) }; return (key, x.Value); }) - .FirstOrDefault(x => x.key.Equals(query?.Key, StringComparison.InvariantCultureIgnoreCase)); + .FirstOrDefault(x => string.Equals(x.key, query?.Key, StringComparison.InvariantCultureIgnoreCase)); - if (!string.IsNullOrWhiteSpace(parameter.key)) - { - // if parameter found for query key, set value accordingly - boundQuery.Add(new UriQuery(parameter.key, parameter.Value)); - } - else - { - // otherwise keep unchanged - boundQuery.Add(new UriQuery(query.Key, query.Value)); - } + // keep the uri's declared query key; only its value is replaced when a parameter matches + boundQuery.Add(string.IsNullOrWhiteSpace(parameter.key) + ? new UriQuery(query.Key, query.Value) + : new UriQuery(query.Key, parameter.Value)); } - return new UriEndpoint(this, pathSegments) + return new UriEndpoint(this, [.. pathSegments]) { Query = boundQuery }; @@ -569,10 +567,10 @@ public static IUri Combine(params string[] uris) { var copy = new UriEndpoint(); - copy.PathSegments = copy.PathSegments + copy.PathSegments = [.. copy.PathSegments .Concat(uris.Where(x => !string.IsNullOrWhiteSpace(x)) .SelectMany(x => x.Split('/', StringSplitOptions.RemoveEmptyEntries)) - .Select(x => new UriPathSegmentConstant(x) as IUriPathSegment)); + .Select(x => new UriPathSegmentConstant(x) as IUriPathSegment))]; return copy; } @@ -585,8 +583,8 @@ public static IUri Combine(params string[] uris) public static IUri Combine(params IUri[] uris) { var copy = new UriEndpoint(uris.FirstOrDefault()); - copy.PathSegments = copy.PathSegments - .Concat(uris.Skip(1).SelectMany(x => x.PathSegments.Skip(1))); + copy.PathSegments = [.. copy.PathSegments + .Concat(uris.Skip(1).Where(x => x is not null).SelectMany(x => x.PathSegments.Skip(1)))]; return copy; } @@ -600,10 +598,10 @@ public static IUri Combine(params IUri[] uris) public static IUri Combine(IUri uri, params string[] uris) { var copy = new UriEndpoint(uri); - copy.PathSegments = copy.PathSegments + copy.PathSegments = [.. copy.PathSegments .Concat(uris.Where(x => !string.IsNullOrWhiteSpace(x)) .SelectMany(x => x.Split('/', StringSplitOptions.RemoveEmptyEntries)) - .Select(x => new UriPathSegmentConstant(x) as IUriPathSegment)); + .Select(x => new UriPathSegmentConstant(x) as IUriPathSegment))]; return copy; } @@ -675,19 +673,8 @@ public virtual IIcon GetIcon(IRenderContext renderContext) /// A string that represents the current uri. public override string ToString() { - var defaultPort = Scheme switch - { - UriScheme.Http => 80, - UriScheme.Https => 443, - UriScheme.FTP => 21, - UriScheme.Ldap => 389, - UriScheme.Ldaps => 636, - _ => -1 - - }; - - var scheme = Scheme.ToString("g").ToLower() + ":"; - var authority = Authority?.ToString(defaultPort); + var scheme = Scheme.ToSchemeString() + ":"; + var authority = Authority?.ToString(Scheme.DefaultPort()); var uri = "/" + string.Join ( "/", diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index b32cc6bc..f453661a 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Text.RegularExpressions; using WebExpress.WebCore.WebIcon; @@ -7,6 +8,26 @@ namespace WebExpress.WebCore.WebUri { + /// + /// Caches compiled regular expressions keyed by their pattern so that route matching does not + /// recompile the same expression on every request. Compilation is comparatively expensive, while + /// matching against an already compiled instance is fast, which matters on the request hot path. + /// + internal static class UriPathSegmentRegexCache + { + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Returns a compiled regular expression for the given pattern, reusing a cached instance when available. + /// + /// The regular expression pattern. + /// A compiled, case-insensitive regular expression. + public static Regex Get(string pattern) + { + return _cache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase | RegexOptions.Compiled)); + } + } + /// /// Variable path segment. /// @@ -86,16 +107,14 @@ public virtual bool IsMatched(string value) { return false; } - else if (string.IsNullOrWhiteSpace(Expression) && Value.Equals(value, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - else if (Regex.IsMatch(value, Expression, RegexOptions.IgnoreCase)) + + // without a constraint expression the segment can only be matched by a literal value + if (string.IsNullOrEmpty(Expression)) { - return true; + return Value is not null && Value.Equals(value, StringComparison.OrdinalIgnoreCase); } - return false; + return UriPathSegmentRegexCache.Get(Expression).IsMatch(value); } /// diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs index 7d4f32db..109dc318 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs @@ -17,7 +17,7 @@ public class UriPathSegmentVariableDouble : UriPathSegmentVariableThe variable value pair. public override IDictionary GetVariable(string value) { - var match = Regex.Match(value, Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); + var match = UriPathSegmentRegexCache.Get(Expression).Match(value); if (match.Success) { @@ -107,7 +106,7 @@ public override string GetDisplayText(IRenderContext renderContext) return base.GetDisplayText(renderContext); } - var match = Regex.Match(Value, Expression, RegexOptions.IgnoreCase | RegexOptions.Compiled); + var match = UriPathSegmentRegexCache.Get(Expression).Match(Value); var guid = DisplayFormat == Format.Simple ? match.Groups[7].ToString() : match.Groups[2].ToString(); if (string.IsNullOrWhiteSpace(Value) || !Value.Contains("{0}")) diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs index f44bed34..51439d70 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs @@ -17,7 +17,7 @@ public class UriPathSegmentVariableInt : UriPathSegmentVariable : UriPathSegmentVariable - /// Converts the to its string representation. + /// Converts the to its canonical lowercase string representation. /// + /// + /// This is intentionally not named ToString: an extension method cannot override the + /// enum's built-in , so such a method would never be invoked + /// through normal call syntax and would silently be dead code. + /// /// The URI scheme to convert. - /// The string representation of the URI scheme. - public static string ToString(this UriScheme scheme) + /// The canonical scheme token (e.g. "https"). + public static string ToSchemeString(this UriScheme scheme) { return scheme switch { @@ -78,5 +83,27 @@ public static string ToString(this UriScheme scheme) _ => "http" }; } + + /// + /// Returns the well-known default port for the scheme. + /// The default port is omitted from the rendered authority so that, for example, + /// "https://example.com:443" collapses to "https://example.com". + /// + /// The URI scheme. + /// The default port, or -1 for schemes that do not carry a port. + public static int DefaultPort(this UriScheme scheme) + { + return scheme switch + { + UriScheme.FTP => 21, + UriScheme.Http => 80, + UriScheme.Https => 443, + UriScheme.Ldap => 389, + UriScheme.Ldaps => 636, + UriScheme.Ws => 80, + UriScheme.Wss => 443, + _ => -1 + }; + } } } \ No newline at end of file From 0cd1131a5fa2bc951c38361cfd2762eef948cdbd Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 22 Jun 2026 14:21:40 +0200 Subject: [PATCH 12/69] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebMessage/Request.cs | 122 ++++++++++--------- 1 file changed, 66 insertions(+), 56 deletions(-) diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index 81cf955f..a825cd14 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -21,6 +21,11 @@ public partial class Request : RequestBase [GeneratedRegex(@"Content-Type:\s*(.*)", RegexOptions.IgnoreCase, "de-DE")] private static partial Regex ContentRegex(); + // matching whole tokens (name|filename) prevents the "name" parameter from being + // confused with the trailing "name" inside "filename" regardless of their order. + [GeneratedRegex(@"(?:^|[;\s])(name|filename)\s*=\s*""([^""]*)""", RegexOptions.IgnoreCase)] + private static partial Regex DispositionParamRegex(); + /// /// Gets the content. /// @@ -47,18 +52,34 @@ internal Request(IFeatureCollection contextFeatures, RequestHeaderFields header, /// /// The content of a request. /// The number of bytes sent in the body or zero. - /// Der Content als Byte-Array + /// The content as a byte array, or null if the body is empty. internal static byte[] GetContent(Stream body, long? contentLength) { - if (!contentLength.HasValue || contentLength.Value == 0) + if (!contentLength.HasValue || contentLength.Value <= 0) { return null; } - using var ms = new MemoryStream(); - body.CopyTo(ms); + // the announced length lets us allocate the exact buffer once instead of + // growing a MemoryStream and copying it out again. a byte[] cannot exceed + // int.MaxValue, which matches the previous MemoryStream.ToArray() limit. + var length = (int)Math.Min(contentLength.Value, int.MaxValue); + var buffer = new byte[length]; + + var offset = 0; + int read; + while (offset < length && (read = body.Read(buffer, offset, length - offset)) > 0) + { + offset += read; + } + + if (offset == 0) + { + return null; + } - return ms.ToArray(); + // the client announced more bytes than it actually sent; trim to what arrived + return offset == length ? buffer : buffer[..offset]; } /// @@ -71,19 +92,10 @@ protected virtual void ParseRequestParams() return; } - // normalize content-type - var ct = Header.ContentType.Split(';') - .Select(x => x.Trim()) - .ToArray(); - - var mainType = ct.FirstOrDefault()?.ToLowerInvariant(); - var enctype = TypeEnctypeExtensions.Convert(mainType); - - // detect multipart/form-data even if Convert() fails - if (mainType.StartsWith("multipart/form-data")) - { - enctype = TypeEnctype.Multipart; - } + // normalize content-type; the first segment is the media type, the + // remaining segments carry parameters such as the multipart boundary. + var ct = Header.ContentType.Split(';', StringSplitOptions.TrimEntries); + var enctype = TypeEnctypeExtensions.Convert(ct[0].ToLowerInvariant()); switch (enctype) { @@ -155,9 +167,21 @@ private void ParseMultipart(string[] contentTypeParts) var headerText = Encoding.UTF8.GetString(Content, headerStart, headerEnd - headerStart); - // parse headers - var name = ExtractHeaderValue(headerText, "name"); - var filename = ExtractHeaderValue(headerText, "filename"); + // parse the content-disposition parameters in a single pass + var name = string.Empty; + var filename = string.Empty; + foreach (Match dispo in DispositionParamRegex().Matches(headerText)) + { + if (string.Equals(dispo.Groups[1].Value, "filename", StringComparison.OrdinalIgnoreCase)) + { + filename = dispo.Groups[2].Value; + } + else + { + name = dispo.Groups[2].Value; + } + } + var contentType = ExtractContentType(headerText); // content start @@ -245,9 +269,11 @@ private void ParseUrlEncoded() var text = Encoding.UTF8.GetString(Content); foreach (var pair in text.Split('&')) { - var parts = pair.Split('='); + // split into at most two parts so that values containing '=' stay intact; + // '+' decoding is handled by the Parameter constructor's UrlDecode. + var parts = pair.Split('=', 2); var key = parts[0]; - var value = parts.Length > 1 ? parts[1].Replace('+', ' ') : string.Empty; + var value = parts.Length > 1 ? parts[1] : string.Empty; AddParameter(new Parameter(key, value, ParameterScope.Parameter)); } @@ -292,24 +318,27 @@ private static int IndexOf(byte[] haystack, byte[] needle, int start) /// Finds the end of multipart headers and returns the separator length. /// Supports both CRLF and LF line endings. /// + /// + /// The headers end at the first blank line (two consecutive line breaks). Scanning + /// forward and stopping there avoids walking the entire body — which may be large and + /// binary — looking for a separator that only exists right after the part headers. + /// private static int FindHeaderEnd(byte[] content, int headerStart, out int separatorLength) { - var crlfSeparator = Encoding.UTF8.GetBytes("\r\n\r\n"); - var lfSeparator = Encoding.UTF8.GetBytes("\n\n"); - - var crlfEnd = IndexOf(content, crlfSeparator, headerStart); - var lfEnd = IndexOf(content, lfSeparator, headerStart); - - if (crlfEnd >= 0 && (lfEnd < 0 || crlfEnd <= lfEnd)) + for (int i = headerStart; i < content.Length; i++) { - separatorLength = crlfSeparator.Length; - return crlfEnd; - } + var firstBreak = GetLineBreakLength(content, i); + if (firstBreak == 0) + { + continue; + } - if (lfEnd >= 0) - { - separatorLength = lfSeparator.Length; - return lfEnd; + var secondBreak = GetLineBreakLength(content, i + firstBreak); + if (secondBreak > 0) + { + separatorLength = firstBreak + secondBreak; + return i; + } } separatorLength = 0; @@ -392,25 +421,6 @@ private static bool StartsWith(byte[] data, byte[] prefix, int offset) return true; } - /// - /// Extracts the value associated with the specified key from a header string formatted - /// as key-value pairs. - /// - /// - /// The header string containing key-value pairs, where values are enclosed in double quotes. - /// - /// - /// The key whose associated value is to be extracted from the header. The search is case-insensitive. - /// - /// - /// The value associated with the specified key if found; otherwise, an empty string. - /// - private static string ExtractHeaderValue(string header, string key) - { - var match = Regex.Match(header, key + "=\"([^\"]*)\"", RegexOptions.IgnoreCase); - return match.Success ? match.Groups[1].Value : string.Empty; - } - /// /// Extracts the value of the Content-Type header from the specified header string. /// From 82e81a10142f986490e8e4a889b6f91a83de6d4e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 22 Jun 2026 18:05:20 +0200 Subject: [PATCH 13/69] feat: doc improvements --- src/WebExpress.WebCore/HttpServerContext.cs | 4 +++- src/WebExpress.WebCore/IHost.cs | 4 +++- src/WebExpress.WebCore/IHttpServerContext.cs | 5 ++++- .../Internationalization/IInternationalizationManager.cs | 4 +++- .../Internationalization/InternationalizationManager.cs | 4 +++- .../Internationalization/Model/InternationalizationItem.cs | 4 ++++ src/WebExpress.WebCore/WebApplication/Application.cs | 4 +++- .../WebApplication/ApplicationContext.cs | 4 +++- src/WebExpress.WebCore/WebApplication/IApplication.cs | 4 +++- .../WebApplication/IApplicationContext.cs | 5 ++++- src/WebExpress.WebCore/WebAsset/AssetContext.cs | 3 ++- src/WebExpress.WebCore/WebAsset/IAsset.cs | 3 ++- .../WebAttribute/SegmentHiddenAttribute.cs | 3 ++- src/WebExpress.WebCore/WebAttribute/ThemeStyleAttribute.cs | 3 ++- src/WebExpress.WebCore/WebComponent/ComponentId.cs | 4 +++- src/WebExpress.WebCore/WebComponent/IComponent.cs | 5 ++++- src/WebExpress.WebCore/WebComponent/IComponentId.cs | 3 ++- src/WebExpress.WebCore/WebComponent/IComponentManager.cs | 5 ++++- .../WebComponent/IComponentManagerPlugin.cs | 4 +++- src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs | 5 ++++- src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs | 4 +++- src/WebExpress.WebCore/WebEvent/EventHandlerContext.cs | 3 ++- src/WebExpress.WebCore/WebEvent/EventManager.cs | 4 +++- src/WebExpress.WebCore/WebEvent/IEvent.cs | 4 +++- src/WebExpress.WebCore/WebEvent/IEventArgument.cs | 3 ++- src/WebExpress.WebCore/WebEvent/IEventHandler.cs | 7 +++++-- src/WebExpress.WebCore/WebEvent/IEventHandlerContext.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlComment.cs | 3 ++- .../WebHtml/HtmlElementEmbeddedPicture.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlElementFieldButton.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlElementFormFieldset.cs | 3 ++- .../WebHtml/HtmlElementInteractiveMenu.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs | 4 +++- .../WebHtml/HtmlElementSectionSection.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlElementTableCol.cs | 3 ++- .../WebHtml/HtmlElementTextContentBlockquote.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs | 2 +- .../WebHtml/HtmlElementWebComponentsSlot.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlEmpty.cs | 3 ++- src/WebExpress.WebCore/WebHtml/HtmlList.cs | 4 +++- src/WebExpress.WebCore/WebHtml/HtmlRaw.cs | 7 +++---- src/WebExpress.WebCore/WebHtml/HtmlText.cs | 3 ++- src/WebExpress.WebCore/WebHtml/IHtml.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElement.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElementInteractive.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElementMetadata.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElementRoot.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElementScripting.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlElementSection.cs | 4 +++- src/WebExpress.WebCore/WebHtml/IHtmlNode.cs | 4 +++- src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs | 3 ++- src/WebExpress.WebCore/WebIdentity/IIdentityPermission.cs | 4 +++- src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs | 4 +++- src/WebExpress.WebCore/WebIdentity/IIdentityResource.cs | 3 ++- src/WebExpress.WebCore/WebIdentity/Model/IdentityItem.cs | 3 ++- .../WebIdentity/Model/IdentityPermissionDictionary.cs | 4 +++- .../WebIdentity/Model/IdentityPolicyDictionary.cs | 4 +++- .../WebIdentity/Model/IdentityPolicyItem.cs | 4 +++- src/WebExpress.WebCore/WebJob/IJobContext.cs | 4 +++- src/WebExpress.WebCore/WebJob/JobContext.cs | 3 ++- src/WebExpress.WebCore/WebJob/JobManager.cs | 5 +++++ src/WebExpress.WebCore/WebLog/LogItem.cs | 3 ++- src/WebExpress.WebCore/WebMessage/HttpExceptionContext.cs | 5 ++++- src/WebExpress.WebCore/WebMessage/IResponse.cs | 3 ++- src/WebExpress.WebCore/WebMessage/Request.cs | 7 +++++-- src/WebExpress.WebCore/WebMessage/RequestBase.cs | 6 ++++-- src/WebExpress.WebCore/WebMessage/Response.cs | 6 +++++- src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs | 3 ++- src/WebExpress.WebCore/WebMessage/ResponseForbidden.cs | 3 ++- src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs | 4 +++- .../WebMessage/ResponseMovedPermanently.cs | 4 +++- .../WebMessage/ResponseMovedTemporarily.cs | 3 ++- src/WebExpress.WebCore/WebMessage/ResponseNotFound.cs | 3 ++- src/WebExpress.WebCore/WebMessage/ResponseOK.cs | 3 ++- .../WebMessage/ResponseUpgradeRequired.cs | 4 +++- src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs | 3 ++- .../WebPackage/Model/PackageCatalogItem.cs | 3 ++- .../WebPackage/Model/PackageCatalogeItemState.cs | 3 ++- src/WebExpress.WebCore/WebPage/IPage.cs | 3 ++- src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs | 4 +++- src/WebExpress.WebCore/WebPage/PageContext.cs | 3 ++- src/WebExpress.WebCore/WebPage/VisualTreeContext.cs | 3 ++- src/WebExpress.WebCore/WebPlugin/IPlugin.cs | 4 +++- src/WebExpress.WebCore/WebPlugin/IPluginContext.cs | 4 +++- src/WebExpress.WebCore/WebPlugin/IPluginManager.cs | 3 ++- src/WebExpress.WebCore/WebPlugin/Model/PluginItem.cs | 4 +++- .../WebPlugin/Model/PluginRuntimeState.cs | 3 ++- src/WebExpress.WebCore/WebPlugin/Plugin.cs | 4 +++- src/WebExpress.WebCore/WebPlugin/PluginManager.cs | 4 +++- src/WebExpress.WebCore/WebResource/IResource.cs | 4 +++- src/WebExpress.WebCore/WebResource/ResourceBinary.cs | 3 ++- src/WebExpress.WebCore/WebResource/ResourceContext.cs | 4 +++- src/WebExpress.WebCore/WebResource/ResourceFile.cs | 3 ++- src/WebExpress.WebCore/WebRestApi/IRestApi.cs | 3 ++- src/WebExpress.WebCore/WebRestApi/RestApiContext.cs | 4 +++- src/WebExpress.WebCore/WebScope/IScope.cs | 4 +++- src/WebExpress.WebCore/WebSection/ISection.cs | 4 +++- .../WebSession/Model/SessionPropertyParameter.cs | 4 +++- .../WebSettingPage/ISettingGroupContext.cs | 3 ++- src/WebExpress.WebCore/WebSettingPage/ISettingPage.cs | 3 ++- .../WebSettingPage/Model/SettingCategoryItem.cs | 3 ++- .../WebSettingPage/Model/SettingGroupItem.cs | 3 ++- .../WebSettingPage/Model/SettingPageItem.cs | 3 ++- src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs | 3 ++- src/WebExpress.WebCore/WebSitemap/Model/SitemapNode.cs | 4 +++- src/WebExpress.WebCore/WebStatusPage/IStatusPage.cs | 3 ++- src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs | 3 ++- .../WebStatusPage/Model/StatusPageItem.cs | 3 ++- src/WebExpress.WebCore/WebStatusPage/StatusMessage.cs | 3 ++- src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs | 3 ++- src/WebExpress.WebCore/WebTheme/Model/ThemeItem.cs | 3 ++- src/WebExpress.WebCore/WebTheme/ThemeMode.cs | 7 ++++--- src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs | 3 ++- src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs | 4 +++- src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs | 3 ++- src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs | 3 ++- src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs | 6 +++++- .../WebUri/UriPathSegmentVariableApiVersion.cs | 3 ++- .../WebUri/UriPathSegmentVariableDouble.cs | 3 ++- .../WebUri/UriPathSegmentVariableGuid.cs | 3 ++- src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs | 3 ++- .../WebUri/UriPathSegmentVariableRegex.cs | 4 +++- .../WebUri/UriPathSegmentVariableString.cs | 3 ++- src/WebExpress.WebCore/WebUri/UriScheme.cs | 3 ++- 124 files changed, 327 insertions(+), 130 deletions(-) diff --git a/src/WebExpress.WebCore/HttpServerContext.cs b/src/WebExpress.WebCore/HttpServerContext.cs index 7c5d1cae..c2c90159 100644 --- a/src/WebExpress.WebCore/HttpServerContext.cs +++ b/src/WebExpress.WebCore/HttpServerContext.cs @@ -8,7 +8,9 @@ namespace WebExpress.WebCore { /// - /// The context of the http server. + /// Default implementation of . It bundles the server-wide + /// information (routing, endpoints, version, directories, culture, log) that is created once + /// at start-up and handed to plugins and components throughout the server's lifetime. /// public class HttpServerContext : IHttpServerContext { diff --git a/src/WebExpress.WebCore/IHost.cs b/src/WebExpress.WebCore/IHost.cs index 06c0dda6..0b6a1c45 100644 --- a/src/WebExpress.WebCore/IHost.cs +++ b/src/WebExpress.WebCore/IHost.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore { /// - /// The host interface. + /// Represents the running web server that hosts WebExpress. It is the top-level entry point + /// that owns the server-wide context () and signals, via the + /// event, when the server is ready to accept requests. /// public interface IHost { diff --git a/src/WebExpress.WebCore/IHttpServerContext.cs b/src/WebExpress.WebCore/IHttpServerContext.cs index d6c5b1b1..edebbf77 100644 --- a/src/WebExpress.WebCore/IHttpServerContext.cs +++ b/src/WebExpress.WebCore/IHttpServerContext.cs @@ -7,7 +7,10 @@ namespace WebExpress.WebCore { /// - /// The context interface of the http server. + /// Provides the server-wide information that plugins and components need to do their work: + /// the routing entry point, the configured endpoints, the server version, the well-known + /// directories (packages, assets, data, configuration), the culture, and the central log. + /// A single instance is shared for the lifetime of the running server. /// public interface IHttpServerContext { diff --git a/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs b/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs index 21a1c3dc..a17e3e0f 100644 --- a/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs +++ b/src/WebExpress.WebCore/Internationalization/IInternationalizationManager.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.Internationalization { /// - /// The interface of the internationalization manager. + /// Contract for the translation registry. It looks up the text for a given key in a chosen (or + /// the default) language, so callers can display localized strings without knowing where the + /// translations come from. /// public interface IInternationalizationManager : IComponentManager { diff --git a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs index a9dc55ad..0a0b5fed 100644 --- a/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs +++ b/src/WebExpress.WebCore/Internationalization/InternationalizationManager.cs @@ -11,7 +11,9 @@ namespace WebExpress.WebCore.Internationalization { /// - /// Internationalization + /// Central registry for translations (internationalization, i18n). It collects the language + /// files a plugin ships, and returns the text for a given key in the requested language so the + /// UI can be shown in different languages. /// public sealed class InternationalizationManager : IInternationalizationManager, IComponentManagerPlugin, ISystemComponent { diff --git a/src/WebExpress.WebCore/Internationalization/Model/InternationalizationItem.cs b/src/WebExpress.WebCore/Internationalization/Model/InternationalizationItem.cs index 40ede17e..e6ee6387 100644 --- a/src/WebExpress.WebCore/Internationalization/Model/InternationalizationItem.cs +++ b/src/WebExpress.WebCore/Internationalization/Model/InternationalizationItem.cs @@ -2,6 +2,10 @@ namespace WebExpress.WebCore.Internationalization.Model { + /// + /// Holds the translations for a single language as a map from translation key to translated + /// text. The internationalization manager keeps one of these per culture to look up localized strings. + /// internal class InternationalizationItem : Dictionary { } diff --git a/src/WebExpress.WebCore/WebApplication/Application.cs b/src/WebExpress.WebCore/WebApplication/Application.cs index a482c963..b3ab3392 100644 --- a/src/WebExpress.WebCore/WebApplication/Application.cs +++ b/src/WebExpress.WebCore/WebApplication/Application.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebApplication { /// - /// This represents an application. + /// Base class for a WebExpress application. Derive from it to create an application; the + /// framework instantiates the subclass, supplies its (id, name, + /// paths, route), and calls Run at start-up. See . /// public abstract class Application : IApplication { diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs index 3cb876ed..5fda5430 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationContext.cs @@ -7,7 +7,9 @@ namespace WebExpress.WebCore.WebApplication { /// - /// Represents the context of an application. + /// Default implementation of : the read-only descriptor of a + /// registered application (id, name, paths, route, owning plugin, default theme) that the + /// framework shares with the application's components. /// public class ApplicationContext : IApplicationContext { diff --git a/src/WebExpress.WebCore/WebApplication/IApplication.cs b/src/WebExpress.WebCore/WebApplication/IApplication.cs index 4ea4ddcb..271cc5cd 100644 --- a/src/WebExpress.WebCore/WebApplication/IApplication.cs +++ b/src/WebExpress.WebCore/WebApplication/IApplication.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebApplication { /// - /// This interface represents an application. + /// A self-contained application hosted by WebExpress — the top-level unit a plugin provides, + /// under which pages, resources, and other components are grouped and mounted at a route. + /// The framework calls once when the application starts. /// public interface IApplication : IComponent { diff --git a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs index 9a5ae48e..fd3874af 100644 --- a/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs +++ b/src/WebExpress.WebCore/WebApplication/IApplicationContext.cs @@ -6,7 +6,10 @@ namespace WebExpress.WebCore.WebApplication { /// - /// The application context. + /// Read-only descriptor of a registered application that the framework hands to components so + /// they can learn about the application they belong to — its id, name, description, asset/data + /// paths, route, icon, owning plugin, and default theme — without referencing the application + /// object itself. /// public interface IApplicationContext : IContext { diff --git a/src/WebExpress.WebCore/WebAsset/AssetContext.cs b/src/WebExpress.WebCore/WebAsset/AssetContext.cs index 9be5a709..6dd8e201 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetContext.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetContext.cs @@ -10,7 +10,8 @@ namespace WebExpress.WebCore.WebAsset { /// - /// Represents the context of a asset. + /// Read-only descriptor of a registered asset endpoint, handed to components so they can learn + /// about the asset and the application and plugin it belongs to without referencing the asset itself. /// public class AssetContext : IAssetContext { diff --git a/src/WebExpress.WebCore/WebAsset/IAsset.cs b/src/WebExpress.WebCore/WebAsset/IAsset.cs index 8d4c2f8b..287a5d09 100644 --- a/src/WebExpress.WebCore/WebAsset/IAsset.cs +++ b/src/WebExpress.WebCore/WebAsset/IAsset.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebAsset { /// - /// Defines the contract for a asset component. + /// An endpoint that serves a static asset — a file such as an image, script, or stylesheet that + /// is delivered to the client largely as-is. Given a request it returns the asset as the response. /// public interface IAsset : IEndpoint { diff --git a/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs b/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs index 586845ea..d0e8239b 100644 --- a/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/SegmentHiddenAttribute.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// Indicates that a segment is hidden. + /// Applied to an endpoint class to mark its URI path segment as hidden, so it is left out of + /// generated navigation such as menus and breadcrumbs while the endpoint itself stays reachable. /// /// /// This attribute can be used to determine if the segment should not be displayed in user diff --git a/src/WebExpress.WebCore/WebAttribute/ThemeStyleAttribute.cs b/src/WebExpress.WebCore/WebAttribute/ThemeStyleAttribute.cs index 1235cb64..060f2434 100644 --- a/src/WebExpress.WebCore/WebAttribute/ThemeStyleAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/ThemeStyleAttribute.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebAttribute { /// - /// Specifies the style for a theme. + /// Applied to a theme class to declare the CSS stylesheet that gives the theme its look. The + /// constructor takes the URI of the stylesheet that should be loaded for the theme. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class ThemeStyleAttribute : Attribute, IThemeAttribute diff --git a/src/WebExpress.WebCore/WebComponent/ComponentId.cs b/src/WebExpress.WebCore/WebComponent/ComponentId.cs index 65fb013e..d904eb3b 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentId.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentId.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebComponent { /// - /// Represents a component identifier. + /// The identifier of a component, stored in a normalized (lower-case) form so lookups are + /// case-insensitive. Implicit conversions to and from let an id be used + /// wherever a plain string is convenient. /// public class ComponentId : IComponentId { diff --git a/src/WebExpress.WebCore/WebComponent/IComponent.cs b/src/WebExpress.WebCore/WebComponent/IComponent.cs index 7954ea8f..c6c2be92 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponent.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponent.cs @@ -1,7 +1,10 @@ namespace WebExpress.WebCore.WebComponent { /// - /// Interface of a component. + /// Common base type for the building blocks a plugin contributes to WebExpress — applications, + /// pages, resources, REST APIs, event handlers, identity policies, and so on. The framework + /// discovers component types, creates them, and manages their lifetime; this interface is the + /// shared marker that lets them be handled uniformly. /// public interface IComponent { diff --git a/src/WebExpress.WebCore/WebComponent/IComponentId.cs b/src/WebExpress.WebCore/WebComponent/IComponentId.cs index e4229d02..88106410 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentId.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentId.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebComponent { /// - /// Represents an interface for component identifiers. + /// Contract for a component identifier — the value that uniquely names a component so it can be + /// referenced and looked up. See for the standard implementation. /// public interface IComponentId { diff --git a/src/WebExpress.WebCore/WebComponent/IComponentManager.cs b/src/WebExpress.WebCore/WebComponent/IComponentManager.cs index e4555e51..e6ee2d3d 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentManager.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentManager.cs @@ -3,7 +3,10 @@ namespace WebExpress.WebCore.WebComponent { /// - /// Interface of the manager classes. + /// Common base type for the framework's managers. A manager is a central registry that keeps + /// track of one kind of component (pages, resources, events, …) for the whole server and gives + /// other parts of the system access to them. This base contract mainly ties their lifetime to + /// . /// public interface IComponentManager : IDisposable { diff --git a/src/WebExpress.WebCore/WebComponent/IComponentManagerPlugin.cs b/src/WebExpress.WebCore/WebComponent/IComponentManagerPlugin.cs index 93664046..6574c5e5 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentManagerPlugin.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentManagerPlugin.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebComponent { /// - /// Interface of the manager classes. + /// A component manager that fills itself from plugins: when a plugin is loaded it discovers and + /// registers the relevant entries, and when a plugin is unloaded it removes them again. Most of + /// the framework's managers implement this so their content follows the set of active plugins. /// public interface IComponentManagerPlugin : IComponentManager { diff --git a/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs b/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs index 8829ffdc..364c0c19 100644 --- a/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs +++ b/src/WebExpress.WebCore/WebEndpoint/IEndpointContext.cs @@ -9,7 +9,10 @@ namespace WebExpress.WebCore.WebEndpoint { /// - /// Represents the context of a endpoint. + /// Read-only descriptor of a registered endpoint (a page, resource, REST API, …). It tells the + /// framework and other components what they need to know about the endpoint — its id, the + /// application and plugin it belongs to, and its routing details — without referencing the + /// endpoint instance itself. /// public interface IEndpointContext : IContext { diff --git a/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs index 0125336e..c5a68f84 100644 --- a/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/IEndpointManager.cs @@ -7,7 +7,9 @@ namespace WebExpress.WebCore.WebEndpoint { /// - /// Represents a endpoint manager. + /// Contract for a registry of endpoints. An endpoint manager keeps track of the endpoints + /// (pages, resources, REST APIs, …) contributed by plugins and exposes their contexts so the + /// rest of the framework can find and route to them. /// public interface IEndpointManager : IComponentManager { diff --git a/src/WebExpress.WebCore/WebEvent/EventHandlerContext.cs b/src/WebExpress.WebCore/WebEvent/EventHandlerContext.cs index d32ed542..cef1e7b7 100644 --- a/src/WebExpress.WebCore/WebEvent/EventHandlerContext.cs +++ b/src/WebExpress.WebCore/WebEvent/EventHandlerContext.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebEvent { /// - /// Represents the context of an event. + /// Default implementation of : the read-only descriptor that + /// identifies a registered event handler and the application and plugin it belongs to. /// public class EventHandlerContext : IEventHandlerContext { diff --git a/src/WebExpress.WebCore/WebEvent/EventManager.cs b/src/WebExpress.WebCore/WebEvent/EventManager.cs index 7b172ee1..45eaedd1 100644 --- a/src/WebExpress.WebCore/WebEvent/EventManager.cs +++ b/src/WebExpress.WebCore/WebEvent/EventManager.cs @@ -13,7 +13,9 @@ namespace WebExpress.WebCore.WebEvent { /// - /// The event manager. + /// Central registry for event handlers. It discovers the event handlers a plugin provides, + /// keeps track of them per application, and dispatches a raised event to every handler that + /// listens for it. This is the wiring that connects events to their handlers at run time. /// public sealed class EventManager : IEventManager, ISystemComponent { diff --git a/src/WebExpress.WebCore/WebEvent/IEvent.cs b/src/WebExpress.WebCore/WebEvent/IEvent.cs index 26c48e48..b2be4b79 100644 --- a/src/WebExpress.WebCore/WebEvent/IEvent.cs +++ b/src/WebExpress.WebCore/WebEvent/IEvent.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebEvent { /// - /// Represents an event. + /// Marks a type as an event that can be raised within the framework. Components register + /// handlers () against such events to react when they occur, + /// allowing loosely coupled communication between parts of an application. /// public interface IEvent { diff --git a/src/WebExpress.WebCore/WebEvent/IEventArgument.cs b/src/WebExpress.WebCore/WebEvent/IEventArgument.cs index bebc6a59..398e04df 100644 --- a/src/WebExpress.WebCore/WebEvent/IEventArgument.cs +++ b/src/WebExpress.WebCore/WebEvent/IEventArgument.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebEvent { /// - /// Represents an argument for an event. + /// Marks a type as the data payload passed to event handlers when an event is raised. It carries + /// whatever information a handler needs to react to that particular event. /// public interface IEventArgument { diff --git a/src/WebExpress.WebCore/WebEvent/IEventHandler.cs b/src/WebExpress.WebCore/WebEvent/IEventHandler.cs index 11b51ad2..be8c0b41 100644 --- a/src/WebExpress.WebCore/WebEvent/IEventHandler.cs +++ b/src/WebExpress.WebCore/WebEvent/IEventHandler.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebEvent { /// - /// Represents an event handler. + /// A component that reacts to an event: when the matching is raised, the + /// framework calls with the sender and the event's + /// argument. Register one to run custom logic in response to something that happens in the system. /// public interface IEventHandler : IComponent { @@ -16,7 +18,8 @@ public interface IEventHandler : IComponent } /// - /// Represents an event handler. + /// Strongly typed variant of that receives its event argument as the + /// concrete type , avoiding a cast in the handler. /// public interface IEventHandler : IComponent where T : class, IEventArgument { diff --git a/src/WebExpress.WebCore/WebEvent/IEventHandlerContext.cs b/src/WebExpress.WebCore/WebEvent/IEventHandlerContext.cs index 3da7adbe..bfb11528 100644 --- a/src/WebExpress.WebCore/WebEvent/IEventHandlerContext.cs +++ b/src/WebExpress.WebCore/WebEvent/IEventHandlerContext.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebEvent { /// - /// Represents the context of an event. + /// Read-only descriptor of a registered event handler, exposing its id and the application and + /// plugin it belongs to, so the event manager can manage handlers without referencing their instances. /// public interface IEventHandlerContext : IContext { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlComment.cs b/src/WebExpress.WebCore/WebHtml/HtmlComment.cs index cf37f389..0646164c 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlComment.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlComment.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an HTML comment node. + /// A node that renders as an HTML comment (<!-- ... -->). Comments are written into + /// the page markup but are not displayed by the browser; useful for notes or markers in the output. /// public class HtmlComment : IHtmlNode { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementEmbeddedPicture.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementEmbeddedPicture.cs index c2e0e90d..b4977251 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementEmbeddedPicture.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementEmbeddedPicture.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a picture. + /// Renders an HTML <picture> element, a container that lets the browser choose between + /// several image sources (for example by screen size or format) and falls back to a contained image. /// public class HtmlElementEmbeddedPicture : HtmlElement, IHtmlElementEmbedded { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementFieldButton.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementFieldButton.cs index 5c9ac532..106065b4 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementFieldButton.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementFieldButton.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a button element. + /// Renders an HTML <button> element — a clickable control used to submit a form or + /// trigger an action in the page. /// public class HtmlElementFieldButton : HtmlElement, IHtmlElementFormItem { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementFormFieldset.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementFormFieldset.cs index abe7546e..8a56ca86 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementFormFieldset.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementFormFieldset.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a set of controls. + /// Renders an HTML <fieldset> element, which groups related form controls together + /// (often with a caption) so they appear and behave as one logical block within a form. /// public class HtmlElementFormFieldset : HtmlElement, IHtmlElementFormItem { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementInteractiveMenu.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementInteractiveMenu.cs index d34d90e2..7394e751 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementInteractiveMenu.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementInteractiveMenu.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a list of commands. + /// Renders an HTML <menu> element, a list of commands or options presented to the user + /// (semantically a toolbar- or menu-style grouping of interactive items). /// public class HtmlElementInteractiveMenu : HtmlElement, IHtmlElementInteractive { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs index d12df91d..38e6746d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMultimediaImg.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an image. + /// Renders an HTML <img> element, which embeds an image in the page. The image source + /// and presentation are set through properties such as , , + /// , and . /// public class HtmlElementMultimediaImg : HtmlElement, IHtmlElementMultimedia { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionSection.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionSection.cs index 6b1d6cec..c4df72db 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementSectionSection.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementSectionSection.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a section of a document. + /// Renders an HTML <section> element, a thematic grouping of content that forms a + /// standalone part of the page outline, typically introduced by a heading. /// public class HtmlElementSectionSection : HtmlElement, IHtmlElementSection { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTableCol.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTableCol.cs index 77eda4b1..726de19d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTableCol.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTableCol.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a table column. + /// Renders an HTML <col> element, used inside a table's column group to apply shared + /// attributes (such as styling) to one or more table columns at once. /// public class HtmlElementTableCol : HtmlElement, IHtmlElementTable { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentBlockquote.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentBlockquote.cs index 688a356f..f47cc42c 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentBlockquote.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentBlockquote.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a quote. + /// Renders an HTML <blockquote> element, used to mark a block of text that is quoted + /// from another source; browsers usually indent it to set it apart. /// public class HtmlElementTextContentBlockquote : HtmlElement, IHtmlElementTextContent { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs index c8b02a51..0cc0b0f7 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementTextContentP.cs @@ -5,7 +5,7 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents the content as a paragraph. + /// Renders an HTML <p> element — a paragraph, the standard block for a run of text. /// public class HtmlElementTextContentP : HtmlElement, IHtmlElementTextContent { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementWebComponentsSlot.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementWebComponentsSlot.cs index 81cfdb69..69d06705 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementWebComponentsSlot.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementWebComponentsSlot.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a placeholder. + /// Renders an HTML <slot> element, a named placeholder inside a web component into which + /// externally supplied content is projected when the component is used. /// public class HtmlElementWebFragmentsSlot : HtmlElement, IHtmlElementWebFragments { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlEmpty.cs b/src/WebExpress.WebCore/WebHtml/HtmlEmpty.cs index 579f7738..307f9b2a 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlEmpty.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlEmpty.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an empty HTML node. + /// A node without its own tag that simply writes out its (possibly empty) value. It is handy as + /// a neutral placeholder where a node is required but no surrounding element should be produced. /// public class HtmlEmpty : IHtmlNode { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlList.cs b/src/WebExpress.WebCore/WebHtml/HtmlList.cs index c0a33ea5..eca6ab92 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlList.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlList.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// List of html elements. + /// An ordered group of HTML nodes that renders its children one after another without adding any + /// wrapping tag of its own (similar to a document fragment). Use it to return or pass around + /// several sibling nodes as a single unit. /// public class HtmlList : IHtmlNode { diff --git a/src/WebExpress.WebCore/WebHtml/HtmlRaw.cs b/src/WebExpress.WebCore/WebHtml/HtmlRaw.cs index faad7a10..add1abd7 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlRaw.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlRaw.cs @@ -3,11 +3,10 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a raw HTML node. + /// A node that holds ready-made HTML markup and writes it to the output verbatim, without any + /// escaping. Only use it with trusted markup; for untrusted text use so + /// special characters cannot be interpreted as markup. /// - /// - /// This class is used to encapsulate raw HTML content. - /// public class HtmlRaw : IHtmlNode { /// diff --git a/src/WebExpress.WebCore/WebHtml/HtmlText.cs b/src/WebExpress.WebCore/WebHtml/HtmlText.cs index 267a2cc7..f21f2615 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlText.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlText.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents a text. + /// A node that holds a piece of plain text placed between or inside HTML elements (a text node). + /// Use it to add textual content to the page; for ready-made markup use instead. /// public class HtmlText : IHtmlNode { diff --git a/src/WebExpress.WebCore/WebHtml/IHtml.cs b/src/WebExpress.WebCore/WebHtml/IHtml.cs index be25f43b..36f62c7c 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtml.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtml.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Interface for HTML elements. + /// Lowest-level building block of the HTML object model: anything that can render itself into + /// HTML markup. Implementations write their output into a shared , + /// which lets the whole page be assembled in a single buffer instead of concatenating strings. /// public interface IHtml { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs index 803941ef..41156c01 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElement.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Interface of an html element. + /// An HTML node that has a tag, and therefore can carry attributes (id, class, style, role, …) + /// and contain child nodes. It exposes a fluent API (Add, AddClass, AddStyle, …) + /// so elements and their content can be built up in a readable, chainable way. /// public interface IHtmlElement : IHtmlNode { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElementInteractive.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElementInteractive.cs index 1b966939..84118f11 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElementInteractive.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElementInteractive.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an element that is interactive. + /// Marker interface that tags an HTML element as belonging to the HTML5 "interactive content" + /// category — elements meant for user interaction, such as buttons, links, or form fields. + /// It carries no members; it only lets the code group elements by this category. /// public interface IHtmlElementInteractive { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElementMetadata.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElementMetadata.cs index 4aa693da..2766ccff 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElementMetadata.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElementMetadata.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an element as metadata. + /// Marker interface that tags an HTML element as belonging to the HTML5 "metadata content" + /// category — elements that describe the document rather than show content, such as those that + /// live in the page head. It carries no members; it only lets the code group elements by this category. /// public interface IHtmlElementMetadata { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElementRoot.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElementRoot.cs index 8fd47cb2..d14f2815 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElementRoot.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElementRoot.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an element as a root. + /// Marker interface that tags an HTML element as a document root element (the <html> + /// element that wraps the whole page). It carries no members; it only lets the code group + /// elements by this category. /// public interface IHtmlElementRoot { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElementScripting.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElementScripting.cs index 99b7e122..b9191d09 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElementScripting.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElementScripting.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an element as a script. + /// Marker interface that tags an HTML element as belonging to the HTML5 "scripting content" + /// category — elements that embed or reference scripts, such as <script>. It carries no + /// members; it only lets the code group elements by this category. /// public interface IHtmlElementScripting { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlElementSection.cs b/src/WebExpress.WebCore/WebHtml/IHtmlElementSection.cs index 7b26925e..8fe7d08d 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlElementSection.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlElementSection.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Represents an element as a section. + /// Marker interface that tags an HTML element as belonging to the HTML5 "sectioning content" + /// category — elements that define a section of the page outline, such as <section>, + /// <article>, or <nav>. It carries no members; it only lets the code group elements by this category. /// public interface IHtmlElementSection { diff --git a/src/WebExpress.WebCore/WebHtml/IHtmlNode.cs b/src/WebExpress.WebCore/WebHtml/IHtmlNode.cs index 5d45041f..7a2367a6 100644 --- a/src/WebExpress.WebCore/WebHtml/IHtmlNode.cs +++ b/src/WebExpress.WebCore/WebHtml/IHtmlNode.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebHtml { /// - /// Interface of an html node. + /// Represents any node that can appear in the HTML output tree — an element, a piece of text, + /// a comment, or raw markup. It is the common type for everything that can be nested inside an + /// HTML element, so containers can hold mixed content without caring about the concrete kind. /// public interface IHtmlNode : IHtml { diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs index 5cde1444..2c3260c5 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Interface that defines an identity group. + /// A named group of identities (users) used for access control. Instead of granting rights to + /// each user individually, a group bundles a set of policies that apply to all of its members. /// public interface IIdentityGroup { diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityPermission.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityPermission.cs index 67fe2c6a..199eb3b7 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityPermission.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityPermission.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Interface that defines an identity permission. + /// A component that defines a single permission — an individual right that can be granted or + /// checked. Permissions are the smallest unit of access control and are combined into policies + /// (). /// public interface IIdentityPermission : IComponent { diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs index 740de32d..a81f5f6f 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityPolicy.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Interface that defines an identity policy. + /// A component that defines an access-control policy: a named rule (built from permissions) that + /// decides whether the current identity is allowed to do something. Plugins provide policies and + /// the framework evaluates them when guarding pages, resources, or actions. /// public interface IIdentityPolicy : IComponent { diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityResource.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityResource.cs index ce18045f..7eef5859 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityResource.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityResource.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebIdentity { /// - /// Interface for identity resources. + /// Marks something as a protected resource in the identity system — an object whose access can + /// be guarded by permissions and policies. /// public interface IIdentityResource { diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityItem.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityItem.cs index 5ad80ca9..330819d4 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityItem.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityItem.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebIdentity.Model { /// - /// Represents an identity item within the WebExpress framework. + /// Model type that represents a single identity (a known user or principal) in WebExpress's + /// identity system. It currently has no members and serves as a placeholder for identity data. /// public class IdentityItem { diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs index 5f7ce50f..f6a7288f 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPermissionDictionary.cs @@ -7,7 +7,9 @@ namespace WebExpress.WebCore.WebIdentity.Model { /// - /// The identity permission directory. + /// Internal lookup that stores the registered identity permissions grouped first by plugin and + /// then by application, so the manager can quickly find, add, or remove the permissions that + /// apply to a given application. /// internal class IdentityPermissionDictionary : Dictionary>> { diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs index b1530e65..73d7d1d8 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyDictionary.cs @@ -7,7 +7,9 @@ namespace WebExpress.WebCore.WebIdentity.Model { /// - /// The identity policy directory. + /// Internal lookup that stores the registered identity policies grouped first by plugin and then + /// by application, so the manager can quickly find, add, or remove the policies that apply to a + /// given application. /// internal class IdentityPolicyDictionary : Dictionary>> { diff --git a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs index 82169d4d..99c5688f 100644 --- a/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs +++ b/src/WebExpress.WebCore/WebIdentity/Model/IdentityPolicyItem.cs @@ -7,7 +7,9 @@ namespace WebExpress.WebCore.WebIdentity.Model { /// - /// Represents an item in the identity policy. + /// Internal record the identity system keeps for one registered policy. It links the policy + /// class and its created instance to the plugin and application it belongs to, and lists the + /// permissions the policy requires. /// public class IdentityPolicyItem { diff --git a/src/WebExpress.WebCore/WebJob/IJobContext.cs b/src/WebExpress.WebCore/WebJob/IJobContext.cs index 8f977602..872b15d7 100644 --- a/src/WebExpress.WebCore/WebJob/IJobContext.cs +++ b/src/WebExpress.WebCore/WebJob/IJobContext.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebJob { /// - /// Represents the context of a job. + /// Read-only descriptor of a registered job (a scheduled, recurring task), exposing the + /// application and plugin it belongs to, so the job manager can manage it without referencing + /// the job instance. /// public interface IJobContext : IContext { diff --git a/src/WebExpress.WebCore/WebJob/JobContext.cs b/src/WebExpress.WebCore/WebJob/JobContext.cs index dce04004..3c59fedc 100644 --- a/src/WebExpress.WebCore/WebJob/JobContext.cs +++ b/src/WebExpress.WebCore/WebJob/JobContext.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebJob { /// - /// Represents the job context. + /// Default implementation of : the read-only descriptor that identifies + /// a scheduled job and the application and plugin it belongs to. /// public class JobContext : IJobContext { diff --git a/src/WebExpress.WebCore/WebJob/JobManager.cs b/src/WebExpress.WebCore/WebJob/JobManager.cs index 26f3b13b..315eaa88 100644 --- a/src/WebExpress.WebCore/WebJob/JobManager.cs +++ b/src/WebExpress.WebCore/WebJob/JobManager.cs @@ -13,6 +13,11 @@ namespace WebExpress.WebCore.WebJob { + /// + /// Central registry and scheduler for jobs — recurring background tasks that run on a schedule + /// (similar to cron). It discovers the jobs a plugin provides, keeps track of them, and runs + /// them at their due times. + /// /// /// This class manages the processing of cyclic jobs. It provides methods to register, remove, and execute jobs. /// diff --git a/src/WebExpress.WebCore/WebLog/LogItem.cs b/src/WebExpress.WebCore/WebLog/LogItem.cs index a3734954..d22f7875 100644 --- a/src/WebExpress.WebCore/WebLog/LogItem.cs +++ b/src/WebExpress.WebCore/WebLog/LogItem.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebLog { /// - /// Log entry + /// A single entry in the server log. It captures one logged message together with its severity + /// level, the source location that produced it, and the time it occurred. /// internal class LogItem { diff --git a/src/WebExpress.WebCore/WebMessage/HttpExceptionContext.cs b/src/WebExpress.WebCore/WebMessage/HttpExceptionContext.cs index b2bc1310..178b7556 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpExceptionContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpExceptionContext.cs @@ -5,7 +5,10 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents the context for an HTTP exception, inheriting from . + /// A fallback used when a normal request context could not be built + /// (for example, the request was malformed). It still carries the basic connection details and + /// additionally holds the that caused the failure, so an error response + /// can be produced. /// public class HttpExceptionContext : HttpContext { diff --git a/src/WebExpress.WebCore/WebMessage/IResponse.cs b/src/WebExpress.WebCore/WebMessage/IResponse.cs index 6cdd344d..997dc2c2 100644 --- a/src/WebExpress.WebCore/WebMessage/IResponse.cs +++ b/src/WebExpress.WebCore/WebMessage/IResponse.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Defines the contract for a response according to RFC 2616 Section 6. + /// Defines what every server response must expose (see RFC 2616): the header fields, the + /// content (body), the numeric HTTP status code, and the human-readable reason phrase. /// public interface IResponse { diff --git a/src/WebExpress.WebCore/WebMessage/Request.cs b/src/WebExpress.WebCore/WebMessage/Request.cs index a825cd14..627f6fda 100644 --- a/src/WebExpress.WebCore/WebMessage/Request.cs +++ b/src/WebExpress.WebCore/WebMessage/Request.cs @@ -10,8 +10,11 @@ namespace WebExpress.WebCore.WebMessage { /// - /// See RFC 2616, The Request class encapsulates and extends the - /// original request of the HttpListener call. + /// Represents a single incoming HTTP request (see RFC 2616). It wraps the raw request data + /// provided by ASP.NET Core and makes it easy to consume: besides the common request + /// information from (method, URI, headers, session), it reads the + /// request body and turns it into parameters, supporting URL-encoded forms, plain text, and + /// multipart form data including file uploads. /// public partial class Request : RequestBase { diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index 60a8f014..d626dcb1 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -14,8 +14,10 @@ namespace WebExpress.WebCore.WebMessage { /// - /// See RFC 2616, The Request class encapsulates and extends the - /// original request of the HttpListener call. + /// Common base class for incoming requests (see RFC 2616). It extracts and exposes the + /// information shared by every request type — HTTP method, URI, header fields, client and + /// server endpoints, session, culture, and the query and session parameters. Concrete + /// requests such as and build on it. /// public abstract class RequestBase : IRequest { diff --git a/src/WebExpress.WebCore/WebMessage/Response.cs b/src/WebExpress.WebCore/WebMessage/Response.cs index b95d93eb..7f5abd75 100644 --- a/src/WebExpress.WebCore/WebMessage/Response.cs +++ b/src/WebExpress.WebCore/WebMessage/Response.cs @@ -4,7 +4,11 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response according to RFC 2616 Section 6. + /// Common base class for the message a server sends back to the client (see RFC 2616). + /// It holds the response header fields, the content (body), and the reason phrase. The numeric + /// HTTP status code is taken from the StatusCode attribute on each concrete response + /// type, so subclasses such as or only + /// need to declare their status and fill in the content. /// public abstract class Response : IResponse { diff --git a/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs b/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs index b3a4c61e..fa4dfe1e 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseBadRequest.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response for a bad request (HTTP 400). See RFC 2616 Section 6 + /// HTTP 400 (Bad Request): sent when the server cannot process the request because it is + /// malformed or invalid (for example, a broken body or missing required data). /// [StatusCode(400)] public class ResponseBadRequest : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseForbidden.cs b/src/WebExpress.WebCore/WebMessage/ResponseForbidden.cs index f47f220d..962f6a94 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseForbidden.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseForbidden.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response according to RFC 2616 Section 6. + /// HTTP 403 (Forbidden): sent when the server understood the request but refuses to fulfil it + /// because the caller lacks permission, regardless of authentication (see RFC 2616 Section 6). /// [StatusCode(403)] public class ResponseForbidden : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index 07e9ff6a..0495214e 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents the response header fields as per RFC 2616. + /// Holds the header fields that are sent back to the client with a response (see RFC 2616), + /// such as content length, content type, caching directives, and cookies. These describe the + /// response body and control how the browser handles it. /// public class ResponseHeaderFields { diff --git a/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs b/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs index 30aadceb..dc06a6b1 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseMovedPermanently.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response for a resource moved permanently (301) according to RFC 2616 Section 6. + /// HTTP 301 (Moved Permanently): redirects the client to a new URL and signals that the + /// resource has moved for good, so clients should use the new location from now on + /// (see RFC 2616 Section 6). /// [StatusCode(301)] public class ResponseMovedPermanently : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs index 232cd0e3..7890b61f 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseMovedTemporarily.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response according to RFC 2616 Section 6. + /// HTTP 302 (Found / Moved Temporarily): redirects the client to a different URL for this + /// request only; the original URL stays valid for future requests (see RFC 2616 Section 6). /// [StatusCode(302)] public class ResponseMovedTemporarily : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseNotFound.cs b/src/WebExpress.WebCore/WebMessage/ResponseNotFound.cs index 43ba6864..93333beb 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseNotFound.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseNotFound.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response for a resource not found (404) according to RFC 2616 Section 6. + /// HTTP 404 (Not Found): sent when no resource matches the requested URI, i.e. the server has + /// nothing to return for that address (see RFC 2616 Section 6). /// [StatusCode(404)] public class ResponseNotFound : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseOK.cs b/src/WebExpress.WebCore/WebMessage/ResponseOK.cs index 87d7a810..c642b630 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseOK.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseOK.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a successful response according to RFC 2616 Section 6. + /// HTTP 200 (OK): the standard answer telling the client the request succeeded and the + /// result is contained in the response body (see RFC 2616 Section 6). /// [StatusCode(200)] public class ResponseOK : Response diff --git a/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs b/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs index 2b7c00dc..f35fca56 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseUpgradeRequired.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebMessage { /// - /// Represents a response for upgrade required (426) according to RFC 7231 section 6.5.15. + /// HTTP 426 (Upgrade Required): tells the client it must switch to a different protocol + /// (such as a WebSocket connection) before the request can be served + /// (see RFC 7231 Section 6.5.15). /// [StatusCode(426)] public class ResponseUpgradeRequired : Response diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs index dc2c9c6d..100d0a17 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebPackage.Model { /// - /// Represents a catalog of packages. + /// The list of packages known to WebExpress, serialized to and from an XML catalog file. Each + /// entry () describes one installable package and its state. /// [XmlRoot("catalog")] public class PackageCatalog diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs index 6a827438..fa8d9d2b 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebPackage.Model { /// - /// Represents an item in the package catalog. + /// One package entry in the . It records a package's identity and + /// metadata together with its current state (available, active, or disabled). /// [XmlRoot("package")] public class PackageCatalogItem diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogeItemState.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogeItemState.cs index 0caf6165..2e31a06a 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogeItemState.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogeItemState.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebPackage.Model { /// - /// Represents the state of a package in the catalog. + /// Lifecycle state of a package listed in the catalog: present but not yet loaded + /// (Available), loaded and usable (Active), or switched off (Disable). /// public enum PackageCatalogeItemState { diff --git a/src/WebExpress.WebCore/WebPage/IPage.cs b/src/WebExpress.WebCore/WebPage/IPage.cs index f5eb23ee..5d5acd67 100644 --- a/src/WebExpress.WebCore/WebPage/IPage.cs +++ b/src/WebExpress.WebCore/WebPage/IPage.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebPage { /// - /// Defines the contract for a page resource. + /// An endpoint that renders an HTML page using the default visual tree. Implement it to build a + /// page from controls; the framework routes a request to it and renders the result. /// public interface IPage : IPage { diff --git a/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs b/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs index bc3956e5..758bd121 100644 --- a/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs +++ b/src/WebExpress.WebCore/WebPage/IVisualTreeContext.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebPage { /// - /// Represents the context of a visual tree. + /// Supplies the per-request information a visual tree needs while a page is being built — most + /// importantly the current request and URI. (A visual tree is the structure of controls that + /// makes up a page.) /// public interface IVisualTreeContext { diff --git a/src/WebExpress.WebCore/WebPage/PageContext.cs b/src/WebExpress.WebCore/WebPage/PageContext.cs index 4d191965..ce2d4bfe 100644 --- a/src/WebExpress.WebCore/WebPage/PageContext.cs +++ b/src/WebExpress.WebCore/WebPage/PageContext.cs @@ -12,7 +12,8 @@ namespace WebExpress.WebCore.WebPage { /// - /// Represents the context of a page. + /// Read-only descriptor of a registered page, handed to components so they can learn about the + /// page and the application and plugin it belongs to without referencing the page instance itself. /// public class PageContext : IPageContext { diff --git a/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs b/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs index fee9fd0c..2444d2a1 100644 --- a/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs +++ b/src/WebExpress.WebCore/WebPage/VisualTreeContext.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebPage { /// - /// Represents the context of a visual tree. + /// Default implementation of . It derives the request and URI + /// from the active render context, giving a page's control tree access to them while it is built. /// public class VisualTreeContext : IVisualTreeContext { diff --git a/src/WebExpress.WebCore/WebPlugin/IPlugin.cs b/src/WebExpress.WebCore/WebPlugin/IPlugin.cs index bcc96d2e..d36cdc6b 100644 --- a/src/WebExpress.WebCore/WebPlugin/IPlugin.cs +++ b/src/WebExpress.WebCore/WebPlugin/IPlugin.cs @@ -3,7 +3,9 @@ namespace WebExpress.WebCore.WebPlugin { /// - /// This interface represents a plugin. + /// A plugin — the deployable unit that extends WebExpress. A plugin packages applications and + /// other components; the framework loads it, calls once at start-up, and can + /// unload it again later. /// public interface IPlugin : IComponent { diff --git a/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs b/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs index 064d190e..86b5093e 100644 --- a/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs +++ b/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebPlugin { /// - /// The context of a plugin. + /// Read-only descriptor of a loaded plugin that the framework passes around so components can + /// learn which plugin they came from and read its metadata — id, name, manufacturer, version, + /// description, copyright, license, icon, and the .NET assembly it lives in. /// public interface IPluginContext : IContext { diff --git a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs index 37ead1e7..c95eb2c3 100644 --- a/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/IPluginManager.cs @@ -7,7 +7,8 @@ namespace WebExpress.WebCore.WebPlugin { /// - /// The plugin manager manages the WebExpress plugins. + /// Contract for the plugin registry. It tracks which plugins are loaded, raises events when a + /// plugin is added or removed, and gives the rest of the framework access to the active plugins. /// public interface IPluginManager : IComponentManager { diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginItem.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginItem.cs index cabafb55..d516f22d 100644 --- a/src/WebExpress.WebCore/WebPlugin/Model/PluginItem.cs +++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginItem.cs @@ -5,7 +5,9 @@ namespace WebExpress.WebCore.WebPlugin.Model { /// - /// Represents a plugin entry. + /// Internal record the plugin manager keeps for one loaded plugin. It bundles everything needed + /// to run and later unload the plugin: its load context, plugin class and instance, public + /// context, declared dependencies, and the application types it supports. /// internal class PluginItem { diff --git a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs index 9cd65920..f6eacf03 100644 --- a/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs +++ b/src/WebExpress.WebCore/WebPlugin/Model/PluginRuntimeState.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebPlugin.Model { /// - /// Represents the runtime state of a plugin. + /// Tells whether a plugin is currently usable. A plugin only becomes Active once all the + /// other plugins it depends on are present; until then it stays in WaitingForDependencies. /// public enum PluginRuntimeState { diff --git a/src/WebExpress.WebCore/WebPlugin/Plugin.cs b/src/WebExpress.WebCore/WebPlugin/Plugin.cs index 2f0436c5..d2f314b4 100644 --- a/src/WebExpress.WebCore/WebPlugin/Plugin.cs +++ b/src/WebExpress.WebCore/WebPlugin/Plugin.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebPlugin { /// - /// This represents an plugin. + /// Base class for a WebExpress plugin. Derive from it to create a plugin; the framework + /// instantiates the subclass, supplies its (id, name, version, …), + /// and calls Run at start-up. See . /// public abstract class Plugin : IPlugin { diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index e4dbc809..618c2a64 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -16,7 +16,9 @@ namespace WebExpress.WebCore.WebPlugin { /// - /// The plugin manager manages the WebExpress plugins. + /// Central registry for plugins. It loads plugin assemblies, resolves the dependencies between + /// them, activates them in the right order, tracks their runtime state, and unloads them again. + /// This is the backbone of WebExpress's plugin system. /// public sealed class PluginManager : IPluginManager, IExecutableElements, ISystemComponent { diff --git a/src/WebExpress.WebCore/WebResource/IResource.cs b/src/WebExpress.WebCore/WebResource/IResource.cs index 6f3ec923..b26404dd 100644 --- a/src/WebExpress.WebCore/WebResource/IResource.cs +++ b/src/WebExpress.WebCore/WebResource/IResource.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebResource { /// - /// Defines the contract for a resource component. + /// An endpoint that answers a request directly: given the incoming it + /// produces an . This is the general-purpose building block for anything + /// served at a route that is not specifically a rendered page or a REST API. /// public interface IResource : IEndpoint { diff --git a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs index f422b1a3..3af58fdb 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceBinary.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceBinary.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebResource { /// - /// A binary resource. + /// Base class for a resource that returns raw bytes (such as an image or download) rather than a + /// rendered page. Subclasses supply the to send to the client. /// public abstract class ResourceBinary : Resource { diff --git a/src/WebExpress.WebCore/WebResource/ResourceContext.cs b/src/WebExpress.WebCore/WebResource/ResourceContext.cs index dfb9f4e3..b310e322 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceContext.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceContext.cs @@ -11,7 +11,9 @@ namespace WebExpress.WebCore.WebResource { /// - /// Represents the context of a resource. + /// Read-only descriptor of a registered resource endpoint, handed to components so they can + /// learn about the resource and the application and plugin it belongs to without referencing + /// the resource instance itself. /// public class ResourceContext : IResourceContext { diff --git a/src/WebExpress.WebCore/WebResource/ResourceFile.cs b/src/WebExpress.WebCore/WebResource/ResourceFile.cs index c16958ba..735d7c42 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceFile.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceFile.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebResource { /// - /// A file resource. + /// A binary resource that serves a file read from disk, delivering its bytes to the client. + /// Access is guarded so concurrent requests can read the same file safely. /// public class ResourceFile : ResourceBinary { diff --git a/src/WebExpress.WebCore/WebRestApi/IRestApi.cs b/src/WebExpress.WebCore/WebRestApi/IRestApi.cs index d27f0819..3e065acd 100644 --- a/src/WebExpress.WebCore/WebRestApi/IRestApi.cs +++ b/src/WebExpress.WebCore/WebRestApi/IRestApi.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebRestApi { /// - /// Defines the contract for a rest api resource. + /// An endpoint that exposes a REST API: it is reachable at a route and typically exchanges data + /// (such as JSON) rather than rendering an HTML page, for use by clients and scripts. /// public interface IRestApi : IEndpoint { diff --git a/src/WebExpress.WebCore/WebRestApi/RestApiContext.cs b/src/WebExpress.WebCore/WebRestApi/RestApiContext.cs index 7de0dbac..48135606 100644 --- a/src/WebExpress.WebCore/WebRestApi/RestApiContext.cs +++ b/src/WebExpress.WebCore/WebRestApi/RestApiContext.cs @@ -12,7 +12,9 @@ namespace WebExpress.WebCore.WebRestApi { /// - /// Represents the context of a rest api resource. + /// Read-only descriptor of a registered REST API endpoint, handed to components so they can + /// learn about the API and the application and plugin it belongs to without referencing the + /// endpoint instance itself. /// public class RestApiContext : IRestApiContext { diff --git a/src/WebExpress.WebCore/WebScope/IScope.cs b/src/WebExpress.WebCore/WebScope/IScope.cs index aa0bbc44..dd47c8b3 100644 --- a/src/WebExpress.WebCore/WebScope/IScope.cs +++ b/src/WebExpress.WebCore/WebScope/IScope.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebScope { /// - /// Interface of a scope. + /// Marker type that names a "scope" — a contextual grouping that pages, setting pages, or + /// includes can be tagged with via [Scope<TScope>]. Scopes let the framework decide + /// where such elements apply (for example, limiting a fragment to a particular area of the app). /// public interface IScope { diff --git a/src/WebExpress.WebCore/WebSection/ISection.cs b/src/WebExpress.WebCore/WebSection/ISection.cs index 98cd4da3..b15ae2b4 100644 --- a/src/WebExpress.WebCore/WebSection/ISection.cs +++ b/src/WebExpress.WebCore/WebSection/ISection.cs @@ -1,7 +1,9 @@ namespace WebExpress.WebCore.WebSection { /// - /// Interface of a section. + /// Marker type that names a "section" — a placeholder location in a page or layout into which + /// fragments can be inserted. Fragments declare the section they belong to, and the framework + /// renders them at that spot. /// public interface ISection { diff --git a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs index 5bab66d3..e66acbb2 100644 --- a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs +++ b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyParameter.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebSession.Model { /// - /// Represents a session property with parameters. + /// A session property that remembers request parameters for the user's session. Parameters + /// stored here are re-applied to later requests (as session-scoped parameters), so values can + /// persist across page calls without being resent each time. /// public class SessionPropertyParameter : SessionProperty { diff --git a/src/WebExpress.WebCore/WebSettingPage/ISettingGroupContext.cs b/src/WebExpress.WebCore/WebSettingPage/ISettingGroupContext.cs index 10f60dce..f322e220 100644 --- a/src/WebExpress.WebCore/WebSettingPage/ISettingGroupContext.cs +++ b/src/WebExpress.WebCore/WebSettingPage/ISettingGroupContext.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebSettingPage { /// - /// Provides the context for a setting group. + /// Read-only descriptor of a settings group — the heading under which related setting pages are + /// listed in the settings navigation — exposing the application and plugin it belongs to. /// public interface ISettingGroupContext : IContext { diff --git a/src/WebExpress.WebCore/WebSettingPage/ISettingPage.cs b/src/WebExpress.WebCore/WebSettingPage/ISettingPage.cs index 3bc3b85a..df0d73c7 100644 --- a/src/WebExpress.WebCore/WebSettingPage/ISettingPage.cs +++ b/src/WebExpress.WebCore/WebSettingPage/ISettingPage.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebSettingPage { /// - /// Defines the contract for a setting page resource. + /// An endpoint that renders a page in the application's settings area. Implement it to add a + /// configuration page; the framework places it within the settings navigation and renders it. /// public interface ISettingPage : ISettingPage { diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryItem.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryItem.cs index dc69f1e6..69ccbb6e 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryItem.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingCategoryItem.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebSettingPage.Model { /// - /// Represents an item on the setting category. + /// Internal record for one settings category — the top level of the settings navigation, which + /// contains groups, which in turn contain pages. Links the category to its application and plugin. /// public class SettingCategoryItem : IDisposable { diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupItem.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupItem.cs index 822877ef..cee42e4c 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupItem.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingGroupItem.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebSettingPage.Model { /// - /// Represents an item on the setting group. + /// Internal record for one settings group — a heading within a category that bundles related + /// setting pages. Links the group to its application and plugin. /// public class SettingGroupItem : IDisposable { diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageItem.cs b/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageItem.cs index 3462b862..89a9ffd6 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageItem.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/SettingPageItem.cs @@ -11,7 +11,8 @@ namespace WebExpress.WebCore.WebSettingPage.Model { /// - /// Represents an item on the setting page. + /// Internal record the settings system keeps for one registered setting page, linking the page + /// to the group it appears in and to the application and plugin that provided it. /// public class SettingPageItem : IDisposable { diff --git a/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs index a0ff5a54..c5ce78b7 100644 --- a/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/ISitemapManager.cs @@ -9,7 +9,8 @@ namespace WebExpress.WebCore.WebSitemap { /// - /// The interface of the sitemap manager. + /// Contract for the sitemap registry — the component that builds and holds the tree of all + /// routes and resolves an incoming URI to the endpoint that should handle it (and back again). /// public interface ISitemapManager : IComponentManager { diff --git a/src/WebExpress.WebCore/WebSitemap/Model/SitemapNode.cs b/src/WebExpress.WebCore/WebSitemap/Model/SitemapNode.cs index ee5607a1..8da2c02f 100644 --- a/src/WebExpress.WebCore/WebSitemap/Model/SitemapNode.cs +++ b/src/WebExpress.WebCore/WebSitemap/Model/SitemapNode.cs @@ -6,7 +6,9 @@ namespace WebExpress.WebCore.WebSitemap.Model { /// - /// A Sitemap node. + /// One node in the sitemap — the tree of all routes the server can serve. Each node represents a + /// path segment, optionally the endpoint reachable there, and links to its parent and child + /// nodes. Routing a request means walking this tree segment by segment. /// public class SitemapNode { diff --git a/src/WebExpress.WebCore/WebStatusPage/IStatusPage.cs b/src/WebExpress.WebCore/WebStatusPage/IStatusPage.cs index 9b6158ba..ec7d1c5a 100644 --- a/src/WebExpress.WebCore/WebStatusPage/IStatusPage.cs +++ b/src/WebExpress.WebCore/WebStatusPage/IStatusPage.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebStatusPage { /// - /// Interface of the status pages. + /// A component that renders the page shown for an HTTP status (for example the 404 or 500 + /// error page). Implement it to customize how a given status is presented to the user. /// public interface IStatusPage : IStatusPage { diff --git a/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs b/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs index c6d0b1c6..6c76e7d6 100644 --- a/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs +++ b/src/WebExpress.WebCore/WebStatusPage/IStatusPageContext.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebStatusPage { /// - /// Represents the context for a status page. + /// Read-only descriptor of a registered status page, exposing the application and plugin it + /// belongs to, so the framework can pick and manage status pages without referencing their instances. /// public interface IStatusPageContext : IContext { diff --git a/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageItem.cs b/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageItem.cs index 68b2194b..1ae7f06a 100644 --- a/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageItem.cs +++ b/src/WebExpress.WebCore/WebStatusPage/Model/StatusPageItem.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebStatusPage.Model { /// - /// Represents a status page item. + /// Internal record the status-page manager keeps for one registered status page, linking it to + /// the application and plugin that provided it. /// internal class StatusPageItem { diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusMessage.cs b/src/WebExpress.WebCore/WebStatusPage/StatusMessage.cs index 125a6309..a4818bf5 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusMessage.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusMessage.cs @@ -2,7 +2,8 @@ { /// - /// Represents a status message. + /// Carries the human-readable text shown on a status page (for example the explanation rendered + /// for a 404 or 500 response), so the status page can display a meaningful message to the user. /// public class StatusMessage { diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs index 8842ee2c..b590b9f4 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageContext.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebStatusPage { /// - /// Represents the context for a status page. + /// Default implementation of : the read-only descriptor that + /// identifies a registered status page and the application and plugin it belongs to. /// public class StatusPageContext : IStatusPageContext { diff --git a/src/WebExpress.WebCore/WebTheme/Model/ThemeItem.cs b/src/WebExpress.WebCore/WebTheme/Model/ThemeItem.cs index 500db837..16f6057c 100644 --- a/src/WebExpress.WebCore/WebTheme/Model/ThemeItem.cs +++ b/src/WebExpress.WebCore/WebTheme/Model/ThemeItem.cs @@ -3,7 +3,8 @@ namespace WebExpress.WebCore.WebTheme.Model { /// - /// Represents an theme item. + /// Internal record the theme manager keeps for one registered theme, holding the theme class + /// and the information needed to apply it (such as its stylesheet). /// public class ThemeItem : IDisposable { diff --git a/src/WebExpress.WebCore/WebTheme/ThemeMode.cs b/src/WebExpress.WebCore/WebTheme/ThemeMode.cs index 9c7deafe..2f719741 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeMode.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeMode.cs @@ -1,8 +1,9 @@ namespace WebExpress.WebCore.WebTheme { - /// - /// Specifies the theme mode. - /// + /// + /// The visual appearance a theme is rendered in — light or dark — letting the UI switch between + /// a bright and a dark colour scheme. + /// public enum ThemeMode { /// diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs index bdb6ef8e..3d79e4ed 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegmentConstant.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// The path segment of a uri. + /// A single part of a URI path (the pieces between the slashes) that is a fixed literal, such as + /// ix in /ix/home. It matches a request only when that part of the path is exactly equal. /// public interface IUriPathSegmentConstant : IUriPathSegment { diff --git a/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs index 11e98360..ad2631c4 100644 --- a/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/IUriPathSegmentVariable.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebUri { /// - /// The path segment of a uri. + /// A single part of a URI path (the pieces between the slashes) that is a placeholder rather than + /// a fixed literal, such as :id in /user/:id. It captures the actual value from the + /// request under a variable name and can restrict which values are accepted via a constraint expression. /// public interface IUriPathSegmentVariable : IUriPathSegment { diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs index 1ec274dc..21eb4d79 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentConstant.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// Constant path segment. + /// A fixed-literal part of a URI path, such as ix in /ix/home. During routing it + /// matches a request only when that part of the path is exactly equal (case-insensitively). /// public class UriPathSegmentConstant : IUriPathSegmentConstant { diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs index 3f90b5f2..8ffed19d 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentRoot.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// Represents the root segment of a URI path. + /// The starting segment of every URI path — the leading / from which all other segments + /// branch. It is the entry point (id ROOT) of the path tree used for routing. /// public class UriPathSegmentRoot : IUriPathSegment { diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs index f453661a..849a07bd 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariable.cs @@ -29,7 +29,11 @@ public static Regex Get(string pattern) } /// - /// Variable path segment. + /// Base class for placeholder path segments such as :id in /user/:id. It captures + /// the value found at that position under a variable name and matches it against an optional + /// constraint expression. Concrete subclasses (int, double, GUID, string, regex, …) supply the + /// constraint for a particular value type; ties the captured + /// value to the strongly typed request parameter it represents. /// /// The parameter type. public abstract class UriPathSegmentVariable : IUriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs index 0be147e1..43dbca0e 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableApiVersion.cs @@ -5,7 +5,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// A variable path segment for the api version (e.g., /api/1/...). + /// A placeholder path segment that captures the API version number from the path + /// (the 1 in /api/1/...), so REST endpoints can be routed per version. /// /// The parameter type. internal class UriPathSegmentVariableApiVersion : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs index 109dc318..412f3134 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableDouble.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// Variable path segment. + /// A placeholder path segment that only matches when the value at that position is a decimal + /// number. Use it to restrict a route parameter to floating-point values. /// /// The parameter type. public class UriPathSegmentVariableDouble : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs index b758e714..d3e9e9ad 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableGuid.cs @@ -6,7 +6,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// Represents a URI path segment variable for GUIDs. + /// A placeholder path segment that only matches when the value at that position is a GUID + /// (for example 67d35a0f-7e94-4bfd-a309-36e9162a67ff). Use it for routes keyed by a unique identifier. /// /// The parameter type. public class UriPathSegmentVariableGuid : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs index 51439d70..8b1c790a 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableInt.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// Int variable path segment. + /// A placeholder path segment that only matches when the value at that position is an integer + /// (for example 42 in /item/42). Use it to restrict a route parameter to whole numbers. /// /// The parameter type. public class UriPathSegmentVariableInt : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs index f584bbc3..e58624d1 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableRegex.cs @@ -4,7 +4,9 @@ namespace WebExpress.WebCore.WebUri { /// - /// Variable path segment. + /// A placeholder path segment whose accepted values are defined by a caller-supplied regular + /// expression. Use it when none of the built-in typed segments (int, double, GUID, string) + /// captures the exact format you need. /// /// The parameter type. public class UriPathSegmentVariableRegex : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs index b7fd3bac..da63b3cb 100644 --- a/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs +++ b/src/WebExpress.WebCore/WebUri/UriPathSegmentVariableString.cs @@ -4,7 +4,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// String variable path segment. + /// A placeholder path segment that matches any free-form text at that position (any value + /// without a double quote). This is the default, least restrictive variable segment. /// /// The parameter type. public class UriPathSegmentVariableString : UriPathSegmentVariable diff --git a/src/WebExpress.WebCore/WebUri/UriScheme.cs b/src/WebExpress.WebCore/WebUri/UriScheme.cs index edd16f2f..b81b1c61 100644 --- a/src/WebExpress.WebCore/WebUri/UriScheme.cs +++ b/src/WebExpress.WebCore/WebUri/UriScheme.cs @@ -1,7 +1,8 @@ namespace WebExpress.WebCore.WebUri { /// - /// The type of the URI. + /// The scheme (protocol) at the start of a URI — the part before the colon, such as + /// http in http://example.com. It tells the client how to reach the resource. /// public enum UriScheme { From f35ec600e51346935ea046664f9dae50056d0299 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 22 Jun 2026 19:06:14 +0200 Subject: [PATCH 14/69] feat: HTTP/2 readiness and configurable Kestrel protocols --- .../Config/UnitTestKestrelConfig.cs | 68 +++++++++++ .../Message/UnitTestResponseSender.cs | 109 ++++++++++++++++++ .../Config/KestrelConfig.cs | 36 +++++- src/WebExpress.WebCore/HttpServer.cs | 30 +++-- .../WebMessage/ResponseSender.cs | 29 ++++- 5 files changed, 261 insertions(+), 11 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs diff --git a/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs b/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs index a6b6754d..12e03fdd 100644 --- a/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs +++ b/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs @@ -1,5 +1,6 @@ using System.IO; using System.Xml.Serialization; +using Microsoft.AspNetCore.Server.Kestrel.Core; using WebExpress.WebCore.Config; namespace WebExpress.WebCore.Test.Config @@ -147,5 +148,72 @@ public void RequestLimitsAreDeserialized() Assert.Equal(3000000000, kestrel.MaxRequestBodySize); Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); } + + /// + /// Tests that the protocols element is read and resolved to the matching Kestrel value, + /// including case-insensitive parsing. + /// + [Theory] + [InlineData("Http1", HttpProtocols.Http1)] + [InlineData("Http2", HttpProtocols.Http2)] + [InlineData("Http1AndHttp2", HttpProtocols.Http1AndHttp2)] + [InlineData("http2", HttpProtocols.Http2)] + public void ProtocolsAreResolved(string value, HttpProtocols expected) + { + // arrange + var xml = + "" + + " " + + $" {value}" + + ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.Equal(value, kestrel.Protocols); + Assert.Equal(expected, kestrel.ResolveProtocols()); + } + + /// + /// Tests that a missing protocols element resolves to null so the Kestrel default is kept. + /// + [Fact] + public void ProtocolsDefaultToNull() + { + // arrange + var xml = ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.Null(kestrel.Protocols); + Assert.Null(kestrel.ResolveProtocols()); + } + + /// + /// Tests that an unrecognised protocols value resolves to null instead of applying an + /// unintended restriction, so a typo cannot silently disable HTTP/2. + /// + [Theory] + [InlineData("Http9")] + [InlineData("999")] + [InlineData("nonsense")] + public void UnknownProtocolsResolveToNull(string value) + { + // arrange + var xml = + "" + + " " + + $" {value}" + + ""; + + // act + var kestrel = Deserialize(xml).Kestrel; + + // validation + Assert.Null(kestrel.ResolveProtocols()); + } } } diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs new file mode 100644 index 00000000..9ca862de --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs @@ -0,0 +1,109 @@ +using System.IO.Pipelines; +using System.Net; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for , focusing on the handling of connection-specific + /// headers across HTTP protocol versions where their validity differs. + /// + public class UnitTestResponseSender + { + /// + /// Sends the given response through the sender on a connection negotiated with the specified + /// protocol and returns the populated response feature for header inspection. + /// + /// The negotiated protocol, e.g. "HTTP/1.1" or "HTTP/2". + /// The response to send. + /// The response feature carrying the emitted headers and status. + private static async Task Send(string protocol, IResponse response) + { + var features = new FeatureCollection(); + features.Set(new HttpRequestFeature { Protocol = protocol, Headers = new HeaderDictionary() }); + features.Set(new HttpResponseFeature { Headers = new HeaderDictionary() }); + features.Set(new FakeResponseBodyFeature()); + + await new ResponseSender().SendAsync(new FakeHttpContext(features), response); + + return features.Get(); + } + + /// + /// Tests that the connection-specific Keep-Alive header is never emitted: persistence is + /// owned by Kestrel and the header is forbidden on HTTP/2. + /// + [Theory] + [InlineData("HTTP/1.1")] + [InlineData("HTTP/2")] + public async Task KeepAliveHeaderIsNeverSent(string protocol) + { + var feature = await Send(protocol, new ResponseOK()); + + Assert.False(feature.Headers.ContainsKey("Keep-Alive")); + } + + /// + /// Tests that the websocket handshake headers are emitted on HTTP/1.x, where they are valid. + /// + [Fact] + public async Task ConnectionAndUpgradeEmittedOnHttp1() + { + var response = new ResponseSwitchingProtocols("upgrade", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + var feature = await Send("HTTP/1.1", response); + + Assert.True(feature.Headers.ContainsKey("Connection")); + Assert.True(feature.Headers.ContainsKey("Upgrade")); + } + + /// + /// Tests that the connection-specific Connection and Upgrade headers are suppressed on + /// HTTP/2, where Kestrel rejects them (RFC 9113 §8.2.2). + /// + [Fact] + public async Task ConnectionAndUpgradeSuppressedOnHttp2() + { + var response = new ResponseSwitchingProtocols("upgrade", "websocket", "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + + var feature = await Send("HTTP/2", response); + + Assert.False(feature.Headers.ContainsKey("Connection")); + Assert.False(feature.Headers.ContainsKey("Upgrade")); + } + + /// + /// A minimal backed by an explicit feature collection, used to + /// drive the sender without constructing a full request pipeline. + /// + private sealed class FakeHttpContext : IHttpContext + { + public FakeHttpContext(IFeatureCollection features) => Features = features; + + public IHttpServerContext HttpServerContext => null; + public string Id => "test"; + public IRequest Request => null; + public EndPoint LocalEndPoint => new IPEndPoint(IPAddress.Loopback, 80); + public EndPoint RemoteEndPoint => new IPEndPoint(IPAddress.Loopback, 12345); + public IFeatureCollection Features { get; } + public Encoding Encoding => Encoding.UTF8; + public Uri Uri => new("http://localhost/"); + } + + /// + /// A minimal response body feature that discards the written body into an in-memory stream. + /// + private sealed class FakeResponseBodyFeature : IHttpResponseBodyFeature + { + public Stream Stream { get; } = new MemoryStream(); + public PipeWriter Writer => PipeWriter.Create(Stream); + public Task CompleteAsync() => Task.CompletedTask; + public void DisableBuffering() { } + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } + } +} diff --git a/src/WebExpress.WebCore/Config/KestrelConfig.cs b/src/WebExpress.WebCore/Config/KestrelConfig.cs index 1907ee3e..0ff222a8 100644 --- a/src/WebExpress.WebCore/Config/KestrelConfig.cs +++ b/src/WebExpress.WebCore/Config/KestrelConfig.cs @@ -1,4 +1,6 @@ -using System.Xml.Serialization; +using System; +using System.Xml.Serialization; +using Microsoft.AspNetCore.Server.Kestrel.Core; namespace WebExpress.WebCore.Config { @@ -31,6 +33,16 @@ public sealed class KestrelConfig [XmlElement("addserverheader")] public bool? AddServerHeader { get; set; } + /// + /// The HTTP protocols enabled on every listening endpoint, given as the name of a Kestrel + /// value (e.g. Http1, Http2 or Http1AndHttp2). + /// When not specified the Kestrel default (Http1AndHttp2) is kept, which negotiates + /// HTTP/2 over TLS via ALPN and serves plain HTTP as HTTP/1.1. Set Http2 on a plain + /// (non-TLS) endpoint to enable cleartext HTTP/2 (h2c), which has no automatic upgrade path. + /// + [XmlElement("protocols")] + public string Protocols { get; set; } + /// /// The maximum number of concurrent client connections. When not specified the Kestrel /// default (unlimited) is kept. @@ -93,6 +105,28 @@ public sealed class KestrelConfig [XmlElement("requestheaderstimeout")] public int? RequestHeadersTimeout { get; set; } + /// + /// Resolves the configured name to the corresponding Kestrel + /// value. Returns null when nothing was configured or the + /// value is not a recognised protocol name, so the caller keeps the Kestrel default instead + /// of silently applying an unintended restriction from a typo. + /// + /// The parsed protocols, or null to keep the Kestrel default. + public HttpProtocols? ResolveProtocols() + { + if (string.IsNullOrWhiteSpace(Protocols)) + { + return null; + } + + // Enum.TryParse alone would accept arbitrary numbers for a flags enum, so the result is + // additionally constrained to a named value to reject unknown or nonsensical combinations + return Enum.TryParse(Protocols, ignoreCase: true, out var result) + && Enum.IsDefined(typeof(HttpProtocols), result) + ? result + : null; + } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 8ac3eb7f..89d1466b 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -204,9 +204,11 @@ public void Start() limits.RequestHeadersTimeout = TimeSpan.FromSeconds(kestrel.RequestHeadersTimeout.Value); } + var protocols = kestrel?.ResolveProtocols(); + foreach (var endpoint in Config.Endpoints) { - AddEndpoint(serverOptions, endpoint); + AddEndpoint(serverOptions, endpoint, protocols); } Kestrel = new KestrelServer(serverOptions, transport, logger); @@ -226,7 +228,8 @@ public void Start() /// /// The server options. /// The endpoint. - private void AddEndpoint(OptionsWrapper serverOptions, EndpointConfig endPoint) + /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. + private void AddEndpoint(OptionsWrapper serverOptions, EndpointConfig endPoint, HttpProtocols? protocols) { try { @@ -249,12 +252,12 @@ private void AddEndpoint(OptionsWrapper serverOptions, End { case "HTTPS": { - AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password); + AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password, protocols); break; } default: { - AddEndpoint(serverOptions, ep); + AddEndpoint(serverOptions, ep, protocols); break; } } @@ -272,9 +275,16 @@ private void AddEndpoint(OptionsWrapper serverOptions, End /// /// The server options. /// The endpoint. - private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint) + /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. + private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, HttpProtocols? protocols) { - serverOptions.Value.Listen(endPoint); + serverOptions.Value.Listen(endPoint, configure => + { + if (protocols is not null) + { + configure.Protocols = protocols.Value; + } + }); HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.listen"), args: endPoint.ToString()); } @@ -285,12 +295,18 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE /// The endpoint. /// The path to the PFX file containing the certificate. /// The password for the PFX file. - private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, string pfxFile, string password) + /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. + private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, string pfxFile, string password, HttpProtocols? protocols) { serverOptions.Value.Listen(endPoint, configure => { var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, password, X509KeyStorageFlags.DefaultKeySet); configure.UseHttps(cert); + + if (protocols is not null) + { + configure.Protocols = protocols.Value; + } }); HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.listen"), args: endPoint.ToString()); diff --git a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs index 6e023154..d4ed9d37 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs @@ -40,7 +40,11 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA responseFeature.StatusCode = response.Status; responseFeature.ReasonPhrase = response.Reason; - responseFeature.Headers.KeepAlive = "true"; + + // Connection and Upgrade are connection-specific headers: they drive the HTTP/1.x + // websocket handshake but are forbidden on HTTP/2+ (RFC 9113 §8.2.2), where Kestrel + // rejects them. Only emit them while the connection still speaks HTTP/1.x. + var allowConnectionSpecificHeaders = !IsHttp2OrHigher(context); if (response.Header.Location is not null) { @@ -80,12 +84,12 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA } } - if (!string.IsNullOrWhiteSpace(response.Header.Upgrade)) + if (allowConnectionSpecificHeaders && !string.IsNullOrWhiteSpace(response.Header.Upgrade)) { responseFeature.Headers.Upgrade = response.Header.Upgrade; } - if (!string.IsNullOrWhiteSpace(response.Header.Connection)) + if (allowConnectionSpecificHeaders && !string.IsNullOrWhiteSpace(response.Header.Connection)) { responseFeature.Headers.Connection = response.Header.Connection; } @@ -131,6 +135,25 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA } } + /// + /// Determines whether the negotiated protocol is HTTP/2 or higher, where connection-specific + /// headers such as Connection and Upgrade are forbidden (RFC 9113 §8.2.2) and would be + /// rejected by Kestrel. On HTTP/1.x these headers remain valid and carry the websocket + /// handshake semantics. + /// + /// The request context whose negotiated protocol is inspected. + /// True when the protocol is HTTP/2 or higher; otherwise false. + private static bool IsHttp2OrHigher(IHttpContext context) + { + // read straight from the request feature so the check does not depend on a fully + // materialised request object (e.g. on the websocket or exception context paths) + var protocol = context?.Features?.Get()?.Protocol; + + return protocol is not null + && (protocol.StartsWith("HTTP/2", StringComparison.OrdinalIgnoreCase) + || protocol.StartsWith("HTTP/3", StringComparison.OrdinalIgnoreCase)); + } + /// /// Serialises a as a single Set-Cookie header /// value preserving Path, Domain, Expires and the HttpOnly / Secure From f12af0fdb636f5cc27a9627f4d42bdfe23a22d2e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 22 Jun 2026 22:30:34 +0200 Subject: [PATCH 15/69] perf: resolve endpoint URIs in O(1) and cut reflection in the render path --- .../Html/UnitTestDeterministicId.cs | 19 +++ .../Internationalization/en | 6 +- .../WebEndpoint/EndpointManager.cs | 48 +++++-- .../WebHtml/DeterministicId.cs | 118 ++++-------------- src/WebExpress.WebCore/WebLog/Log.cs | 23 ++-- .../WebSitemap/SitemapManager.cs | 56 ++++++++- 6 files changed, 146 insertions(+), 124 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs index d2aec909..8dc19912 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestDeterministicId.cs @@ -55,6 +55,25 @@ public void CreateDifferentCallsites() } + /// + /// Tests that repeated calls from the same call site (as happens when controls are created + /// in a loop) all yield distinct ids. The previous call-stack based implementation produced + /// duplicates here because the stack is identical across iterations. + /// + [Fact] + public void CreateInLoopAreUnique() + { + // act + var ids = new HashSet(); + for (var i = 0; i < 1000; i++) + { + ids.Add(DeterministicId.Create()); + } + + // validation + Assert.Equal(1000, ids.Count); + } + /// /// Generates a deterministic identifier. /// diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 376d2d93..678e19ff 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -21,9 +21,9 @@ httpserver.notsupported=The operating system on which WebExpress is to be run is httpserver.start=The HttpServer was started on {0} at {1} o'clock. httpserver.connected={0}: Client has been connected. httpserver.rejected={0}: Client rejected. -httpserver.request={0}: Request '{1}' -httpserver.unexpected.request={0}: Unexpected request '{1}' -httpserver.request.done={0}: Request has been processed in {1} ms. Status = {2} +httpserver.request={0}: Request #{1} '{2}' +httpserver.request.unexpected={0}: Unexpected request '{1}' +httpserver.request.done={0}: Request #{1} has been processed in {2} ms. Status = {3} httpserver.connectionlimit=The limit for concurrent requests has been exceeded. httpserver.endpoint=Endpoint '{0}' is registered. httpserver.listen=The web server listens for the endpoint {0}. diff --git a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs index 6ccde3bd..9a6b0308 100644 --- a/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs +++ b/src/WebExpress.WebCore/WebEndpoint/EndpointManager.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -24,6 +25,12 @@ public sealed class EndpointManager : IEndpointManager, ISystemComponent private readonly IHttpServerContext _httpServerContext; private readonly Dictionary _registrations = []; + // an assembly's type set is immutable at runtime, so it is memoized here. CreateEndpointRoute + // would otherwise call Assembly.GetTypes() (a full type enumeration) for every route segment + // of every endpoint during registration - repeatedly, because the segment query is enumerated + // more than once. + private static readonly ConcurrentDictionary _assemblyTypes = new(); + /// /// An event that fires when an endpoint is added. /// @@ -143,6 +150,18 @@ public void Dispose() { } + /// + /// Returns the types declared in the given assembly, memoized for the lifetime of the + /// process. The type set of a loaded assembly does not change, so caching it avoids the + /// repeated full enumeration that route creation would otherwise trigger per segment. + /// + /// The assembly whose types are requested. + /// The assembly's declared types. + private static Type[] GetAssemblyTypes(Assembly assembly) + { + return _assemblyTypes.GetOrAdd(assembly, a => a.GetTypes()); + } + /// /// Returns the route of an endpoint based on the class type, application context and segment attributes. /// @@ -184,8 +203,7 @@ public static IRoute CreateEndpointRoute var icon = default(IIcon); var hidden = false; - var segmentInfoType = classType.Assembly - .GetTypes() + var segmentInfoType = GetAssemblyTypes(classType.Assembly) .Where(t => t.IsClass) .Where(t => t.Namespace?.ToLowerInvariant() == s.FullNamespace.ToLowerInvariant()) .FirstOrDefault(t => t.Name.StartsWith("Index", StringComparison.OrdinalIgnoreCase)); @@ -232,22 +250,26 @@ public static IRoute CreateEndpointRoute Icon = icon, Hidden = segmentResult is null || hidden }; - }); + }).ToList(); + // materialized once so the reflection-heavy projection runs a single time per segment; + // leading namespace-prefix segments are then dropped via a computed skip count instead of + // re-enumerating (and thus re-projecting) the sequence multiple times. + var skip = 0; var firstMapping = segmentAttributesMapping.FirstOrDefault(); - segmentAttributesMapping = firstMapping is not null && _namespacePrefixes - .Contains(firstMapping.Segment?.ToString()) - ? segmentAttributesMapping.Skip(1) - : segmentAttributesMapping; + if (firstMapping is not null && _namespacePrefixes.Contains(firstMapping.Segment?.ToString())) + { + skip = 1; + } - firstMapping = segmentAttributesMapping.FirstOrDefault(); - segmentAttributesMapping = firstMapping is not null && (namespacePrefixes ?? []) - .Contains(firstMapping.Segment?.ToString()) - ? segmentAttributesMapping.Skip(1) - : segmentAttributesMapping; + firstMapping = segmentAttributesMapping.Skip(skip).FirstOrDefault(); + if (firstMapping is not null && (namespacePrefixes ?? []).Contains(firstMapping.Segment?.ToString())) + { + skip++; + } var endpointRoute = (intermediateSegments ?? []) - .Concat(segmentAttributesMapping.Where(x => !x.Segment.IsEmpty).Select(x => x.Segment)); + .Concat(segmentAttributesMapping.Skip(skip).Where(x => !x.Segment.IsEmpty).Select(x => x.Segment)); var classSegment = !className.StartsWith(_indexPrefix) ? segment?.ToPathSegment() ?? new UriPathSegmentConstant(className) diff --git a/src/WebExpress.WebCore/WebHtml/DeterministicId.cs b/src/WebExpress.WebCore/WebHtml/DeterministicId.cs index 7764b699..a72bf32f 100644 --- a/src/WebExpress.WebCore/WebHtml/DeterministicId.cs +++ b/src/WebExpress.WebCore/WebHtml/DeterministicId.cs @@ -1,51 +1,43 @@ -using System.Collections.Concurrent; -using System.Diagnostics; using System.Runtime.CompilerServices; -using System.Text; +using System.Threading; namespace WebExpress.WebCore.WebHtml { /// - /// Provides methods for generating deterministic unique identifiers - /// based on the caller's file path, line number, and an optional - /// index value. + /// Provides unique identifiers for HTML elements that need a stable handle within a rendered + /// page, for example to wire a label to its input. /// /// - /// This class utilizes a caching mechanism to ensure that repeated - /// calls with the same parameters return the same identifier, - /// enhancing performance and consistency. + /// Uniqueness is guaranteed by a process-wide monotonic counter, which is all the generated + /// markup requires: every control instance receives a distinct id within the page it renders on. + /// An earlier implementation derived the id from the caller's source location and full call + /// stack, which made every call walk the managed stack with per-frame reflection. That was + /// roughly three orders of magnitude slower (~100 us instead of nanoseconds) and ran once per + /// control construction, so it dominated server-side render time on control-heavy pages. It also + /// produced duplicate ids for repeated calls from the same source line (e.g. inside a loop), + /// because the call stack is identical across iterations. /// public static class DeterministicId { - private static readonly ConcurrentDictionary Cache = new(); + private static long _counter; /// - /// Generates a deterministic unique identifier based on the caller's file - /// path, line number, and an optional index value. + /// Returns a new identifier that is unique within the running process and is suitable as an + /// HTML element id. /// - /// - /// This method uses a caching mechanism to ensure that repeated calls with - /// the same parameters return the same identifier. The generated identifier - /// is based on a FNV-1a hash of the signature formed from the file path, - /// line number, and index. - /// /// - /// An optional object that provides additional context for generating the - /// identifier. If specified, its hash code is included in the identifier - /// to further distinguish it. + /// An optional disambiguator. It no longer influences the result, because the counter + /// already guarantees uniqueness; it is retained for source and binary compatibility. /// /// - /// The full path of the source file where the method is called. This - /// value is automatically supplied by the compiler. + /// Unused. Retained only for binary compatibility: the C# compiler bakes the + /// value into every existing call site, so removing + /// the parameter would break already-compiled callers with a MissingMethodException. /// /// - /// The line number in the source file where the method is called. This - /// value is automatically supplied by the compiler. + /// Unused. Retained only for binary compatibility, see . /// - /// - /// A unique identifier string that represents the combination of the file path, - /// line number, and optional index. - /// + /// A unique identifier of the form id_{hex}. public static string Create ( object context = null, @@ -53,73 +45,7 @@ public static string Create [CallerLineNumber] int line = 0 ) { - var stack = new StackTrace(skipFrames: 1, fNeedFileInfo: false); - var frames = stack.GetFrames(); - - var sb = new StringBuilder(128); - - sb.Append(file); - sb.Append(':'); - sb.Append(line); - - if (context is not null) - { - sb.Append(':'); - sb.Append(context.GetHashCode()); - } - - foreach (var f in frames) - { - var m = f.GetMethod(); - sb.Append(m.Name); - sb.Append(f.GetILOffset()); - } - - var signature = sb.ToString(); - - if (Cache.TryGetValue(signature, out var cached)) - { - return cached; - } - - // fnv-1a hash - var hash = Fnv1a(signature); - - var id = "id_" + hash.ToString("X"); - - Cache[signature] = id; - - return id; - } - - /// - /// Calculates the 32-bit FNV-1a hash value for the specified string. - /// - /// - /// The FNV-1a algorithm is a non-cryptographic hash function known - /// for its simplicity and speed. It is commonly used for hash tables - /// and checksums, but should not be used for cryptographic purposes. - /// - /// - /// The input string for which to compute the hash. This parameter - /// cannot be null. - /// - /// - /// A 32-bit unsigned integer representing the FNV-1a hash of the - /// input string. - /// - private static uint Fnv1a(string text) - { - unchecked - { - uint hash = 2166136261; - for (int i = 0; i < text.Length; i++) - { - hash = (hash ^ text[i]) * 16777619; - } - - return hash; - } + return "id_" + Interlocked.Increment(ref _counter).ToString("X"); } } } diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index f5b1376f..3d2f2516 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -569,15 +569,19 @@ public void Exception(Exception exception, [CallerMemberName] string instance = /// The source file. public void Debug(string message, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null) { + // capturing the call stack to derive the class name is expensive; skip it entirely when + // debug output is disabled, which is the common case outside of troubleshooting. + if (!DebugMode) + { + return; + } + try { var methodInfo = new StackTrace().GetFrame(1)?.GetMethod(); var className = methodInfo?.ReflectedType.Name; - if (DebugMode) - { - Add(LogLevel.Debug, message, $"{className}.{instance}", line, file); - } + Add(LogLevel.Debug, message, $"{className}.{instance}", line, file); } catch (Exception ex) { @@ -595,15 +599,18 @@ public void Debug(string message, [CallerMemberName] string instance = null, [Ca /// Parameter für die Formatierung der Nachricht public void Debug(string message, [CallerMemberName] string instance = null, [CallerLineNumber] int? line = null, [CallerFilePath] string file = null, params object[] args) { + // see the parameterless-args overload: avoid the stack walk unless debug output is on. + if (!DebugMode) + { + return; + } + try { var methodInfo = new StackTrace().GetFrame(1)?.GetMethod(); var className = methodInfo?.ReflectedType.Name; - if (DebugMode) - { - Add(LogLevel.Debug, string.Format(message, args), $"{className}.{instance}", line, file); - } + Add(LogLevel.Debug, string.Format(message, args), $"{className}.{instance}", line, file); } catch (Exception ex) { diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 39ac0c7d..5016e774 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -19,6 +19,11 @@ namespace WebExpress.WebCore.WebSitemap public sealed class SitemapManager : ISitemapManager, ISystemComponent { private SitemapNode _root = new(); + + // maps an endpoint context to its sitemap node so GetUri resolves a route in O(1) instead of + // rebuilding and linearly scanning the whole sitemap tree on every call. rebuilt by Refresh + // alongside _root; the stored order preserves the previous pre-order "first match" tiebreak. + private Dictionary _endpointIndex = new(); private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly IUri _serverUri; @@ -98,7 +103,21 @@ public void Refresh() )); } + var index = new Dictionary(); + var order = 0; + foreach (var node in newSiteMapNode.GetPreOrder()) + { + // first pre-order occurrence wins, mirroring the previous FirstOrDefault over GetPreOrder + if (node.EndpointContext is not null) + { + index.TryAdd(node.EndpointContext, (node, order)); + } + + order++; + } + _root = newSiteMapNode; + _endpointIndex = index; Log(); } @@ -164,8 +183,7 @@ public IUri GetUri(Type endpointType, IApplicationContext applicationContext, pa { var endpointContexts = _componentHub?.EndpointManager.GetEndpoints(endpointType, applicationContext); - var node = _root.GetPreOrder() - .FirstOrDefault(x => endpointContexts.Contains(x.EndpointContext)); + var node = ResolveNode(endpointContexts); return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null).BindParameters(parameters); } @@ -190,8 +208,7 @@ public IUri GetUri(IEndpointContext endpointContext) var endpointContexts = _componentHub?.EndpointManager.GetEndpoints(typeof(TEnpoint), endpointContext.ApplicationContext) .Where(x => x.EndpointId.Equals(endpointContext.EndpointId)); - var node = _root.GetPreOrder() - .FirstOrDefault(x => endpointContexts.Contains(x.EndpointContext)); + var node = ResolveNode(endpointContexts); if (node is null) { @@ -202,6 +219,37 @@ public IUri GetUri(IEndpointContext endpointContext) return new UriEndpoint(_serverUri, node?.EndpointContext?.Route.PathSegments, null); } + /// + /// Resolves the sitemap node whose endpoint context appears first in pre-order among the + /// given candidates. The lookup uses the precomputed endpoint index, replacing a full + /// rebuild and linear scan of the sitemap tree (previously the dominant render-time cost) with + /// an O(1) lookup per candidate. + /// + /// The candidate endpoint contexts, or null. + /// The matching node, or null when no candidate is part of the sitemap. + private SitemapNode ResolveNode(IEnumerable endpointContexts) + { + if (endpointContexts is null) + { + return null; + } + + var index = _endpointIndex; + SitemapNode best = null; + var bestOrder = int.MaxValue; + + foreach (var ctx in endpointContexts) + { + if (ctx is not null && index.TryGetValue(ctx, out var hit) && hit.Order < bestOrder) + { + best = hit.Node; + bestOrder = hit.Order; + } + } + + return best; + } + /// /// Retrieves the endpoint context associated with the given URI. /// From b3bdf6634a5a2704b5d65245013c58651a186ea0 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 10 Jul 2026 04:31:18 +0200 Subject: [PATCH 16/69] feat: general improvements and minor bugs --- .../Data/MockIdentityFactory.cs | 4 ++-- .../Data/MockIdentityGroup.cs | 14 +++++++------- .../Data/MockIdentityProvider.cs | 8 ++++---- .../Manager/UnitTestResourceManager.cs | 4 ++-- .../WebIdentity/IIdentityGroup.cs | 2 +- .../WebIdentity/IIdentityManager.cs | 5 ++--- .../WebIdentity/IIdentityProvider.cs | 6 +++--- .../WebIdentity/IdentityManager.cs | 11 +++++------ .../WebResource/IResourceManager.cs | 10 +++++----- .../WebResource/ResourceManager.cs | 14 +++++++------- 10 files changed, 38 insertions(+), 40 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs index afb77fc9..8082f8ba 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityFactory.cs @@ -38,12 +38,12 @@ public static IIdentityGroup GetIdentityGroup(string name) private static IEnumerable CreateTestGroups() { var group1 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Admins"); - group1.Assign(["webexpress.webcore.test.testidentitypolicya", "webexpress.webcore.test.testidentitypolicyb"]); + group1.Assign([new TestIdentityPolicyA(), new TestIdentityPolicyB()]); yield return group1; var group2 = new MockIdentityGroup(id: Guid.NewGuid(), name: "Users"); - group2.Assign(["webexpress.webcore.test.testidentitypolicyb"]); + group2.Assign([new TestIdentityPolicyB()]); yield return group2; diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs index 0c30ec15..0ec0957f 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityGroup.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.Test.Data /// internal class MockIdentityGroup : IIdentityGroup { - private readonly List _roles = []; + private readonly List _policies = []; /// /// Gets or sets the id of the group. @@ -20,9 +20,9 @@ internal class MockIdentityGroup : IIdentityGroup public string Name { get; set; } /// - /// Gets the roles associated with the group. + /// Gets the policies associated with the group. /// - public IEnumerable Policies => _roles; + public IEnumerable Policies => _policies; /// /// Initializes a new instance of the class with the specified id and name. @@ -36,12 +36,12 @@ public MockIdentityGroup(Guid id, string name) } /// - /// Assigns roles. + /// Assigns policies. /// - /// The list of roles to assign group to. - public void Assign(IEnumerable roles) + /// The list of policies to assign to the group. + public void Assign(IEnumerable policies) { - _roles.AddRange(roles); + _policies.AddRange(policies); } } } diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs index 48e08125..fadb3de2 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs @@ -1,6 +1,6 @@ -using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebIdentity; using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.Test.Data { @@ -84,7 +84,7 @@ public void Logout(IRequest request) /// An object that represents the response to the login dialog, including authentication results and any /// relevant status information. /// - public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity) + public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity) { return null; } @@ -106,7 +106,7 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initi /// A response representing the forbidden page if this provider can handle the forbidden /// scenario; otherwise, null. /// - public IResponse CreateForbiddenPage(IRequest request, IPageContext initiator, IIdentity identity) + public IResponse CreateForbiddenPage(IRequest request, IEndpointContext initiator, IIdentity identity) { return null; } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs index 18ebb837..efb31d7d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestResourceManager.cs @@ -62,7 +62,7 @@ public void Id(Type applicationType, Type resourceType, string id) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); - var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); + var resource = componentHub.ResourceManager.GetResources(resourceType, application)?.FirstOrDefault(); // act Assert.Equal(id, resource?.EndpointId.ToString()); @@ -90,7 +90,7 @@ public void RoutePath(Type applicationType, Type resourceType, string path) // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); - var resource = componentHub.ResourceManager.GetResorces(resourceType, application)?.FirstOrDefault(); + var resource = componentHub.ResourceManager.GetResources(resourceType, application)?.FirstOrDefault(); // act Assert.Equal(path, resource.Route.ToString()); diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs index 2c3260c5..cc4680fd 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityGroup.cs @@ -22,6 +22,6 @@ public interface IIdentityGroup /// /// Gets the policies associated with the group. /// - IEnumerable Policies { get; } + IEnumerable Policies { get; } } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index b30176de..9b1284f8 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -4,7 +4,6 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.WebIdentity @@ -41,7 +40,7 @@ public interface IIdentityManager : IComponentManager /// An object that represents the response to the login dialog, including authentication results and any /// relevant status information. /// - IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity = null); + IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity = null); /// /// Creates a forbidden response page for the specified request when the authenticated @@ -61,7 +60,7 @@ public interface IIdentityManager : IComponentManager /// A response representing the forbidden page if a registered identity provider can handle the /// forbidden scenario; otherwise, null. /// - IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity); + IResponse CreateForbiddenResponse(IRequest request, IEndpointContext initiator, IIdentity identity); /// /// Login an identity. diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs index d80d9195..697e1261 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; +using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebIdentity { @@ -37,7 +37,7 @@ public interface IIdentityProvider /// An object that represents the response to the login dialog, including authentication results and any /// relevant status information. /// - IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity); + IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity); /// /// Creates a forbidden response page for the specified request when the authenticated @@ -57,6 +57,6 @@ public interface IIdentityProvider /// A response representing the forbidden page if this provider can handle the forbidden /// scenario; otherwise, null. /// - IResponse CreateForbiddenPage(IRequest request, IPageContext initiator, IIdentity identity); + IResponse CreateForbiddenPage(IRequest request, IEndpointContext initiator, IIdentity identity); } } diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 4b8d3f32..5f3fdb2a 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -12,7 +12,6 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebIdentity.Model; using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebSession.Model; @@ -386,7 +385,7 @@ private void OnAddApplication(object sender, IApplicationContext e) /// An object that represents the response to the login dialog, including authentication results /// and any relevant status information. /// - public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity = null) + public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity = null) { if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) { @@ -416,7 +415,7 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initi /// A response representing the forbidden page if a registered identity provider can handle the /// forbidden scenario; otherwise, null. /// - public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) + public IResponse CreateForbiddenResponse(IRequest request, IEndpointContext initiator, IIdentity identity) { if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) { @@ -526,8 +525,8 @@ public bool CheckAccess(IIdentityGroup group, IIdentityPolicy policy) return false; } - // check if any string policy matches the full name of the required policy - return group.Policies?.Any(currentPolicy => string.Equals(currentPolicy, policy.GetType().FullName, StringComparison.OrdinalIgnoreCase)) ?? false; + // a group carries policy instances; the required policy matches by type + return group.Policies?.Any(currentPolicy => currentPolicy?.GetType() == policy.GetType()) ?? false; } /// @@ -580,7 +579,7 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentityGrou /// True if the identity group has the permission, false otherwise. public bool CheckAccess(IApplicationContext applicationContext, IIdentityGroup group, Type permission) { - return (group?.Policies ?? []).Any(policy => CheckAccess(applicationContext, policy, permission)); + return (group?.Policies ?? []).Any(policy => CheckAccess(applicationContext, policy.GetType(), permission)); } /// diff --git a/src/WebExpress.WebCore/WebResource/IResourceManager.cs b/src/WebExpress.WebCore/WebResource/IResourceManager.cs index 131fe8d4..e3c2b430 100644 --- a/src/WebExpress.WebCore/WebResource/IResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/IResourceManager.cs @@ -31,21 +31,21 @@ public interface IResourceManager : IComponentManager /// /// A context of a plugin whose resources are to be registered. /// An enumeration of resource contexts. - IEnumerable GetResorces(IPluginContext pluginContext); + IEnumerable GetResources(IPluginContext pluginContext); /// /// Returns an enumeration of resource contextes. /// /// The resource type. /// An enumeration of resource contextes. - IEnumerable GetResorces() where T : IResource; + IEnumerable GetResources() where T : IResource; /// /// Returns an enumeration of resource contextes. /// /// The resource type. /// An enumeration of resource contextes. - IEnumerable GetResorces(Type resourceType); + IEnumerable GetResources(Type resourceType); /// /// Returns an enumeration of resource contextes. @@ -53,7 +53,7 @@ public interface IResourceManager : IComponentManager /// The resource type. /// The context of the application. /// An enumeration of resource contextes. - IEnumerable GetResorces(Type resourceType, IApplicationContext applicationContext); + IEnumerable GetResources(Type resourceType, IApplicationContext applicationContext); /// /// Returns an enumeration of resource contextes. @@ -61,7 +61,7 @@ public interface IResourceManager : IComponentManager /// The resource type. /// The context of the application. /// An enumeration of resource contextes. - IEnumerable GetResorces(IApplicationContext applicationContext) where T : IResource; + IEnumerable GetResources(IApplicationContext applicationContext) where T : IResource; /// /// Returns the resource context. diff --git a/src/WebExpress.WebCore/WebResource/ResourceManager.cs b/src/WebExpress.WebCore/WebResource/ResourceManager.cs index 33354133..66f8fd91 100644 --- a/src/WebExpress.WebCore/WebResource/ResourceManager.cs +++ b/src/WebExpress.WebCore/WebResource/ResourceManager.cs @@ -82,7 +82,7 @@ private ResourceManager(IComponentHub componentHub, IHttpServerContext httpServe EndpointResolver = (type, applicationContext) => { // return appropriate endpoints based on applicationContext - return applicationContext is not null ? GetResorces(type, applicationContext) : GetResorces(type); + return applicationContext is not null ? GetResources(type, applicationContext) : GetResources(type); }, EndpointsResolver = () => { @@ -371,7 +371,7 @@ internal void Remove(IApplicationContext applicationContext) /// /// A context of a plugin whose resources are to be registered. /// An enumeration of resource contexts. - public IEnumerable GetResorces(IPluginContext pluginContext) + public IEnumerable GetResources(IPluginContext pluginContext) { lock (_guard) { @@ -392,9 +392,9 @@ public IEnumerable GetResorces(IPluginContext pluginContext) /// /// The resource type. /// An enumeration of resource contexts. - public IEnumerable GetResorces() where T : IResource + public IEnumerable GetResources() where T : IResource { - return GetResorces(typeof(T)); + return GetResources(typeof(T)); } /// @@ -402,7 +402,7 @@ public IEnumerable GetResorces() where T : IResource /// /// The resource type. /// An enumeration of resource contexts. - public IEnumerable GetResorces(Type resourceType) + public IEnumerable GetResources(Type resourceType) { lock (_guard) { @@ -421,7 +421,7 @@ public IEnumerable GetResorces(Type resourceType) /// The resource type. /// The context of the application. /// An enumeration of resource contexts. - public IEnumerable GetResorces(Type resourceType, IApplicationContext applicationContext) + public IEnumerable GetResources(Type resourceType, IApplicationContext applicationContext) { lock (_guard) { @@ -441,7 +441,7 @@ public IEnumerable GetResorces(Type resourceType, IApplication /// The resource type. /// The context of the application. /// An enumeration of resource contexts. - public IEnumerable GetResorces(IApplicationContext applicationContext) where T : IResource + public IEnumerable GetResources(IApplicationContext applicationContext) where T : IResource { lock (_guard) { From e83f815d150808ca5ffe86a8027ca79d6e71fc9e Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Fri, 10 Jul 2026 21:25:36 +0200 Subject: [PATCH 17/69] feat: add table templates and minor bugs --- .../Data/MockIdentityProvider.cs | 8 ++++---- .../WebIdentity/IIdentityManager.cs | 12 +++++++----- .../WebIdentity/IIdentityProvider.cs | 12 +++++++----- .../WebIdentity/IdentityManager.cs | 7 ++++--- 4 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs index fadb3de2..7b68e362 100644 --- a/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs +++ b/src/WebExpress.WebCore.Test/Data/MockIdentityProvider.cs @@ -1,6 +1,6 @@ -using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebIdentity; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.Test.Data { @@ -84,7 +84,7 @@ public void Logout(IRequest request) /// An object that represents the response to the login dialog, including authentication results and any /// relevant status information. /// - public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity) + public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity) { return null; } @@ -106,7 +106,7 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext i /// A response representing the forbidden page if this provider can handle the forbidden /// scenario; otherwise, null. /// - public IResponse CreateForbiddenPage(IRequest request, IEndpointContext initiator, IIdentity identity) + public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) { return null; } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index 9b1284f8..e40ec71f 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -4,6 +4,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.WebIdentity @@ -38,9 +39,10 @@ public interface IIdentityManager : IComponentManager /// /// /// An object that represents the response to the login dialog, including authentication results and any - /// relevant status information. + /// relevant status information. Returns null when no registered identity provider can handle the + /// scenario; the server then falls back to the status page. /// - IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity = null); + IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity = null); /// /// Creates a forbidden response page for the specified request when the authenticated @@ -57,10 +59,10 @@ public interface IIdentityManager : IComponentManager /// The authenticated identity that lacks sufficient permissions. Cannot be null. /// /// - /// A response representing the forbidden page if a registered identity provider can handle the - /// forbidden scenario; otherwise, null. + /// A response representing the forbidden page if a registered identity provider can handle the + /// forbidden scenario; otherwise, null. The server then falls back to the status page. /// - IResponse CreateForbiddenResponse(IRequest request, IEndpointContext initiator, IIdentity identity); + IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity); /// /// Login an identity. diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs index 697e1261..c9b3e045 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityProvider.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebIdentity { @@ -35,9 +35,10 @@ public interface IIdentityProvider /// /// /// An object that represents the response to the login dialog, including authentication results and any - /// relevant status information. + /// relevant status information. A provider that cannot handle the scenario returns null; the + /// identity manager then asks the next provider, and the server falls back to the status page. /// - IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity); + IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity); /// /// Creates a forbidden response page for the specified request when the authenticated @@ -55,8 +56,9 @@ public interface IIdentityProvider /// /// /// A response representing the forbidden page if this provider can handle the forbidden - /// scenario; otherwise, null. + /// scenario; otherwise, null. When every provider returns null, the identity + /// manager reports no response and the server falls back to the status page. /// - IResponse CreateForbiddenPage(IRequest request, IEndpointContext initiator, IIdentity identity); + IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity); } } diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index 5f3fdb2a..bac5cec0 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -12,6 +12,7 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebIdentity.Model; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebSession.Model; @@ -385,7 +386,7 @@ private void OnAddApplication(object sender, IApplicationContext e) /// An object that represents the response to the login dialog, including authentication results /// and any relevant status information. /// - public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext initiator, IIdentity identity = null) + public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity = null) { if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) { @@ -415,13 +416,13 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IEndpointContext i /// A response representing the forbidden page if a registered identity provider can handle the /// forbidden scenario; otherwise, null. /// - public IResponse CreateForbiddenResponse(IRequest request, IEndpointContext initiator, IIdentity identity) + public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) { if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) { foreach (var provider in list) { - var response = provider.CreateForbiddenPage(request, initiator, identity); + var response = provider.CreateForbiddenResponse(request, initiator, identity); if (response is not null) { From 0751946edcf415b0c748f37ca5fa5b27b7b5f4c0 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Mon, 13 Jul 2026 21:55:35 +0200 Subject: [PATCH 18/69] perf: code optimized and fix minor bugs --- src/WebExpress.WebCore/WebUri/UriAuthority.cs | 55 ++++++++++++-- src/WebExpress.WebCore/WebUri/UriEndpoint.cs | 74 ++++++++++++++----- src/WebExpress.WebCore/WebUri/UriQuery.cs | 4 +- 3 files changed, 108 insertions(+), 25 deletions(-) diff --git a/src/WebExpress.WebCore/WebUri/UriAuthority.cs b/src/WebExpress.WebCore/WebUri/UriAuthority.cs index 4c2c8a4d..e9d31074 100644 --- a/src/WebExpress.WebCore/WebUri/UriAuthority.cs +++ b/src/WebExpress.WebCore/WebUri/UriAuthority.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Text; namespace WebExpress.WebCore.WebUri { @@ -74,17 +75,59 @@ public override string ToString() /// The string representation of the authority. public virtual string ToString(int defaultPort) { + var builder = new StringBuilder(); + AppendTo(builder, defaultPort); + + return builder.ToString(); + } + + /// + /// Appends the string representation of the authority directly to the given builder, + /// avoiding the intermediate string/array allocations of . + /// + /// The target builder. + /// The default port for the active scheme. + internal void AppendTo(StringBuilder builder, int defaultPort) + { + builder.Append("//"); + #pragma warning disable 618 - var userinfo = string.Join(":", new string[] { User, Password }.Where(x => !string.IsNullOrWhiteSpace(x))); + var hasUser = !string.IsNullOrWhiteSpace(User); + var hasPassword = !string.IsNullOrWhiteSpace(Password); #pragma warning restore 618 - var address = string.Join(":", new string[] + if (hasUser || hasPassword) { - Host, - Port != defaultPort ? Port?.ToString() : "" - }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (hasUser) + { + builder.Append(User); + } - return "//" + string.Join("@", new string[] { userinfo, address }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (hasPassword) + { +#pragma warning disable 618 + if (hasUser) + { + builder.Append(':'); + } + + builder.Append(Password); +#pragma warning restore 618 + } + + builder.Append('@'); + } + + if (!string.IsNullOrWhiteSpace(Host)) + { + builder.Append(Host); + } + + if (Port.HasValue && Port != defaultPort) + { + builder.Append(':'); + builder.Append(Port.Value); + } } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs index f015aa55..ac5d8c5d 100644 --- a/src/WebExpress.WebCore/WebUri/UriEndpoint.cs +++ b/src/WebExpress.WebCore/WebUri/UriEndpoint.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebMessage; @@ -673,30 +674,69 @@ public virtual IIcon GetIcon(IRenderContext renderContext) /// A string that represents the current uri. public override string ToString() { - var scheme = Scheme.ToSchemeString() + ":"; - var authority = Authority?.ToString(Scheme.DefaultPort()); - var uri = "/" + string.Join - ( - "/", - PathSegments.Where(x => x is not UriPathSegmentRoot) - .Select(x => x.ToString().TrimStart('/')) - ).TrimEnd('/'); - - if (Query.Any()) + var builder = new StringBuilder(); + + if (Scheme != UriScheme.Mailto && !IsRelative) + { + builder.Append(Scheme.ToSchemeString()); + builder.Append(':'); + Authority?.AppendTo(builder, Scheme.DefaultPort()); + } + else if (Scheme == UriScheme.Mailto) { - uri += "?" + string.Join("&", Query.Select(x => x.ToString())); + builder.Append(Scheme.ToSchemeString()); + builder.Append(':'); + Authority?.AppendTo(builder, Scheme.DefaultPort()); + return builder.ToString(); } - if (!string.IsNullOrWhiteSpace(Fragment)) + var hasPathSegment = false; + foreach (var segment in PathSegments) { - uri += "#" + Fragment; + if (segment is UriPathSegmentRoot) + { + continue; + } + + builder.Append('/'); + var value = segment?.ToString(); + if (!string.IsNullOrEmpty(value)) + { + builder.Append(value.TrimStart('/')); + } + + hasPathSegment = true; } - return Scheme switch + if (hasPathSegment) { - UriScheme.Mailto => string.Format("{0}{1}", scheme, authority), - _ => IsRelative ? uri : string.Format("{0}{1}{2}", scheme, authority, uri), - }; + while (builder.Length > 1 && builder[builder.Length - 1] == '/') + { + builder.Length--; + } + } + else + { + builder.Append('/'); + } + + var hasQuery = false; + foreach (var query in Query) + { + builder.Append(hasQuery ? '&' : '?'); + hasQuery = true; + builder.Append(query.Key); + builder.Append('='); + builder.Append(query.Value); + } + + if (!string.IsNullOrWhiteSpace(Fragment)) + { + builder.Append('#'); + builder.Append(Fragment); + } + + return builder.ToString(); } } } \ No newline at end of file diff --git a/src/WebExpress.WebCore/WebUri/UriQuery.cs b/src/WebExpress.WebCore/WebUri/UriQuery.cs index 383ce359..be1a8c4e 100644 --- a/src/WebExpress.WebCore/WebUri/UriQuery.cs +++ b/src/WebExpress.WebCore/WebUri/UriQuery.cs @@ -34,7 +34,7 @@ public UriQuery(string key, string value) /// A string that represents the current query. public override string ToString() { - return $"{Key}={Value}"; + return string.Concat(Key, "=", Value); } } @@ -74,7 +74,7 @@ public UriQuery(string value = null) /// A string that represents the current query. public override string ToString() { - return $"{Key}={Value}"; + return string.Concat(Key, "=", Value); } } } \ No newline at end of file From 9795e9aea0585827d56a1caf36e9d64eb7a2d0c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Sat, 25 Jul 2026 10:09:25 +0200 Subject: [PATCH 19/69] feat: kanban improvements --- src/WebExpress.WebCore.Test/test/general.get | 28 +-- src/WebExpress.WebCore.Test/test/less.get | 4 +- src/WebExpress.WebCore.Test/test/massive.get | 226 +++++++++--------- src/WebExpress.WebCore.Test/test/param.get | 28 +-- .../test/param_umlaut.get | 28 +-- 5 files changed, 157 insertions(+), 157 deletions(-) diff --git a/src/WebExpress.WebCore.Test/test/general.get b/src/WebExpress.WebCore.Test/test/general.get index 7d521203..b190a0f0 100644 --- a/src/WebExpress.WebCore.Test/test/general.get +++ b/src/WebExpress.WebCore.Test/test/general.get @@ -1,14 +1,14 @@ -GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 -Host: localhost -Connection: keep-alive -Cache-Control: max-age=0 -Upgrade-Insecure-Requests: 1 -User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Sec-Fetch-Site: cross-site -Sec-Fetch-Mode: navigate -Sec-Fetch-User: ?1 -Sec-Fetch-Dest: document -Accept-Encoding: gzip, deflate, br -Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 - +GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 +Host: localhost +Connection: keep-alive +Cache-Control: max-age=0 +Upgrade-Insecure-Requests: 1 +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-User: ?1 +Sec-Fetch-Dest: document +Accept-Encoding: gzip, deflate, br +Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 + diff --git a/src/WebExpress.WebCore.Test/test/less.get b/src/WebExpress.WebCore.Test/test/less.get index f843c6eb..7cb4ba74 100644 --- a/src/WebExpress.WebCore.Test/test/less.get +++ b/src/WebExpress.WebCore.Test/test/less.get @@ -1,2 +1,2 @@ -GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 -Host: localhost +GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 +Host: localhost diff --git a/src/WebExpress.WebCore.Test/test/massive.get b/src/WebExpress.WebCore.Test/test/massive.get index ec786c3f..70712cae 100644 --- a/src/WebExpress.WebCore.Test/test/massive.get +++ b/src/WebExpress.WebCore.Test/test/massive.get @@ -1,113 +1,113 @@ -GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 -Host: localhost -Connection: keep-alive -Cache-Control: max-age=0 -Upgrade-Insecure-Requests: 1 -User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Sec-Fetch-Site: cross-site -Sec-Fetch-Mode: navigate -Sec-Fetch-User: ?1 -Sec-Fetch-Dest: document -Accept-Encoding: gzip, deflate, br -Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 -Field-1: abcdefghijklmnopqrstuvwxyz -Field-2: abcdefghijklmnopqrstuvwxyz -Field-3: abcdefghijklmnopqrstuvwxyz -Field-4: abcdefghijklmnopqrstuvwxyz -Field-5: abcdefghijklmnopqrstuvwxyz -Field-6: abcdefghijklmnopqrstuvwxyz -Field-7: abcdefghijklmnopqrstuvwxyz -Field-8: abcdefghijklmnopqrstuvwxyz -Field-9: abcdefghijklmnopqrstuvwxyz -Field-10: abcdefghijklmnopqrstuvwxyz -Field-11: abcdefghijklmnopqrstuvwxyz -Field-12: abcdefghijklmnopqrstuvwxyz -Field-13: abcdefghijklmnopqrstuvwxyz -Field-14: abcdefghijklmnopqrstuvwxyz -Field-15: abcdefghijklmnopqrstuvwxyz -Field-16: abcdefghijklmnopqrstuvwxyz -Field-17: abcdefghijklmnopqrstuvwxyz -Field-18: abcdefghijklmnopqrstuvwxyz -Field-19: abcdefghijklmnopqrstuvwxyz -Field-20: abcdefghijklmnopqrstuvwxyz -Field-21: abcdefghijklmnopqrstuvwxyz -Field-22: abcdefghijklmnopqrstuvwxyz -Field-23: abcdefghijklmnopqrstuvwxyz -Field-24: abcdefghijklmnopqrstuvwxyz -Field-25: abcdefghijklmnopqrstuvwxyz -Field-26: abcdefghijklmnopqrstuvwxyz -Field-27: abcdefghijklmnopqrstuvwxyz -Field-28: abcdefghijklmnopqrstuvwxyz -Field-29: abcdefghijklmnopqrstuvwxyz -Field-30: abcdefghijklmnopqrstuvwxyz -Field-31: abcdefghijklmnopqrstuvwxyz -Field-32: abcdefghijklmnopqrstuvwxyz -Field-33: abcdefghijklmnopqrstuvwxyz -Field-34: abcdefghijklmnopqrstuvwxyz -Field-35: abcdefghijklmnopqrstuvwxyz -Field-36: abcdefghijklmnopqrstuvwxyz -Field-37: abcdefghijklmnopqrstuvwxyz -Field-38: abcdefghijklmnopqrstuvwxyz -Field-39: abcdefghijklmnopqrstuvwxyz -Field-40: abcdefghijklmnopqrstuvwxyz -Field-41: abcdefghijklmnopqrstuvwxyz -Field-42: abcdefghijklmnopqrstuvwxyz -Field-43: abcdefghijklmnopqrstuvwxyz -Field-44: abcdefghijklmnopqrstuvwxyz -Field-45: abcdefghijklmnopqrstuvwxyz -Field-46: abcdefghijklmnopqrstuvwxyz -Field-47: abcdefghijklmnopqrstuvwxyz -Field-48: abcdefghijklmnopqrstuvwxyz -Field-49: abcdefghijklmnopqrstuvwxyz -Field-50: abcdefghijklmnopqrstuvwxyz -Field-51: abcdefghijklmnopqrstuvwxyz -Field-52: abcdefghijklmnopqrstuvwxyz -Field-53: abcdefghijklmnopqrstuvwxyz -Field-54: abcdefghijklmnopqrstuvwxyz -Field-55: abcdefghijklmnopqrstuvwxyz -Field-56: abcdefghijklmnopqrstuvwxyz -Field-57: abcdefghijklmnopqrstuvwxyz -Field-58: abcdefghijklmnopqrstuvwxyz -Field-59: abcdefghijklmnopqrstuvwxyz -Field-60: abcdefghijklmnopqrstuvwxyz -Field-61: abcdefghijklmnopqrstuvwxyz -Field-62: abcdefghijklmnopqrstuvwxyz -Field-63: abcdefghijklmnopqrstuvwxyz -Field-64: abcdefghijklmnopqrstuvwxyz -Field-65: abcdefghijklmnopqrstuvwxyz -Field-66: abcdefghijklmnopqrstuvwxyz -Field-67: abcdefghijklmnopqrstuvwxyz -Field-68: abcdefghijklmnopqrstuvwxyz -Field-69: abcdefghijklmnopqrstuvwxyz -Field-70: abcdefghijklmnopqrstuvwxyz -Field-71: abcdefghijklmnopqrstuvwxyz -Field-72: abcdefghijklmnopqrstuvwxyz -Field-73: abcdefghijklmnopqrstuvwxyz -Field-74: abcdefghijklmnopqrstuvwxyz -Field-75: abcdefghijklmnopqrstuvwxyz -Field-76: abcdefghijklmnopqrstuvwxyz -Field-77: abcdefghijklmnopqrstuvwxyz -Field-78: abcdefghijklmnopqrstuvwxyz -Field-79: abcdefghijklmnopqrstuvwxyz -Field-80: abcdefghijklmnopqrstuvwxyz -Field-81: abcdefghijklmnopqrstuvwxyz -Field-82: abcdefghijklmnopqrstuvwxyz -Field-83: abcdefghijklmnopqrstuvwxyz -Field-84: abcdefghijklmnopqrstuvwxyz -Field-85: abcdefghijklmnopqrstuvwxyz -Field-86: abcdefghijklmnopqrstuvwxyz -Field-87: abcdefghijklmnopqrstuvwxyz -Field-88: abcdefghijklmnopqrstuvwxyz -Field-89: abcdefghijklmnopqrstuvwxyz -Field-90: abcdefghijklmnopqrstuvwxyz -Field-91: abcdefghijklmnopqrstuvwxyz -Field-92: abcdefghijklmnopqrstuvwxyz -Field-93: abcdefghijklmnopqrstuvwxyz -Field-94: abcdefghijklmnopqrstuvwxyz -Field-95: abcdefghijklmnopqrstuvwxyz -Field-96: abcdefghijklmnopqrstuvwxyz -Field-97: abcdefghijklmnopqrstuvwxyz -Field-98: abcdefghijklmnopqrstuvwxyz -Field-99: abcdefghijklmnopqrstuvwxyz - +GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A HTTP/1.1 +Host: localhost +Connection: keep-alive +Cache-Control: max-age=0 +Upgrade-Insecure-Requests: 1 +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-User: ?1 +Sec-Fetch-Dest: document +Accept-Encoding: gzip, deflate, br +Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 +Field-1: abcdefghijklmnopqrstuvwxyz +Field-2: abcdefghijklmnopqrstuvwxyz +Field-3: abcdefghijklmnopqrstuvwxyz +Field-4: abcdefghijklmnopqrstuvwxyz +Field-5: abcdefghijklmnopqrstuvwxyz +Field-6: abcdefghijklmnopqrstuvwxyz +Field-7: abcdefghijklmnopqrstuvwxyz +Field-8: abcdefghijklmnopqrstuvwxyz +Field-9: abcdefghijklmnopqrstuvwxyz +Field-10: abcdefghijklmnopqrstuvwxyz +Field-11: abcdefghijklmnopqrstuvwxyz +Field-12: abcdefghijklmnopqrstuvwxyz +Field-13: abcdefghijklmnopqrstuvwxyz +Field-14: abcdefghijklmnopqrstuvwxyz +Field-15: abcdefghijklmnopqrstuvwxyz +Field-16: abcdefghijklmnopqrstuvwxyz +Field-17: abcdefghijklmnopqrstuvwxyz +Field-18: abcdefghijklmnopqrstuvwxyz +Field-19: abcdefghijklmnopqrstuvwxyz +Field-20: abcdefghijklmnopqrstuvwxyz +Field-21: abcdefghijklmnopqrstuvwxyz +Field-22: abcdefghijklmnopqrstuvwxyz +Field-23: abcdefghijklmnopqrstuvwxyz +Field-24: abcdefghijklmnopqrstuvwxyz +Field-25: abcdefghijklmnopqrstuvwxyz +Field-26: abcdefghijklmnopqrstuvwxyz +Field-27: abcdefghijklmnopqrstuvwxyz +Field-28: abcdefghijklmnopqrstuvwxyz +Field-29: abcdefghijklmnopqrstuvwxyz +Field-30: abcdefghijklmnopqrstuvwxyz +Field-31: abcdefghijklmnopqrstuvwxyz +Field-32: abcdefghijklmnopqrstuvwxyz +Field-33: abcdefghijklmnopqrstuvwxyz +Field-34: abcdefghijklmnopqrstuvwxyz +Field-35: abcdefghijklmnopqrstuvwxyz +Field-36: abcdefghijklmnopqrstuvwxyz +Field-37: abcdefghijklmnopqrstuvwxyz +Field-38: abcdefghijklmnopqrstuvwxyz +Field-39: abcdefghijklmnopqrstuvwxyz +Field-40: abcdefghijklmnopqrstuvwxyz +Field-41: abcdefghijklmnopqrstuvwxyz +Field-42: abcdefghijklmnopqrstuvwxyz +Field-43: abcdefghijklmnopqrstuvwxyz +Field-44: abcdefghijklmnopqrstuvwxyz +Field-45: abcdefghijklmnopqrstuvwxyz +Field-46: abcdefghijklmnopqrstuvwxyz +Field-47: abcdefghijklmnopqrstuvwxyz +Field-48: abcdefghijklmnopqrstuvwxyz +Field-49: abcdefghijklmnopqrstuvwxyz +Field-50: abcdefghijklmnopqrstuvwxyz +Field-51: abcdefghijklmnopqrstuvwxyz +Field-52: abcdefghijklmnopqrstuvwxyz +Field-53: abcdefghijklmnopqrstuvwxyz +Field-54: abcdefghijklmnopqrstuvwxyz +Field-55: abcdefghijklmnopqrstuvwxyz +Field-56: abcdefghijklmnopqrstuvwxyz +Field-57: abcdefghijklmnopqrstuvwxyz +Field-58: abcdefghijklmnopqrstuvwxyz +Field-59: abcdefghijklmnopqrstuvwxyz +Field-60: abcdefghijklmnopqrstuvwxyz +Field-61: abcdefghijklmnopqrstuvwxyz +Field-62: abcdefghijklmnopqrstuvwxyz +Field-63: abcdefghijklmnopqrstuvwxyz +Field-64: abcdefghijklmnopqrstuvwxyz +Field-65: abcdefghijklmnopqrstuvwxyz +Field-66: abcdefghijklmnopqrstuvwxyz +Field-67: abcdefghijklmnopqrstuvwxyz +Field-68: abcdefghijklmnopqrstuvwxyz +Field-69: abcdefghijklmnopqrstuvwxyz +Field-70: abcdefghijklmnopqrstuvwxyz +Field-71: abcdefghijklmnopqrstuvwxyz +Field-72: abcdefghijklmnopqrstuvwxyz +Field-73: abcdefghijklmnopqrstuvwxyz +Field-74: abcdefghijklmnopqrstuvwxyz +Field-75: abcdefghijklmnopqrstuvwxyz +Field-76: abcdefghijklmnopqrstuvwxyz +Field-77: abcdefghijklmnopqrstuvwxyz +Field-78: abcdefghijklmnopqrstuvwxyz +Field-79: abcdefghijklmnopqrstuvwxyz +Field-80: abcdefghijklmnopqrstuvwxyz +Field-81: abcdefghijklmnopqrstuvwxyz +Field-82: abcdefghijklmnopqrstuvwxyz +Field-83: abcdefghijklmnopqrstuvwxyz +Field-84: abcdefghijklmnopqrstuvwxyz +Field-85: abcdefghijklmnopqrstuvwxyz +Field-86: abcdefghijklmnopqrstuvwxyz +Field-87: abcdefghijklmnopqrstuvwxyz +Field-88: abcdefghijklmnopqrstuvwxyz +Field-89: abcdefghijklmnopqrstuvwxyz +Field-90: abcdefghijklmnopqrstuvwxyz +Field-91: abcdefghijklmnopqrstuvwxyz +Field-92: abcdefghijklmnopqrstuvwxyz +Field-93: abcdefghijklmnopqrstuvwxyz +Field-94: abcdefghijklmnopqrstuvwxyz +Field-95: abcdefghijklmnopqrstuvwxyz +Field-96: abcdefghijklmnopqrstuvwxyz +Field-97: abcdefghijklmnopqrstuvwxyz +Field-98: abcdefghijklmnopqrstuvwxyz +Field-99: abcdefghijklmnopqrstuvwxyz + diff --git a/src/WebExpress.WebCore.Test/test/param.get b/src/WebExpress.WebCore.Test/test/param.get index 078a594a..83bc1ca2 100644 --- a/src/WebExpress.WebCore.Test/test/param.get +++ b/src/WebExpress.WebCore.Test/test/param.get @@ -1,14 +1,14 @@ -GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A?a=1&b=1%202 HTTP/1.1 -Host: localhost -Connection: keep-alive -Cache-Control: max-age=0 -Upgrade-Insecure-Requests: 1 -User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Sec-Fetch-Site: cross-site -Sec-Fetch-Mode: navigate -Sec-Fetch-User: ?1 -Sec-Fetch-Dest: document -Accept-Encoding: gzip, deflate, br -Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 - +GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A?a=1&b=1%202 HTTP/1.1 +Host: localhost +Connection: keep-alive +Cache-Control: max-age=0 +Upgrade-Insecure-Requests: 1 +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-User: ?1 +Sec-Fetch-Dest: document +Accept-Encoding: gzip, deflate, br +Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 + diff --git a/src/WebExpress.WebCore.Test/test/param_umlaut.get b/src/WebExpress.WebCore.Test/test/param_umlaut.get index 894e9c99..a0081dbc 100644 --- a/src/WebExpress.WebCore.Test/test/param_umlaut.get +++ b/src/WebExpress.WebCore.Test/test/param_umlaut.get @@ -1,14 +1,14 @@ -GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A?a=%c3%a4&b=%c3%b6%20%c3%bc HTTP/1.1 -Host: localhost -Connection: keep-alive -Cache-Control: max-age=0 -Upgrade-Insecure-Requests: 1 -User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 -Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 -Sec-Fetch-Site: cross-site -Sec-Fetch-Mode: navigate -Sec-Fetch-User: ?1 -Sec-Fetch-Dest: document -Accept-Encoding: gzip, deflate, br -Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 - +GET /abc/xyz/A7BCCCA9-4C7E-4117-9EE2-ECC3381B605A?a=%c3%a4&b=%c3%b6%20%c3%bc HTTP/1.1 +Host: localhost +Connection: keep-alive +Cache-Control: max-age=0 +Upgrade-Insecure-Requests: 1 +User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.60 +Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 +Sec-Fetch-Site: cross-site +Sec-Fetch-Mode: navigate +Sec-Fetch-User: ?1 +Sec-Fetch-Dest: document +Accept-Encoding: gzip, deflate, br +Accept-Language: de,en;q=0.9,en-GB;q=0.8,de-DE;q=0.7,en-US;q=0.6 + From fd6e926b759c0cb6021b3a9ca031040dd4533631 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Sun, 2 Aug 2026 09:17:48 +0200 Subject: [PATCH 20/69] docs: disclose AI-assisted creation in READMEs and imprints (EU AI Act, Reg. 2024/1689) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6d9a8fc3..39035ecb 100644 --- a/README.md +++ b/README.md @@ -40,5 +40,9 @@ The following tutorials illustrate the essential techniques of `WebExpress`. The - [WebApp](https://github.com/webexpress-framework/WebExpress.Tutorial.WebApp#readme) - [WebIndex](https://github.com/webexpress-framework/WebExpress.Tutorial.WebIndex#readme) +## AI transparency notice + +Parts of this software, its documentation, and its assets were created with the assistance of AI-based tools, including large language models. AI-assisted contributions are reviewed by the project maintainer before they are included. + # Tags #WebCore #WebExpress #DotNet #NETCore From 57e45263d7ffe8b3b5e208851678b2a2f80a7d82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Sun, 2 Aug 2026 22:39:54 +0200 Subject: [PATCH 21/69] feat: general improvements and minor bugs --- .../Manager/UnitTestPackageManager.cs | 194 ++++++++++++++++++ .../WebPackage/Model/PackageCatalog.cs | 12 +- .../WebPackage/Model/PackageCatalogItem.cs | 14 ++ .../WebPackage/PackageManager.cs | 142 ++++++++++++- .../WebPlugin/PluginManager.cs | 5 +- 5 files changed, 353 insertions(+), 14 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index c39f1e4f..5209ebbf 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -5,6 +5,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage; using WebExpress.WebCore.WebPackage.Model; +using WebExpress.WebCore.WebPlugin; namespace WebExpress.WebCore.Test.Manager { @@ -404,6 +405,199 @@ public void ValidatePackageWithSha256() } } + /// + /// Tests that the plugins loaded from the application directory are reported by the read + /// side even though they are not packages. + /// + /// + /// This is the defect the built-in entries exist for: in a plain build deployment every + /// plugin is referenced statically, the catalog is empty, and a management surface reading + /// the catalog alone stays blank while the server logs the plugins as running. + /// + [Fact] + public void GetPackagesReportsStaticallyLoadedPluginsAsBuiltIn() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var packageManager = componentHub.PackageManager as PackageManager; + var pluginIds = componentHub.PluginManager.Plugins.Select(x => x.PluginId.ToString()).ToList(); + + // act + var packages = packageManager.GetPackages().ToList(); + + // validation + Assert.NotEmpty(pluginIds); + Assert.Empty(packageManager.Catalog.Packages); + + foreach (var pluginId in pluginIds) + { + var package = Assert.Single(packages, x => x.Id == pluginId); + + Assert.True(package.BuiltIn); + Assert.Equal(string.Empty, package.File); + Assert.Equal(PackageCatalogeItemState.Active, package.State); + Assert.Contains(package.Plugins, x => x.PluginId.ToString() == pluginId); + Assert.Equal(pluginId, package.Metadata?.Id); + } + } + + /// + /// Tests that a plugin present both statically and as an installed package is reported + /// once, by the package - which is the entry the lifecycle operations can act on. + /// + [Fact] + public void GetPackagesReportsAPluginOnceWhenItIsAlsoInstalled() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var pluginId = componentHub.PluginManager.Plugins.First().PluginId.ToString(); + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, $"{pluginId}.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, pluginId, "1.0.0"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + var packages = packageManager.GetPackages().Where(x => x.Id == pluginId).ToList(); + + // validation + Assert.True(install.Success); + Assert.False(Assert.Single(packages).BuiltIn); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests that the built-in entries never reach the persisted catalog. + /// + /// + /// Persisting them would be worse than the original defect: on the next start LoadCatalog + /// would read them back as installed packages, every path built from their empty file name + /// would fail to resolve, and Scan would count them as no longer present and drop them. + /// + [Fact] + public void SaveCatalogLeavesBuiltInEntriesOutOfTheCatalogFile() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var catalogFile = Path.Combine(packagePath, "catalog.xml"); + var save = typeof(PackageManager).GetMethod("SaveCatalog", BindingFlags.NonPublic | BindingFlags.Instance); + var load = typeof(PackageManager).GetMethod("LoadCatalog", BindingFlags.NonPublic | BindingFlags.Instance); + + try + { + Directory.CreateDirectory(packagePath); + Assert.NotEmpty(packageManager.GetPackages()); + + // act - two start cycles worth of save/load must not adopt the built-ins + save.Invoke(packageManager, null); + load.Invoke(packageManager, null); + save.Invoke(packageManager, null); + + // validation + Assert.Empty(packageManager.Catalog.Packages); + Assert.DoesNotContain(" + /// Tests that the directory scan neither adopts nor removes the built-in entries, which + /// only exist in the read path. + /// + [Fact] + public void ScanKeepsBuiltInEntriesOutOfTheCatalog() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + + try + { + Directory.CreateDirectory(packagePath); + + // act + packageManager.Scan(); + packageManager.Scan(); + + // validation + Assert.Empty(packageManager.Catalog.Packages); + Assert.All(packageManager.GetPackages(), x => Assert.True(x.BuiltIn)); + } + finally + { + if (Directory.Exists(packagePath)) + { + Directory.Delete(packagePath, true); + } + } + } + + /// + /// Tests that every lifecycle operation refuses a built-in plugin with a defined failure + /// rather than running half of its steps. + /// + [Fact] + public void BuiltInPackageRefusesEveryLifecycleOperation() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + (componentHub.PluginManager as PluginManager).Register(); + + var packageManager = componentHub.PackageManager as PackageManager; + var pluginId = componentHub.PluginManager.Plugins.First().PluginId.ToString(); + + // act + var results = new[] + { + packageManager.ActivatePackage(pluginId), + packageManager.DeactivatePackage(pluginId), + packageManager.UpdatePackage(pluginId, Path.Combine(httpServerContext.PackagePath, "missing.wxp")), + packageManager.UninstallPackage(pluginId) + }; + + // validation + Assert.All(results, x => + { + Assert.False(x.Success); + Assert.Contains("ships with the application", x.Message, StringComparison.OrdinalIgnoreCase); + }); + + // the refusal has to leave the plugin exactly as it was + Assert.Contains(componentHub.PluginManager.Plugins, x => x.PluginId.ToString() == pluginId); + Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage(pluginId)?.State); + } + /// /// Creates a simple package archive for tests. /// diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs index 100d0a17..487b3657 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalog.cs @@ -15,15 +15,15 @@ public class PackageCatalog /// /// Gets the package entries in the catalog. /// + /// + /// This is the persisted collection and holds installed packages only. The plugins that + /// ship with the application are not packages and never enter it; they are synthesized on + /// read by and marked + /// . + /// [XmlElement("package")] public List Packages { get; } = []; - /// - /// Gets the system package entries in the catalog. - /// - [XmlIgnore] - public List SystemPackages { get; } = []; - /// /// Locates a specific catalog item. /// diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs index fa8d9d2b..834965b2 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageCatalogItem.cs @@ -29,6 +29,20 @@ public class PackageCatalogItem [XmlAttribute("state")] public PackageCatalogeItemState State { get; set; } + /// + /// Gets a value indicating whether the entry stands for a plugin that ships with the + /// application rather than for an installed package. + /// + /// + /// A built-in entry is synthesized on read from the plugins the + /// registered from the application directory, so + /// the management surface can list them next to the installed packages. It has no package + /// file behind it, is never written to the catalog, and none of the package operations + /// apply to it - an assembly in the application directory cannot be uninstalled at runtime. + /// + [XmlIgnore] + public bool BuiltIn { get; internal set; } + /// /// Gets the plugins belonging to the package. /// diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs index 5c87d98f..0f5bb639 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs @@ -293,19 +293,33 @@ public void Scan() } /// - /// Returns all package entries from the package catalog. + /// Returns all package entries, the installed ones and the plugins that ship with the + /// application. /// + /// + /// The catalog only ever knows what was installed from a *.wxp file. In a plain build + /// deployment every plugin is referenced statically, the catalog is empty, and a management + /// surface reading it alone shows nothing while the server logs four running plugins. The + /// union is formed here rather than in the pages so that a third consumer cannot inherit + /// the blind spot; it is a read-only view and never touches + /// , which is what keeps the synthesized entries out + /// of the persisted catalog. + /// /// An enumerable collection with all package entries. public IEnumerable GetPackages() { lock (_scanLock) { - return [.. Catalog.Packages.Where(x => x is not null)]; + var packages = Catalog.Packages.Where(x => x is not null).ToList(); + + packages.AddRange(GetBuiltInPackages(packages)); + + return packages; } } /// - /// Returns a package by id. + /// Returns a package by id, the installed ones as well as the built-in plugins. /// /// The package id. /// The package or null. @@ -318,11 +332,99 @@ public PackageCatalogItem GetPackage(string packageId) lock (_scanLock) { - return Catalog.Packages - .FirstOrDefault(x => x is not null && x.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); + return GetPackages() + .FirstOrDefault(x => x.Id is not null && x.Id.Equals(packageId, StringComparison.OrdinalIgnoreCase)); } } + /// + /// Builds a catalog entry for every registered plugin that no catalog entry accounts for. + /// + /// + /// A plugin is covered when a catalog entry lists it among its own plugins, or when a + /// catalog entry carries its id - so a plugin that is present statically and as a package + /// is reported once, by the package, which is the entry the operations can act on. + /// + /// The installed packages the plugins are matched against. + /// The synthesized entries, ordered by id. + private IEnumerable GetBuiltInPackages(IEnumerable packages) + { + var covered = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in packages) + { + if (!string.IsNullOrWhiteSpace(package.Id)) + { + covered.Add(package.Id); + } + + foreach (var plugin in package.Plugins.Where(x => x?.PluginId is not null)) + { + covered.Add(plugin.PluginId.ToString()); + } + } + + return (_pluginManager?.Plugins ?? []) + .Where(x => x?.PluginId is not null && !covered.Contains(x.PluginId.ToString())) + .Select(CreateBuiltInItem) + .OrderBy(x => x.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Creates the catalog entry that stands for a plugin loaded from the application directory. + /// + /// The plugin context. + /// The synthesized catalog entry. + private static PackageCatalogItem CreateBuiltInItem(IPluginContext plugin) + { + var id = plugin.PluginId.ToString(); + + return new PackageCatalogItem() + { + Id = id, + // there is no package file behind a built-in plugin. the empty name is deliberate: + // every path built from it fails to resolve, which is the second line of defence + // behind the guards on the operations + File = string.Empty, + State = PackageCatalogeItemState.Active, + BuiltIn = true, + Plugins = [plugin], + Metadata = new PackageItem() + { + FileName = string.Empty, + Id = id, + Version = plugin.Version, + Title = plugin.PluginName, + Authors = plugin.Manufacturer, + License = plugin.License, + Icon = plugin.Icon?.Display, + Description = plugin.Description, + PluginSources = [], + Dependencies = [] + } + }; + } + + /// + /// Rejects an operation that was asked to modify a plugin shipping with the application. + /// + /// + /// None of the package operations can be carried out on such a plugin: its assembly lives + /// in the application directory, is loaded into the default context and cannot be replaced + /// or removed while the process runs. Failing up front is what keeps a request from leaving + /// the plugin half unregistered. + /// + /// The package the operation was addressed to. + /// The name of the operation, used in the message. + /// The failure result, or null when the package may be operated on. + private static PackageOperationResult RejectBuiltIn(PackageCatalogItem package, string operation) + { + return package is not null && package.BuiltIn + ? PackageOperationResult.Failed($"Package '{package.Id}' ships with the application and cannot be {operation}.", package) + : null; + } + /// /// Validates a package file. /// @@ -571,6 +673,12 @@ public PackageOperationResult ActivatePackage(string packageId) return PackageOperationResult.Failed($"Package '{packageId}' was not found."); } + var builtIn = RejectBuiltIn(package, "activated"); + if (builtIn is not null) + { + return builtIn; + } + if (package.State == PackageCatalogeItemState.Active) { return PackageOperationResult.Ok($"Package '{packageId}' is already active.", package); @@ -614,6 +722,12 @@ public PackageOperationResult DeactivatePackage(string packageId) return PackageOperationResult.Failed($"Package '{packageId}' was not found."); } + var builtIn = RejectBuiltIn(package, "deactivated"); + if (builtIn is not null) + { + return builtIn; + } + DeactivateAndUnregisterPackage(package); RemoveExtractedDirectory(package); package.State = PackageCatalogeItemState.Disable; @@ -636,6 +750,12 @@ public PackageOperationResult DeactivatePackage(string packageId) /// The operation result. public PackageOperationResult UpdatePackage(string packageId, string packageFile, bool activate = true, long maxPackageBytes = 0, string expectedSha256 = null) { + var builtIn = RejectBuiltIn(GetPackage(packageId), "updated"); + if (builtIn is not null) + { + return builtIn; + } + var validation = ValidatePackage(packageFile, maxPackageBytes, expectedSha256); if (!validation.IsValid) { @@ -665,6 +785,12 @@ public PackageOperationResult UninstallPackage(string packageId) return PackageOperationResult.Failed($"Package '{packageId}' was not found."); } + var builtIn = RejectBuiltIn(package, "uninstalled"); + if (builtIn is not null) + { + return builtIn; + } + DeactivateAndUnregisterPackage(package); RemoveExtractedDirectory(package); @@ -1151,8 +1277,10 @@ private IEnumerable GetUnfulfilledPackageDependencies(PackageCatalogItem continue; } - var dependencyPackage = Catalog.Packages - .FirstOrDefault(x => x is not null && x.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + // built-in plugins count as fulfilled dependencies: a package that depends on + // webexpress.webui must install against a build deployment, where that plugin is + // referenced statically and therefore never appears in the catalog + var dependencyPackage = GetPackage(id); if (dependencyPackage is null || dependencyPackage.State == PackageCatalogeItemState.Disable) { diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index 618c2a64..8cbd75d0 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -66,7 +66,10 @@ private PluginManager(IComponentHub componentHub, IHttpServerContext httpServerC /// A list of plugins created. internal void Register() { - var path = Environment.CurrentDirectory; + // the statically deployed plugins sit next to the host assembly. the working + // directory is whatever the process happened to be started from - a service + // launched from the system directory would find no plugin at all + var path = AppContext.BaseDirectory; var assemblies = new List(); // create plugins From acf02ecb1493e9312320121caae84d99c0892db7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 3 Aug 2026 18:07:46 +0200 Subject: [PATCH 22/69] feat: general improvements and minor bugs --- src/WebExpress.WebCore/WebPlugin/PluginManager.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index 8cbd75d0..c32ff6c7 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -309,7 +309,12 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex PluginName = name, Manufacturer = type.Assembly.GetCustomAttribute()?.Company, Copyright = type.Assembly.GetCustomAttribute()?.Copyright, - Icon = RouteEndpoint.Combine(_httpServerContext?.Route, icon), + // a plugin without an icon attribute has no icon: combining the empty + // value would yield the server route, which callers cannot tell apart + // from a real icon and would render as a broken image + Icon = !string.IsNullOrWhiteSpace(icon) + ? RouteEndpoint.Combine(_httpServerContext?.Route, icon) + : null, Description = description, Version = type.Assembly.GetCustomAttribute()?.InformationalVersion }; From 1548a859018088f4d88e41c2682a435409125504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 3 Aug 2026 18:30:26 +0200 Subject: [PATCH 23/69] feat: monitor improvements --- src/WebExpress.WebCore/HttpServer.cs | 37 ++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 89d1466b..e367fce0 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -442,8 +442,6 @@ request.Session is not null stopwatch.Stop(); - UpdateStatistics(response, stopwatch.ElapsedMilliseconds); - HttpServerContext.Log?.Info(I18N.Translate ( "webexpress.webcore:httpserver.request.done", @@ -631,7 +629,20 @@ public IHttpContext CreateContext(IFeatureCollection contextFeatures) /// Provides an asynchronous operation that handles the http context. public async Task ProcessRequestAsync(IHttpContext httpContext) { - var responseSender = new ResponseSender(); + var sender = new ResponseSender(); + var stopwatch = Stopwatch.StartNew(); + + // every response leaves through this local send, so the statistics are recorded + // here rather than inside the handler: a request that never reaches a handler - + // an unknown route, a denied or an unauthenticated one - produces a status code + // the monitor has to account for just the same. Recording precedes the send, so + // a slow client does not end up counted as a slow server. + async Task SendAsync(IHttpContext context, IResponse response) + { + UpdateStatistics(response, stopwatch.ElapsedMilliseconds); + + await sender.SendAsync(context, response); + } if (httpContext is HttpExceptionContext exceptionContext) { @@ -644,7 +655,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) var response500 = CreateStatusPage(message, httpContext?.Request); - await responseSender.SendAsync(exceptionContext, response500); + await SendAsync(exceptionContext, response500); return; } @@ -665,7 +676,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) httpContext.Request ); - await responseSender.SendAsync(httpContext, notFoundResponse); + await SendAsync(httpContext, notFoundResponse); return; } @@ -692,7 +703,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) if (!(searchResult.EndpointContext.Policies?.Any() ?? false)) { var response = HandleClient(httpContext, searchResult); - await responseSender.SendAsync(httpContext, response); + await SendAsync(httpContext, response); return; } @@ -703,7 +714,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) if (_componentHub.IdentityManager.CheckAccess(identity, searchResult.EndpointContext)) { var response = HandleClient(httpContext, searchResult); - await responseSender.SendAsync(httpContext, response); + await SendAsync(httpContext, response); return; } @@ -722,7 +733,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) if (forbiddenResponse is not null) { - await responseSender.SendAsync(httpContext, forbiddenResponse); + await SendAsync(httpContext, forbiddenResponse); return; } else @@ -734,7 +745,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) searchResult ); - await responseSender.SendAsync(httpContext, forbiddenResponse); + await SendAsync(httpContext, forbiddenResponse); return; } } @@ -742,7 +753,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) { var forbiddenResponse = new ResponseForbidden(new StatusMessage("You do not have permission to access this resource.")); - await responseSender.SendAsync(httpContext, forbiddenResponse); + await SendAsync(httpContext, forbiddenResponse); return; } else if (searchResult.EndpointContext is IPageContext pageContext) @@ -757,7 +768,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) if (loginResponse is not null) { - await responseSender.SendAsync(httpContext, loginResponse); + await SendAsync(httpContext, loginResponse); return; } } @@ -765,7 +776,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) { var unauthorizedResponse = new ResponseUnauthorized(new StatusMessage("Authentication required. Provide a valid access token.")); - await responseSender.SendAsync(httpContext, unauthorizedResponse); + await SendAsync(httpContext, unauthorizedResponse); return; } } @@ -773,7 +784,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) // fallback: no specific denied-response (login prompt / forbidden) could be created { var response = HandleClient(httpContext, searchResult); - await responseSender.SendAsync(httpContext, response); + await SendAsync(httpContext, response); } } From 63cf7f3d025b4bf064555734ba8cb91341d7ea17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 3 Aug 2026 21:23:04 +0200 Subject: [PATCH 24/69] feat: settings improvements and minor bugs --- .../WebAttribute/DescriptionAttribute.cs | 2 +- .../WebAttribute/IJobAttribute.cs | 9 ++++++ .../WebAttribute/JobAttribute.cs | 2 +- .../WebAttribute/NameAttribute.cs | 2 +- src/WebExpress.WebCore/WebJob/IJobContext.cs | 14 +++++++++- src/WebExpress.WebCore/WebJob/JobContext.cs | 12 +++++++- src/WebExpress.WebCore/WebJob/JobManager.cs | 28 +++++++++++++++---- 7 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 src/WebExpress.WebCore/WebAttribute/IJobAttribute.cs diff --git a/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs b/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs index 874fcf67..8af8f438 100644 --- a/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/DescriptionAttribute.cs @@ -7,7 +7,7 @@ namespace WebExpress.WebCore.WebAttribute /// Implements , , and . /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class DescriptionAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute, IStatusPageAttribute + public class DescriptionAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute, IStatusPageAttribute, IJobAttribute { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebAttribute/IJobAttribute.cs b/src/WebExpress.WebCore/WebAttribute/IJobAttribute.cs new file mode 100644 index 00000000..48ad868b --- /dev/null +++ b/src/WebExpress.WebCore/WebAttribute/IJobAttribute.cs @@ -0,0 +1,9 @@ +namespace WebExpress.WebCore.WebAttribute +{ + /// + /// Interface of a job assignment attribute. + /// + public interface IJobAttribute + { + } +} diff --git a/src/WebExpress.WebCore/WebAttribute/JobAttribute.cs b/src/WebExpress.WebCore/WebAttribute/JobAttribute.cs index e6fbe3e9..3ba2e284 100644 --- a/src/WebExpress.WebCore/WebAttribute/JobAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/JobAttribute.cs @@ -4,7 +4,7 @@ /// Represents an attribute to schedule jobs based on specified time intervals. /// [System.AttributeUsage(System.AttributeTargets.Class)] - public class JobAttribute : System.Attribute + public class JobAttribute : System.Attribute, IJobAttribute { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebAttribute/NameAttribute.cs b/src/WebExpress.WebCore/WebAttribute/NameAttribute.cs index e9d30b46..b50d612d 100644 --- a/src/WebExpress.WebCore/WebAttribute/NameAttribute.cs +++ b/src/WebExpress.WebCore/WebAttribute/NameAttribute.cs @@ -6,7 +6,7 @@ namespace WebExpress.WebCore.WebAttribute /// Attribute to assign a name to a class. /// [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class NameAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute + public class NameAttribute : Attribute, IPluginAttribute, IApplicationAttribute, ISettingCategoryAttribute, ISettingGroupAttribute, IThemeAttribute, IJobAttribute { /// /// Initializes a new instance of the class. diff --git a/src/WebExpress.WebCore/WebJob/IJobContext.cs b/src/WebExpress.WebCore/WebJob/IJobContext.cs index 872b15d7..3d728b7e 100644 --- a/src/WebExpress.WebCore/WebJob/IJobContext.cs +++ b/src/WebExpress.WebCore/WebJob/IJobContext.cs @@ -22,10 +22,22 @@ public interface IJobContext : IContext IApplicationContext ApplicationContext { get; } /// - /// Gets the job id. + /// Gets the job id. /// IComponentId JobId { get; } + /// + /// Gets the name of the job, which may be an internationalization key. Null when the + /// job declares none, in which case only the id identifies it. + /// + string JobName { get; } + + /// + /// Gets the description of the job, which may be an internationalization key. It states + /// what the job does, because a schedule alone does not say why it runs. + /// + string Description { get; } + /// /// Gets the cron-object. /// diff --git a/src/WebExpress.WebCore/WebJob/JobContext.cs b/src/WebExpress.WebCore/WebJob/JobContext.cs index 3c59fedc..f33dd084 100644 --- a/src/WebExpress.WebCore/WebJob/JobContext.cs +++ b/src/WebExpress.WebCore/WebJob/JobContext.cs @@ -21,10 +21,20 @@ public class JobContext : IJobContext public IApplicationContext ApplicationContext { get; internal set; } /// - /// Gets the job id. + /// Gets the job id. /// public IComponentId JobId { get; internal set; } + /// + /// Gets the name of the job, which may be an internationalization key. + /// + public string JobName { get; internal set; } + + /// + /// Gets the description of the job, which may be an internationalization key. + /// + public string Description { get; internal set; } + /// /// Gets the cron-object. /// diff --git a/src/WebExpress.WebCore/WebJob/JobManager.cs b/src/WebExpress.WebCore/WebJob/JobManager.cs index 315eaa88..30097b6d 100644 --- a/src/WebExpress.WebCore/WebJob/JobManager.cs +++ b/src/WebExpress.WebCore/WebJob/JobManager.cs @@ -130,14 +130,28 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType == typeof(JobAttribute))) + foreach (var customAttribute in job.CustomAttributes + .Where(x => x.AttributeType.GetInterfaces().Contains(typeof(IJobAttribute)))) { - minute = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); - hour = customAttribute.ConstructorArguments.Skip(1).FirstOrDefault().Value?.ToString(); - day = customAttribute.ConstructorArguments.Skip(2).FirstOrDefault().Value?.ToString(); - month = customAttribute.ConstructorArguments.Skip(3).FirstOrDefault().Value?.ToString(); - weekday = customAttribute.ConstructorArguments.Skip(4).FirstOrDefault().Value?.ToString(); + if (customAttribute.AttributeType == typeof(JobAttribute)) + { + minute = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + hour = customAttribute.ConstructorArguments.Skip(1).FirstOrDefault().Value?.ToString(); + day = customAttribute.ConstructorArguments.Skip(2).FirstOrDefault().Value?.ToString(); + month = customAttribute.ConstructorArguments.Skip(3).FirstOrDefault().Value?.ToString(); + weekday = customAttribute.ConstructorArguments.Skip(4).FirstOrDefault().Value?.ToString(); + } + else if (customAttribute.AttributeType == typeof(NameAttribute)) + { + name = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + } + else if (customAttribute.AttributeType == typeof(DescriptionAttribute)) + { + description = customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString(); + } } // assign the job to existing applications @@ -146,6 +160,8 @@ private void Register(IPluginContext pluginContext, IEnumerable Date: Sat, 8 Aug 2026 18:54:30 +0200 Subject: [PATCH 25/69] feat: wql prompt improvements and minor bugs --- .../WebHtml/HtmlElementScriptingScript.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs index aadfbe4b..12038af7 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs @@ -69,11 +69,13 @@ public override void ToString(StringBuilder builder, int deep) if (!string.IsNullOrWhiteSpace(Code)) { -#if DEBUG + // the code is emitted as written. dropping the line breaks outside a debug + // build saved a few bytes, but a line break is syntax in JavaScript: after + // a // comment everything up to the end of the script is swallowed, and + // statements relying on automatic semicolon insertion run into each other. + // Both turn a working script into a parse error that appears only in the + // configuration that strips them. Shrinking source is a minifier's job. builder.Append(Code); -#else - builder.Append(Code.Replace("\r", "").Replace("\n", "")); -#endif } ToPostString(builder, deep, false); From f57defd383768bda24d54601c239008bd1096099 Mon Sep 17 00:00:00 2001 From: Rene Schwarzer Date: Tue, 18 Aug 2026 20:16:04 +0200 Subject: [PATCH 26/69] chore: migrate projects to .slnx --- src/WebExpress.WebCore.sln | 31 ------------------- src/WebExpress.WebCore.slnx | 4 +++ src/WebExpress.WebCore/WebExpress.WebCore.sln | 25 --------------- 3 files changed, 4 insertions(+), 56 deletions(-) delete mode 100644 src/WebExpress.WebCore.sln create mode 100644 src/WebExpress.WebCore.slnx delete mode 100644 src/WebExpress.WebCore/WebExpress.WebCore.sln diff --git a/src/WebExpress.WebCore.sln b/src/WebExpress.WebCore.sln deleted file mode 100644 index b811d5f6..00000000 --- a/src/WebExpress.WebCore.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.7.34221.43 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebExpress.WebCore", "WebExpress.WebCore\WebExpress.WebCore.csproj", "{FA4AD06F-77C5-410E-B03C-3FABF00E692D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebExpress.WebCore.Test", "WebExpress.WebCore.Test\WebExpress.WebCore.Test.csproj", "{135F043D-1881-4C4A-8120-5FE11498E1A4}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {FA4AD06F-77C5-410E-B03C-3FABF00E692D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA4AD06F-77C5-410E-B03C-3FABF00E692D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA4AD06F-77C5-410E-B03C-3FABF00E692D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA4AD06F-77C5-410E-B03C-3FABF00E692D}.Release|Any CPU.Build.0 = Release|Any CPU - {135F043D-1881-4C4A-8120-5FE11498E1A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {135F043D-1881-4C4A-8120-5FE11498E1A4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {135F043D-1881-4C4A-8120-5FE11498E1A4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {135F043D-1881-4C4A-8120-5FE11498E1A4}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {FAFE4739-CC63-4B24-8358-A6A8868C29F4} - EndGlobalSection -EndGlobal diff --git a/src/WebExpress.WebCore.slnx b/src/WebExpress.WebCore.slnx new file mode 100644 index 00000000..74728ba2 --- /dev/null +++ b/src/WebExpress.WebCore.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.sln b/src/WebExpress.WebCore/WebExpress.WebCore.sln deleted file mode 100644 index 00a922bc..00000000 --- a/src/WebExpress.WebCore/WebExpress.WebCore.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebExpress.WebCore", "WebExpress.WebCore.csproj", "{01E99C8B-0B21-46C0-93A9-6A8C2E89B619}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Debug|Any CPU.Build.0 = Debug|Any CPU - {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Release|Any CPU.ActiveCfg = Release|Any CPU - {01E99C8B-0B21-46C0-93A9-6A8C2E89B619}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {FD0FF9EA-12BF-4F03-B62F-F720D51C23DB} - EndGlobalSection -EndGlobal From a4dd3c58da3a457197e6a3ab9c5ef09fea67049a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Wed, 19 Aug 2026 18:52:18 +0200 Subject: [PATCH 27/69] feat: add section control and minor bugs --- .../WebSitemap/SitemapManager.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 5016e774..4b9d6147 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -513,9 +513,32 @@ SearchContext searchContext }; } - foreach (var child in node.Children.Where(x => IsMatched(x, nextPathSegment))) + // a constant segment is the more specific match, so it is tried before a + // variable one. without the ordering the winner is whichever endpoint happened + // to register first, which lets a route like /assets/${workspacekey} swallow + // /assets/css/theme.css and answer 404 from a page that was never meant to + // serve it. + // + // a candidate that leads nowhere is no longer the end of the search either: + // each branch is walked on copies of the queues, so an exhausted branch leaves + // the state untouched for the next candidate instead of taking the whole + // request down with it. + foreach (var child in node.Children + .Where(x => IsMatched(x, nextPathSegment)) + .OrderBy(x => x.PathSegment is IUriPathSegmentVariable ? 1 : 0)) { - return SearchNode(child, inPathSegments, outPathSegments, searchContext); + var result = SearchNode + ( + child, + new Queue(inPathSegments), + new Queue(outPathSegments), + searchContext + ); + + if (result is not null) + { + return result; + } } } From fc71db88c155b43c76b56b6a3140f255e5011174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 20 Aug 2026 05:44:43 +0200 Subject: [PATCH 28/69] feat: general improvements and minor bugs --- .../Manager/UnitTestApplicationManager.cs | 103 ++++++++++++++++++ .../Internationalization/de | 1 + .../Internationalization/en | 1 + .../WebApplication/ApplicationManager.cs | 82 +++++++++++++- .../WebApplication/IApplicationManager.cs | 33 ++++++ .../Model/ApplicationDictionary.cs | 17 +++ .../WebApplication/Model/ApplicationItem.cs | 15 +++ 7 files changed, 251 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs index 92c22a41..f8a2c366 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestApplicationManager.cs @@ -117,6 +117,109 @@ public void Icon(Type applicationType, string icon) Assert.Equal(icon, application.Icon.ToString()); } + /// + /// Test that the name of a registered application can be replaced at runtime, and that a + /// blank value puts the declared name back. + /// + [Fact] + public void SetApplicationName() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var declared = application.ApplicationName; + var updated = new List(); + + applicationManager.UpdateApplication += (_, context) => updated.Add(context); + + // act + applicationManager.SetApplicationName(application, "Renamed"); + + // validation + Assert.Equal("Renamed", application.ApplicationName); + Assert.Equal("webexpress.webcore.test.testapplicationa", application.ApplicationId); + Assert.Single(updated); + + // a blank value restores what the application declared + applicationManager.SetApplicationName(application, " "); + + Assert.Equal(declared, application.ApplicationName); + Assert.Equal(2, updated.Count); + } + + /// + /// Test that renaming an application to the name it already carries changes nothing and + /// raises no event. + /// + [Fact] + public void SetApplicationNameUnchanged() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var updated = 0; + + applicationManager.UpdateApplication += (_, _) => updated++; + + // act + applicationManager.SetApplicationName(application, application.ApplicationName); + + // validation + Assert.Equal(0, updated); + } + + /// + /// Test that the icon of a registered application can be replaced at runtime. The value is + /// a path relative to the application, which the manager combines into a route the same + /// way it does at registration. + /// + [Fact] + public void SetApplicationIcon() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var application = applicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + var updated = new List(); + + applicationManager.UpdateApplication += (_, context) => updated.Add(context); + + // act + applicationManager.SetApplicationIcon(application, "/assets/img/Custom.svg"); + + // validation + Assert.Equal("/server/appa/assets/img/Custom.svg", application.Icon.ToString()); + Assert.Single(updated); + + // a blank value restores what the application declared + applicationManager.SetApplicationIcon(application, null); + + Assert.Equal("/server/appa/assets/img/Logo.png", application.Icon.ToString()); + Assert.Equal(2, updated.Count); + } + + /// + /// Test that an unknown application context is ignored rather than throwing. + /// + [Fact] + public void SetApplicationNameOfUnknownApplication() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var applicationManager = componentHub.ApplicationManager; + var updated = 0; + + applicationManager.UpdateApplication += (_, _) => updated++; + + // act + applicationManager.SetApplicationName(null, "Renamed"); + + // validation + Assert.Equal(0, updated); + } + /// /// Test the context path property of the application. /// diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 4c59feaf..6041845b 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -78,6 +78,7 @@ pluginmanager.applicationless=Das Plugin '{0}' besitzt keine Angaben zur Anwendu applicationmanager.titel=Anwendungsmanager: applicationmanager.initialization=Der Anwendungsmanager wurde initialisiert. applicationmanager.register=Die Anwendung '{0}' wurde erstellt und im Anwendungsmanager registriert. +applicationmanager.update=Der Name oder das Icon der Anwendung '{0}' wurde zur Laufzeit geändert. applicationmanager.duplicate=Die Anwendung '{0}' wurde bereits im Anwendungsmanager registriert. applicationmanager.application=Anwendung: '{0}' applicationmanager.application.initialization=Die Anwendung '{0}' wurde initialisiert. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 678e19ff..fb83b940 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -78,6 +78,7 @@ pluginmanager.applicationless=The plugin '{0}' does not have any information abo applicationmanager.titel=Application manager: applicationmanager.initialization=The application manager has been initialized. applicationmanager.registerapplication=The application '{0}' has been created and registered in the application manager. +applicationmanager.update=The name or the icon of the application '{0}' was changed at runtime. applicationmanager.duplicateapplication=The application '{0}' has already been registered in the application manager. applicationmanager.application=Application: '{0}' applicationmanager.application.initialization=The application'{0}' has been initialized. diff --git a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs index db2358f8..b58a6f0b 100644 --- a/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/ApplicationManager.cs @@ -35,6 +35,11 @@ public sealed class ApplicationManager : IApplicationManager, IExecutableElement /// public event EventHandler RemoveApplication; + /// + /// An event that fires when the name or the icon of a registered application changed. + /// + public event EventHandler UpdateApplication; + /// /// Gets the stored applications. /// @@ -156,7 +161,9 @@ private void Register(IPluginContext pluginContext) { ApplicationClass = type, ApplicationContext = applicationContext, - Application = applicationInstance + Application = applicationInstance, + DeclaredApplicationName = name, + DeclaredIcon = icon })) { _httpServerContext?.Log?.Debug @@ -270,6 +277,79 @@ public IEnumerable GetApplications(Type application) return _dictionary.GetApplications(application); } + /// + /// Replaces the display name of a registered application. + /// + /// The context of the application to rename. + /// The new name. A blank value restores the declared one. + public void SetApplicationName(IApplicationContext applicationContext, string applicationName) + { + var item = _dictionary.GetApplicationItem(applicationContext); + + if (item?.ApplicationContext is not ApplicationContext context) + { + return; + } + + var name = string.IsNullOrWhiteSpace(applicationName) + ? item.DeclaredApplicationName + : applicationName; + + if (context.ApplicationName == name) + { + return; + } + + context.ApplicationName = name; + + OnUpdateApplication(context); + } + + /// + /// Replaces the icon of a registered application. + /// + /// The context of the application. + /// The new icon path, relative to the application. A blank value + /// restores the declared one. + public void SetApplicationIcon(IApplicationContext applicationContext, string icon) + { + var item = _dictionary.GetApplicationItem(applicationContext); + + if (item?.ApplicationContext is not ApplicationContext context) + { + return; + } + + var path = string.IsNullOrWhiteSpace(icon) ? item.DeclaredIcon : icon; + + // combined exactly as at registration, so a caller hands over the same relative path + // the [Icon] attribute would have carried and never has to assemble a route + var route = RouteEndpoint.Combine(_httpServerContext?.Route, context.ContextPath, path); + + if (context.Icon?.ToString() == route?.ToString()) + { + return; + } + + context.Icon = route; + + OnUpdateApplication(context); + } + + /// + /// Raises the update event and logs the change. + /// + /// The context that changed. + private void OnUpdateApplication(IApplicationContext applicationContext) + { + UpdateApplication?.Invoke(this, applicationContext); + + _httpServerContext?.Log?.Debug + ( + I18N.Translate("webexpress.webcore:applicationmanager.update", applicationContext.ApplicationId) + ); + } + /// /// Boots the applications. /// diff --git a/src/WebExpress.WebCore/WebApplication/IApplicationManager.cs b/src/WebExpress.WebCore/WebApplication/IApplicationManager.cs index 037f1795..b34fad24 100644 --- a/src/WebExpress.WebCore/WebApplication/IApplicationManager.cs +++ b/src/WebExpress.WebCore/WebApplication/IApplicationManager.cs @@ -20,6 +20,11 @@ public interface IApplicationManager : IComponentManager /// event EventHandler RemoveApplication; + /// + /// An event that fires when the name or the icon of a registered application changed. + /// + event EventHandler UpdateApplication; + /// /// Gets the stored applications. /// @@ -59,5 +64,33 @@ public interface IApplicationManager : IComponentManager /// The application type. /// The contexts of the applications as an enumeration. IEnumerable GetApplications(Type application); + + /// + /// Replaces the display name of a registered application. + /// + /// + /// The name an application registers with comes from its [Name] attribute and is + /// therefore fixed at compile time. An installation that wants to call the application + /// something else - a tenant with its own branding, a deployment named after the team it + /// serves - has no way to say so through an attribute, which is what this exists for. The + /// application id is untouched: it identifies the application to the framework, while the + /// name is only ever shown to a reader. + /// + /// The context of the application to rename. + /// The new name. A blank value restores the declared one. + void SetApplicationName(IApplicationContext applicationContext, string applicationName); + + /// + /// Replaces the icon of a registered application. + /// + /// + /// The value is a path relative to the application, exactly as the [Icon] attribute + /// declares it; the manager combines it with the server route and the context path the + /// same way it does at registration, so a caller never has to know how the route is + /// assembled. + /// + /// The context of the application. + /// The new icon path. A blank value restores the declared one. + void SetApplicationIcon(IApplicationContext applicationContext, string icon); } } diff --git a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs index c6dd0585..97c3754d 100644 --- a/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs +++ b/src/WebExpress.WebCore/WebApplication/Model/ApplicationDictionary.cs @@ -55,6 +55,23 @@ public IEnumerable RemoveApplications(IPluginContext plugin return applicationContexts; } + /// + /// Returns the item carrying a given application context. + /// + /// The application context. + /// The item, or null when the context belongs to no registered application. + public ApplicationItem GetApplicationItem(IApplicationContext applicationContext) + { + if (applicationContext is null) + { + return null; + } + + return _dict + .Values.SelectMany(x => x.Values) + .FirstOrDefault(x => x.ApplicationContext == applicationContext); + } + /// /// Returns the application contexts for a given plugin context. /// diff --git a/src/WebExpress.WebCore/WebApplication/Model/ApplicationItem.cs b/src/WebExpress.WebCore/WebApplication/Model/ApplicationItem.cs index 9e084bb8..864374c3 100644 --- a/src/WebExpress.WebCore/WebApplication/Model/ApplicationItem.cs +++ b/src/WebExpress.WebCore/WebApplication/Model/ApplicationItem.cs @@ -23,6 +23,21 @@ internal class ApplicationItem /// public IApplication Application { get; set; } + /// + /// Gets the name the application declared through its [Name] attribute. + /// + /// + /// Kept beside the context so a runtime rename can be undone: the context carries what is + /// shown now, this carries what the application asked to be called. + /// + public string DeclaredApplicationName { get; set; } + + /// + /// Gets the icon path the application declared through its [Icon] attribute, + /// relative to the application. See . + /// + public string DeclaredIcon { get; set; } + /// /// Gets the thread termination token. /// From 3643f9c5894067f2b7c743ae27eef8dd780fedb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 20 Aug 2026 17:54:03 +0200 Subject: [PATCH 29/69] feat: responsive app-shell layout --- .../Html/UnitTestHtmlElementMetadataMeta.cs | 20 +++++++++++-- .../WebHtml/HtmlElementMetadataMeta.cs | 30 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs index ad2b7730..2c56844f 100644 --- a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlElementMetadataMeta.cs @@ -9,8 +9,8 @@ namespace WebExpress.WebCore.Test.Html public class UnitTestHtmlElementMetadataMeta { /// - /// Tests a tag built from a key and a value. Meta is rendered as a single - /// key/value attribute, so the empty constructor has no meaningful output. + /// Tests the one key that names a declaration by itself and therefore + /// carries its value directly. /// [Fact] public void KeyValue() @@ -20,5 +20,21 @@ public void KeyValue() Assert.Equal(@"", html.Trim()); } + + /// + /// Tests that every other key becomes a name/content pair. Rendered as a + /// single attribute the declaration is markup no browser acts on, which + /// is what left the viewport without effect on small screens. + /// + [Theory] + [InlineData("viewport", "width=device-width, initial-scale=1")] + [InlineData("description", "A page.")] + public void NamedValue(string key, string value) + { + // act + var html = new HtmlElementMetadataMeta(key, value); + + Assert.Equal($"", html.Trim()); + } } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataMeta.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataMeta.cs index c52ff724..73a6a13f 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataMeta.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementMetadataMeta.cs @@ -1,4 +1,5 @@ -using System.Text; +using System; +using System.Text; namespace WebExpress.WebCore.WebHtml { @@ -7,6 +8,12 @@ namespace WebExpress.WebCore.WebHtml /// public class HtmlElementMetadataMeta : HtmlElement, IHtmlElementMetadata { + /// + /// The keys that name a meta declaration in their own right. Everything + /// else is a named declaration and belongs in a name/content pair. + /// + private static readonly string[] _standaloneKeys = ["charset"]; + /// /// Gets or sets the attribute name. /// @@ -52,6 +59,13 @@ public HtmlElementMetadataMeta(string key, string value) /// /// Convert to a string using a StringBuilder. /// + /// + /// Only charset carries its value directly; every other declaration is + /// addressed by name and holds its value in content. Writing them all as + /// a single attribute produced markup a browser has no rule for and + /// silently dropped - which is how the viewport declaration came to have + /// no effect on small screens. + /// /// The string builder. /// The call depth. public override void ToString(StringBuilder builder, int deep) @@ -61,8 +75,20 @@ public override void ToString(StringBuilder builder, int deep) builder.Append("<"); builder.Append(ElementName); builder.Append(" "); + + if (Array.Exists(_standaloneKeys, x => x.Equals(Key, StringComparison.OrdinalIgnoreCase))) + { + builder.Append(Key); + builder.Append("='"); + builder.Append(Value); + builder.Append("'>"); + + return; + } + + builder.Append("name='"); builder.Append(Key); - builder.Append("='"); + builder.Append("' content='"); builder.Append(Value); builder.Append("'>"); } From 0f0f708c9b5cf1980ac6e04090a06d6ead87a0b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Fri, 21 Aug 2026 23:23:00 +0200 Subject: [PATCH 30/69] feat: icon improvements and minor bugs --- .../Manager/UnitTestThemeManager.cs | 25 ------- src/WebExpress.WebCore.Test/TestThemeA.cs | 2 - .../WebAttribute/IconThemeAttribute.cs | 41 ----------- .../WebIcon/TypeIconTheme.cs | 18 ----- src/WebExpress.WebCore/WebPage/PageManager.cs | 39 ++-------- .../WebSettingPage/SettingPageManager.cs | 39 ++-------- .../WebTheme/IThemeContext.cs | 10 --- .../WebTheme/RenderContextThemeExtensions.cs | 72 +------------------ .../WebTheme/ThemeContext.cs | 6 -- .../WebTheme/ThemeManager.cs | 14 ---- 10 files changed, 13 insertions(+), 253 deletions(-) delete mode 100644 src/WebExpress.WebCore/WebAttribute/IconThemeAttribute.cs delete mode 100644 src/WebExpress.WebCore/WebIcon/TypeIconTheme.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs index d0dbffbb..5476d8e2 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestThemeManager.cs @@ -207,30 +207,5 @@ public void ThemeStyle(Type applicationType, Type themeType, string expected) Assert.NotNull(theme); Assert.Equal(expected, theme?.ThemeStyle?.ToString()); } - - /// - /// Test the icon theme property of the theme. TestThemeA - /// carries [IconTheme(Light)]; TestThemeB does not - /// declare an icon theme and therefore falls back to - /// . - /// - [Theory] - [InlineData(typeof(TestApplicationA), typeof(TestThemeA), WebCore.WebIcon.TypeIconTheme.Light)] - [InlineData(typeof(TestApplicationA), typeof(TestThemeB), WebCore.WebIcon.TypeIconTheme.Default)] - [InlineData(typeof(TestApplicationB), typeof(TestThemeA), WebCore.WebIcon.TypeIconTheme.Light)] - [InlineData(typeof(TestApplicationB), typeof(TestThemeB), WebCore.WebIcon.TypeIconTheme.Default)] - public void IconTheme(Type applicationType, Type themeType, WebCore.WebIcon.TypeIconTheme expected) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var application = componentHub.ApplicationManager.GetApplications(applicationType).FirstOrDefault(); - - // act - var theme = componentHub.ThemeManager.GetThemes(application, themeType).FirstOrDefault(); - - // validation - Assert.NotNull(theme); - Assert.Equal(expected, theme?.IconTheme); - } } } diff --git a/src/WebExpress.WebCore.Test/TestThemeA.cs b/src/WebExpress.WebCore.Test/TestThemeA.cs index 09dbf1c6..680a5a49 100644 --- a/src/WebExpress.WebCore.Test/TestThemeA.cs +++ b/src/WebExpress.WebCore.Test/TestThemeA.cs @@ -1,5 +1,4 @@ using WebExpress.WebCore.WebAttribute; -using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebTheme; namespace WebExpress.WebCore.Test @@ -12,7 +11,6 @@ namespace WebExpress.WebCore.Test [Image("webexpress.webcore.test.testthemea.png")] [ThemeMode(ThemeMode.Dark)] [ThemeStyle("/asserts/css/themea.css")] - [IconTheme(TypeIconTheme.Light)] public sealed class TestThemeA : ITheme { /// diff --git a/src/WebExpress.WebCore/WebAttribute/IconThemeAttribute.cs b/src/WebExpress.WebCore/WebAttribute/IconThemeAttribute.cs deleted file mode 100644 index 652363c9..00000000 --- a/src/WebExpress.WebCore/WebAttribute/IconThemeAttribute.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using WebExpress.WebCore.WebIcon; - -namespace WebExpress.WebCore.WebAttribute -{ - /// - /// Specifies the icon theme to use for rendering icons within a theme. - /// Apply this attribute to a class implementing ITheme; the value is - /// surfaced through IThemeContext.IconTheme and emitted on the root - /// <html data-icon-theme> attribute when the theme is active. - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] - public class IconThemeAttribute : Attribute, IThemeAttribute - { - /// - /// Gets the icon theme used to display type icons. - /// - public TypeIconTheme Theme { get; } - - /// - /// Initializes a new instance of the class. - /// - public IconThemeAttribute() - { - Theme = TypeIconTheme.Default; - } - - /// - /// Initializes a new instance of the class using the specified icon theme. - /// - /// - /// The icon theme applied to this attribute. The selected theme determines the visual appearance - /// of the associated web icon. - /// - - public IconThemeAttribute(TypeIconTheme theme) - { - Theme = theme; - } - } -} diff --git a/src/WebExpress.WebCore/WebIcon/TypeIconTheme.cs b/src/WebExpress.WebCore/WebIcon/TypeIconTheme.cs deleted file mode 100644 index dc8b84a2..00000000 --- a/src/WebExpress.WebCore/WebIcon/TypeIconTheme.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace WebExpress.WebCore.WebIcon; - -/// -/// Defines the visual theme of an icon. -/// -public enum TypeIconTheme -{ - /// - /// The default theme renders icons using the bundled FontAwesome glyph font. - /// - Default, - - /// - /// The light theme renders icons using the lightweight, line-style SVG variants - /// shipped with WebExpress.WebUI. - /// - Light -} diff --git a/src/WebExpress.WebCore/WebPage/PageManager.cs b/src/WebExpress.WebCore/WebPage/PageManager.cs index 27dd4a4a..53e58ce3 100644 --- a/src/WebExpress.WebCore/WebPage/PageManager.cs +++ b/src/WebExpress.WebCore/WebPage/PageManager.cs @@ -462,7 +462,7 @@ var attribute in pageType PluginContext = pluginContext, ApplicationContext = applicationContext, PageTitle = title, - PageIcon = GetIcon(icon, applicationContext, _componentHub), + PageIcon = GetIcon(icon), Route = routePath, Scopes = scopes, Domains = domains, @@ -600,46 +600,19 @@ private void OnRemoveApplication(object sender, IApplicationContext e) } /// - /// Creates an instance of an icon of the specified type, optionally using theme information if available. + /// Creates an instance of an icon of the specified type. /// /// /// The type of the icon to instantiate. Must implement the IIcon interface. /// - /// - /// The application context used for resolving dependencies or additional information required for - /// icon creation. - /// - /// - /// The component hub used to discover the active theme so the icon - /// is constructed with the matching TypeIconTheme when the - /// icon type ships theme-specific variants. - /// /// /// An instance of IIcon created from the specified type. Returns null if the icon cannot be instantiated. /// - private static IIcon GetIcon(Type iconType, IApplicationContext applicationContext, IComponentHub componentHub) + private static IIcon GetIcon(Type iconType) { - if (iconType is not null) - { - // resolve theme from the first theme registered for this application - - // falls back to TypeIconTheme.Default when no theme is registered. - var themeValue = componentHub?.ThemeManager?.Themes - ?.FirstOrDefault(t => t.ApplicationContext == applicationContext)?.IconTheme - ?? TypeIconTheme.Default; - var themeType = themeValue.GetType(); - - // look for a constructor on the icon type that accepts the theme type - var ctorWithTheme = iconType.GetConstructor([themeType]); - if (ctorWithTheme is not null) - { - return ctorWithTheme.Invoke([themeValue]) as IIcon; - } - - // fallback: parameterless constructor - return Activator.CreateInstance(iconType) as IIcon; - } - - return null; + return iconType is not null + ? Activator.CreateInstance(iconType) as IIcon + : null; } /// diff --git a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs index 4838ba1e..113e18e7 100644 --- a/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs +++ b/src/WebExpress.WebCore/WebSettingPage/SettingPageManager.cs @@ -613,7 +613,7 @@ var attribute in settingPageType IncludeSubPaths = includeSubPaths, Attributes = EndpointManager.GetAttributeInstances(attributes), PageTitle = title, - PageIcon = GetIcon(icon, applicationContext, _componentHub), + PageIcon = GetIcon(icon), Scopes = scopes, Domains = domains, SettingGroup = _groupDictionary.GetSettingGroup(applicationContext, group), @@ -859,46 +859,19 @@ private void OnRemoveApplication(object sender, IApplicationContext e) } /// - /// Creates an instance of an icon of the specified type, optionally using theme information if available. + /// Creates an instance of an icon of the specified type. /// /// /// The type of the icon to instantiate. Must implement the IIcon interface. /// - /// - /// The application context used for resolving dependencies or additional information required for - /// icon creation. - /// - /// - /// The component hub used to discover the active theme so the icon - /// is constructed with the matching TypeIconTheme when the - /// icon type ships theme-specific variants. - /// /// /// An instance of IIcon created from the specified type. Returns null if the icon cannot be instantiated. /// - private static IIcon GetIcon(Type iconType, IApplicationContext applicationContext, IComponentHub componentHub) + private static IIcon GetIcon(Type iconType) { - if (iconType is not null) - { - // resolve theme from the first theme registered for this application - - // falls back to TypeIconTheme.Default when no theme is registered. - var themeValue = componentHub?.ThemeManager?.Themes - ?.FirstOrDefault(t => t.ApplicationContext == applicationContext)?.IconTheme - ?? TypeIconTheme.Default; - var themeType = themeValue.GetType(); - - // look for a constructor on the icon type that accepts the theme type - var ctorWithTheme = iconType.GetConstructor([themeType]); - if (ctorWithTheme is not null) - { - return ctorWithTheme.Invoke([themeValue]) as IIcon; - } - - // fallback: parameterless constructor - return Activator.CreateInstance(iconType) as IIcon; - } - - return null; + return iconType is not null + ? Activator.CreateInstance(iconType) as IIcon + : null; } /// diff --git a/src/WebExpress.WebCore/WebTheme/IThemeContext.cs b/src/WebExpress.WebCore/WebTheme/IThemeContext.cs index 6aff7b4c..8d1dfd63 100644 --- a/src/WebExpress.WebCore/WebTheme/IThemeContext.cs +++ b/src/WebExpress.WebCore/WebTheme/IThemeContext.cs @@ -1,7 +1,6 @@ using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPlugin; namespace WebExpress.WebCore.WebTheme @@ -50,14 +49,5 @@ public interface IThemeContext : IContext /// Gets the route resource for the css theme style. /// IRoute ThemeStyle { get; } - - /// - /// Gets the icon theme used when this theme is active. Determines - /// whether icons are rendered with the bundled FontAwesome glyphs - /// () or the lightweight SVG - /// variants shipped with WebExpress.WebUI - /// (). - /// - TypeIconTheme IconTheme { get; } } } diff --git a/src/WebExpress.WebCore/WebTheme/RenderContextThemeExtensions.cs b/src/WebExpress.WebCore/WebTheme/RenderContextThemeExtensions.cs index 1f5d0b21..17ba79b0 100644 --- a/src/WebExpress.WebCore/WebTheme/RenderContextThemeExtensions.cs +++ b/src/WebExpress.WebCore/WebTheme/RenderContextThemeExtensions.cs @@ -1,5 +1,4 @@ using System.Linq; -using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPage; namespace WebExpress.WebCore.WebTheme @@ -20,8 +19,7 @@ namespace WebExpress.WebCore.WebTheme /// The first theme registered for the application (legacy fallback). /// /// - /// ; downstream - /// callers fall back to . + /// ; callers then render without a theme. /// /// /// Per-user overrides are wired by application code: the page's @@ -55,73 +53,5 @@ public static IThemeContext GetActiveTheme(this IRenderContext renderContext) return WebEx.ComponentHub?.ThemeManager?.Themes ?.FirstOrDefault(t => t.ApplicationContext == applicationContext); } - - /// - /// Returns the icon theme of the active theme, falling back to - /// when no theme is registered - /// for the render context's application. - /// - /// The current render context. - /// The icon theme to use when rendering icons. - public static TypeIconTheme GetIconTheme(this IRenderContext renderContext) - { - return renderContext.GetActiveTheme()?.IconTheme ?? TypeIconTheme.Default; - } - - /// - /// Re-themes an existing for the active - /// icon theme of . Convenience over - /// for - /// callers that only have a render context in hand. - /// - /// The icon to re-theme; may be . - /// The current render context. - /// The re-themed icon or the original instance. - public static WebIcon.IIcon ApplyIconTheme(this WebIcon.IIcon icon, IRenderContext renderContext) - { - return icon.ApplyIconTheme(renderContext.GetIconTheme()); - } - - /// - /// Re-themes an existing for the given - /// . Icons created at registration time - /// (e.g. PageContext.PageIcon) carry the theme that was - /// active when the page was discovered; this helper rebuilds them - /// so the breadcrumb, sidebars, etc. swap glyphs at runtime when - /// the application code activates a different theme via - /// visualTree.UseTheme<TTheme>(). Controls that have - /// a visual tree in hand should pass - /// visualTree.IconTheme here. - /// - /// Falls back to when its concrete type does - /// not expose a (TypeIconTheme) constructor. - /// - /// - /// The icon to re-theme; may be . - /// The icon theme to apply. - /// The re-themed icon or the original instance. - public static WebIcon.IIcon ApplyIconTheme(this WebIcon.IIcon icon, TypeIconTheme theme) - { - if (icon is null) - { - return null; - } - - var iconType = icon.GetType(); - var ctor = iconType.GetConstructor(new[] { typeof(TypeIconTheme) }); - if (ctor is null) - { - return icon; - } - - try - { - return ctor.Invoke(new object[] { theme }) as WebIcon.IIcon ?? icon; - } - catch - { - return icon; - } - } } } diff --git a/src/WebExpress.WebCore/WebTheme/ThemeContext.cs b/src/WebExpress.WebCore/WebTheme/ThemeContext.cs index 27325e68..94e74015 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeContext.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeContext.cs @@ -1,7 +1,6 @@ using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebPlugin; namespace WebExpress.WebCore.WebTheme @@ -50,10 +49,5 @@ public class ThemeContext : IThemeContext /// Gets the route resource for the css theme style. /// public IRoute ThemeStyle { get; internal set; } - - /// - /// Gets the icon theme used when this theme is active. - /// - public TypeIconTheme IconTheme { get; internal set; } } } diff --git a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs index b191c603..fd9b897d 100644 --- a/src/WebExpress.WebCore/WebTheme/ThemeManager.cs +++ b/src/WebExpress.WebCore/WebTheme/ThemeManager.cs @@ -7,7 +7,6 @@ using WebExpress.WebCore.WebAttribute; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; -using WebExpress.WebCore.WebIcon; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebTheme.Model; @@ -157,7 +156,6 @@ private void Register(IPluginContext pluginContext, IEnumerable x.AttributeType.GetInterfaces().Contains(typeof(IThemeAttribute)))) @@ -189,17 +187,6 @@ private void Register(IPluginContext pluginContext, IEnumerable(customAttribute.ConstructorArguments.FirstOrDefault().Value?.ToString()); - } - catch - { - iconTheme = TypeIconTheme.Default; - } - } } // assign the theme to existing applications @@ -215,7 +202,6 @@ private void Register(IPluginContext pluginContext, IEnumerable Date: Wed, 26 Aug 2026 21:45:13 +0200 Subject: [PATCH 31/69] feat: general improvements and minor bugs --- .../Manager/UnitTestAssetManager.cs | 49 ++++++++++++++ src/WebExpress.WebCore/HttpServer.cs | 43 +++++++++++++ .../Internationalization/de | 1 + .../Internationalization/en | 1 + .../WebAsset/AssetManager.cs | 48 ++++++++++++-- .../WebAsset/IAssetManager.cs | 15 +++++ .../WebInclude/IncludeManager.cs | 64 +++++++++++++++++++ 7 files changed, 215 insertions(+), 6 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs index a4d52c6e..77dae537 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAssetManager.cs @@ -94,6 +94,55 @@ public void Uri(Type applicationType, string route) Assert.Equal(route, asset?.Route.ToString()); } + /// + /// Tests that the route resolved for a file of a plugin is the route the asset is + /// really mounted on. Consumers that link an embedded file - the includes a page + /// renders as link and script elements - resolve it this way instead of composing + /// the route themselves, because a route that disagrees with the mount answers 404 + /// and a browser accepts that html error page as a stylesheet with no rules. + /// + [Theory] + [InlineData(typeof(TestApplicationA), "/assets/css/mycss.css", "/server/appa/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationA), "/assets/js/myjavascript.js", "/server/appa/assets/js/myjavascript.js")] + [InlineData(typeof(TestApplicationB), "/assets/css/mycss.css", "/server/appb/assets/css/mycss.css")] + [InlineData(typeof(TestApplicationC), "/assets/css/mycss.css", "/server/assets/css/mycss.css")] + public void AssetRoute(Type applicationType, string file, string expected) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(applicationType)?.FirstOrDefault(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + + // act + var route = componentHub.AssetManager.GetAssetRoute(application, plugin, file); + + // validation + Assert.Equal(expected, route?.ToString()); + Assert.Contains + ( + expected, + componentHub.AssetManager.GetAssets(application).Select(x => x.Route.ToString()) + ); + } + + /// + /// Tests that a resolution which does not describe a file answers with nothing rather + /// than with a route that leads nowhere. + /// + [Fact] + public void AssetRouteWithoutFile() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA))?.FirstOrDefault(); + var plugin = componentHub.PluginManager?.GetPlugin(typeof(TestPlugin)); + + // act & validation + Assert.Null(componentHub.AssetManager.GetAssetRoute(application, plugin, null)); + Assert.Null(componentHub.AssetManager.GetAssetRoute(application, null, "/assets/css/mycss.css")); + Assert.Null(componentHub.AssetManager.GetAssetRoute(null, plugin, "/assets/css/mycss.css")); + } + /// /// Test the request of the asset. /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index e367fce0..e484712b 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -549,6 +549,12 @@ private static IResponse CreateStatusPage(string message, IRequest re where TResponse : Response, new() { var response = new TResponse() as Response; + + if (IsNonHtmlFileRequest(request)) + { + return CreatePlainStatusResponse(response); + } + var statusPageManager = WebEx.ComponentHub.StatusPageManager; var applicationManager = WebEx.ComponentHub.ApplicationManager; var route = new RouteEndpoint(request.Uri.PathSegments)?.ToString(); @@ -590,6 +596,43 @@ private static IResponse CreateStatusPage(string message, IRequest re return response; } + /// + /// Determines whether the request names a file whose type is not html. A browser + /// loading a stylesheet, script or image does not surface the status of the answer, + /// it only reads the body, so an html status page is taken as the file itself: a + /// missing stylesheet presents as a valid one with no rules and a missing script as + /// one that defines nothing. + /// + /// The request whose target is examined. + /// True when the requested file is of a known, non-html type. + private static bool IsNonHtmlFileRequest(IRequest request) + { + var file = request?.Uri?.PathSegments?.LastOrDefault()?.ToString(); + var contentType = ContentTypeExtensions.ToContentType(System.IO.Path.GetExtension(file)); + + return contentType != ContentType.Unknown + && contentType != ContentType.Html + && contentType != ContentType.Htm; + } + + /// + /// Answers a status for a non-html file with a plain text body, so a browser rejects + /// it instead of accepting the status page as the file it asked for. The status + /// itself is untouched; only the body a client would otherwise misread is replaced. + /// + /// The status response to complete. + /// The response carrying a plain text body. + private static IResponse CreatePlainStatusResponse(Response response) + { + var content = $"{response.Status} - {response.Reason}"; + + response.Content = content; + response.Header.ContentLength = content.Length; + response.Header.ContentType = "text/plain; charset=utf-8"; + + return response; + } + /// /// Creates an appropriate IHttpContext instance (HttpContext or WebSocketContext) /// based on feature detection. diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 6041845b..d6427eda 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -176,6 +176,7 @@ identitymanager.policy.systemaccess.description=Policy für Operationen auf Syst includemanager.initialization=Der Includemanager wurde initialisiert. includemanager.addinclude=Die Client-Ressource '{0}' wurde in der Anwendung '{1}' registiert. includemanager.removeinclude=Die Client-Ressource '{0}' wurde aus der Anwendung '{1}' entfernt. +includemanager.unresolvedfile=Die Datei '{1}' der Client-Ressource '{0}' verweist auf die Route '{2}', unter der in der Anwendung '{3}' kein Asset bereitgestellt wird. socketmanager.initialization=Der Socketmanager wurde initialisiert. socketmanager.addsocket=Der WebSocket '{0}' wurde in der Anwendung '{1}' registiert. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index fb83b940..d17c27a5 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -176,6 +176,7 @@ identitymanager.policy.systemaccess.description=Policy for system-level operatio includemanager.initialization=The include manager has been initialized. includemanager.addinclude=The client resource '{0}' has been registered in the application '{1}'. includemanager.removeinclude=The client resource '{0}' has been removed from the application '{1}'. +includemanager.unresolvedfile=The file '{1}' of the client resource '{0}' points to the route '{2}', where no asset is served in the application '{3}'. socketmanager.initialization=The WebSocket manager has been initialized. socketmanager.addsocket=The WebSocket '{0}' has been registered in the application '{1}'. diff --git a/src/WebExpress.WebCore/WebAsset/AssetManager.cs b/src/WebExpress.WebCore/WebAsset/AssetManager.cs index 68cce5b6..65e5b8be 100644 --- a/src/WebExpress.WebCore/WebAsset/AssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/AssetManager.cs @@ -175,12 +175,7 @@ private void Register(IPluginContext pluginContext, IEnumerable + /// Returns the route a plugin's endpoints are addressed under within an application. + /// A plugin that owns the application serves straight from the application route, + /// while every other plugin contributing to that application is given a segment of + /// its own so two plugins cannot collide on the same file name. + /// + /// The context of the application. + /// The context of the plugin. + /// The route the plugin's assets are mounted under. + private static IRoute GetPluginRoute(IApplicationContext applicationContext, IPluginContext pluginContext) + { + return applicationContext.Route.Concat + ( + applicationContext.PluginContext != pluginContext + ? pluginContext.PluginId.ToString() + : null + ); + } + + /// + /// Returns the route the given embedded file of a plugin is served from within an + /// application. Consumers that have to link an embedded file - an include rendering a + /// link or script element - resolve it here instead of composing the route themselves, + /// because a route composed independently drifts from the mount without anything + /// noticing: the browser answers the resulting 404 with the html error page and accepts + /// it as a stylesheet with no rules. + /// + /// The context of the application the file is addressed in. + /// The context of the plugin the file belongs to. + /// The file path relative to the plugin's mount, as declared on the asset attribute. + /// The route of the file, or null when the arguments do not describe one. + public IRoute GetAssetRoute(IApplicationContext applicationContext, IPluginContext pluginContext, string file) + { + if (applicationContext is null || pluginContext is null || string.IsNullOrWhiteSpace(file)) + { + return null; + } + + return GetPluginRoute(applicationContext, pluginContext).Concat(file); + } + /// /// Removes all resources associated with the specified plugin context. /// diff --git a/src/WebExpress.WebCore/WebAsset/IAssetManager.cs b/src/WebExpress.WebCore/WebAsset/IAssetManager.cs index 3d20f440..8e3721da 100644 --- a/src/WebExpress.WebCore/WebAsset/IAssetManager.cs +++ b/src/WebExpress.WebCore/WebAsset/IAssetManager.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebPlugin; namespace WebExpress.WebCore.WebAsset @@ -39,5 +40,19 @@ public interface IAssetManager : IComponentManager /// The context of the application. /// An enumeration of asset contextes. IEnumerable GetAssets(IApplicationContext applicationContext); + + /// + /// Returns the route the given embedded file of a plugin is served from within an + /// application. Consumers that have to link an embedded file - an include rendering a + /// link or script element - resolve it here instead of composing the route themselves, + /// because a route composed independently drifts from the mount without anything + /// noticing: the browser answers the resulting 404 with the html error page and accepts + /// it as a stylesheet with no rules. + /// + /// The context of the application the file is addressed in. + /// The context of the plugin the file belongs to. + /// The file path relative to the plugin's mount, as declared on the asset attribute. + /// The route of the file, or null when the arguments do not describe one. + IRoute GetAssetRoute(IApplicationContext applicationContext, IPluginContext pluginContext, string file); } } diff --git a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs index 1e09ac93..95b930a5 100644 --- a/src/WebExpress.WebCore/WebInclude/IncludeManager.cs +++ b/src/WebExpress.WebCore/WebInclude/IncludeManager.cs @@ -117,6 +117,14 @@ private void Register(IPluginContext pluginContext, IEnumerable>(); + foreach (var includeType in assembly.GetTypes() .Where(x => x.IsClass && x.IsSealed && x.IsPublic) .Where(x => x.GetInterface(typeof(IInclude).Name) is not null)) @@ -182,6 +190,8 @@ private void Register(IPluginContext pluginContext, IEnumerable new IncludeFile() { Type = x.Item1, FileName = x.Item2 }) }; + WarnAboutUnresolvedFiles(includeContext, mountedRoutes); + if (_dictionary.AddIncludeItem(pluginContext, applicationContext, includeItem)) { OnAddInclude(includeItem.IncludeContext); @@ -199,6 +209,60 @@ private void Register(IPluginContext pluginContext, IEnumerable + /// Logs every file of an include that does not resolve to an asset the asset manager + /// has mounted. The include is kept rather than refused, because the two managers are + /// wired to the same events and a hard refusal would turn any future change of that + /// order into a failed start instead of a log line. + /// + /// The include whose files are checked. + /// The per-application index of mounted asset routes, filled on demand. + private void WarnAboutUnresolvedFiles(IIncludeContext includeContext, Dictionary> mountedRoutes) + { + var applicationContext = includeContext.ApplicationContext; + var assetManager = _componentHub?.AssetManager; + + if (assetManager is null) + { + return; + } + + if (!mountedRoutes.TryGetValue(applicationContext, out var mounted)) + { + mounted = assetManager.GetAssets(applicationContext) + .Select(x => x.Route?.ToString()) + .Where(x => x is not null) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + mountedRoutes[applicationContext] = mounted; + } + + foreach (var file in includeContext.Files) + { + var route = assetManager.GetAssetRoute + ( + applicationContext, + includeContext.PluginContext, + file.FileName + )?.ToString(); + + if (route is not null && mounted.Contains(route)) + { + continue; + } + + _httpServerContext?.Log?.Warning( + I18N.Translate( + "webexpress.webcore:includemanager.unresolvedfile", + includeContext.IncludeId, + file.FileName, + route ?? string.Empty, + applicationContext.ApplicationId + ) + ); + } + } + /// /// Removes all includes associated with the plugin context. /// From 16e6e735a5482ab6d4a1fb67be6fac0c92f0d395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 27 Aug 2026 01:48:38 +0200 Subject: [PATCH 32/69] feat: add group control and minor bugs --- .../Message/UnitTestHttpContextHost.cs | 61 +++++++++++++++ src/WebExpress.WebCore/HttpServer.cs | 74 ++++++++++++++++--- .../WebMessage/HttpContext.cs | 29 +++++++- 3 files changed, 152 insertions(+), 12 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs b/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs new file mode 100644 index 00000000..6f7fe8ff --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestHttpContextHost.cs @@ -0,0 +1,61 @@ +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for the host-header handling of . + /// + /// + /// A client addressing a non-default port sends it in the host header, while the port of + /// the connection is known separately. Taking the header as the host name would produce a + /// second port and an unparsable uri, which fails before a request context exists - so + /// every request on such a port answers an empty error naming nothing. The tests pin the + /// forms a host header actually arrives in. + /// + public class UnitTestHttpContextHost + { + /// + /// Tests that a host header keeps only its name, whatever port it carries. + /// + /// The value of the host header. + /// The expected host name. + [Theory] + [InlineData("localhost", "localhost")] + [InlineData("localhost:8080", "localhost")] + [InlineData("example.org:443", "example.org")] + [InlineData("192.168.0.5:5000", "192.168.0.5")] + [InlineData("[::1]", "[::1]")] + [InlineData("[::1]:8080", "[::1]")] + [InlineData("[2001:db8::1]:80", "[2001:db8::1]")] + [InlineData("", "")] + [InlineData(null, null)] + public void HostNameOf(string host, string expected) + { + // act + var name = HttpContext.HostNameOf(host); + + // validation + Assert.Equal(expected, name); + } + + /// + /// Tests that the host name is usable as the host of a uri together with a port taken + /// from elsewhere - the case the production code builds. + /// + /// The value of the host header. + /// The port of the connection. + /// The expected uri. + [Theory] + [InlineData("localhost:8080", 8080, "http://localhost:8080/")] + [InlineData("localhost", 80, "http://localhost/")] + [InlineData("[::1]:8080", 8080, "http://[::1]:8080/")] + public void HostNameBuildsUri(string host, int port, string expected) + { + // act + var uri = new UriBuilder("http", HttpContext.HostNameOf(host), port).Uri; + + // validation + Assert.Equal(expected, uri.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index e484712b..ebcff2d9 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -426,10 +426,7 @@ request.Session is not null { HttpServerContext.Log?.Exception(ex); - var message = $"

Message

{ex.Message}

" + - $"
Source
{ex.Source}

" + - $"
StackTrace
{ex.StackTrace.Replace("\n", "
\n")}

" + - $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; + var message = Describe(ex); response = CreateStatusPage ( @@ -557,7 +554,14 @@ private static IResponse CreateStatusPage(string message, IRequest re var statusPageManager = WebEx.ComponentHub.StatusPageManager; var applicationManager = WebEx.ComponentHub.ApplicationManager; - var route = new RouteEndpoint(request.Uri.PathSegments)?.ToString(); + + // a request that failed before its context could be built has none, and the status + // page still has to be produced - it is the only place the original failure is + // reported. Without the guard the report itself fails and the caller receives an + // empty answer naming nothing. + var route = request?.Uri?.PathSegments is null + ? null + : new RouteEndpoint(request.Uri.PathSegments)?.ToString(); var applicationContext = string.IsNullOrEmpty(route) ? null : applicationManager.Applications @@ -662,15 +666,66 @@ public IHttpContext CreateContext(IFeatureCollection contextFeatures) } } + /// + /// Processes an http context asynchronously and answers a failure the pipeline itself + /// could not handle. + /// + /// + /// Kestrel treats an exception escaping here as a transport failure: it logs the bare + /// message without a stack trace and closes the connection with an empty body. Every + /// request then looks identically broken and nothing says where. Catching it means the + /// cause is written to the server log once and the caller receives a status page it can + /// read - which is what makes a fault in the shell diagnosable at all. + /// + /// The http context that the operation processes. + /// Provides an asynchronous operation that handles the http context. + public async Task ProcessRequestAsync(IHttpContext httpContext) + { + try + { + await ProcessRequestCoreAsync(httpContext); + } + catch (Exception ex) + { + HttpServerContext.Log?.Exception(ex); + + var response = CreateStatusPage + ( + Describe(ex), + httpContext?.Request + ); + + await new ResponseSender().SendAsync(httpContext, response); + } + } + + /// + /// Renders an exception as the html fragment a status page shows. + /// + /// + /// The stack trace is read defensively: an exception that was constructed but never + /// thrown carries none, and reading it unguarded fails inside the very code that + /// exists to report the first failure. + /// + /// The exception to describe. + /// The html fragment. + private static string Describe(Exception ex) + { + return $"

Message

{ex.Message}

" + + $"
Source
{ex.Source}

" + + $"
StackTrace
{ex.StackTrace?.Replace("\n", "
\n")}

" + + $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; + } + /// /// Processes an http context asynchronously. - /// If the request is a websocket upgrade to a configured endpoint, handle + /// If the request is a websocket upgrade to a configured endpoint, handle /// websocket lifecycle instead of request/response. /// Handles missing sitemap endpoints directly here. /// /// The http context that the operation processes. /// Provides an asynchronous operation that handles the http context. - public async Task ProcessRequestAsync(IHttpContext httpContext) + private async Task ProcessRequestCoreAsync(IHttpContext httpContext) { var sender = new ResponseSender(); var stopwatch = Stopwatch.StartNew(); @@ -690,10 +745,7 @@ async Task SendAsync(IHttpContext context, IResponse response) if (httpContext is HttpExceptionContext exceptionContext) { var message = "404" + - $"

Message

{exceptionContext.Exception.Message}

" + - $"
Source
{exceptionContext.Exception.Source}

" + - $"
StackTrace
{exceptionContext.Exception.StackTrace.Replace("\n", "
\n")}

" + - $"
InnerException
{exceptionContext.Exception.InnerException?.ToString().Replace("\n", "
\n")}" + + Describe(exceptionContext.Exception) + ""; var response500 = CreateStatusPage(message, httpContext?.Request); diff --git a/src/WebExpress.WebCore/WebMessage/HttpContext.cs b/src/WebExpress.WebCore/WebMessage/HttpContext.cs index 1385f170..f6026872 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpContext.cs @@ -67,7 +67,7 @@ public HttpContext(IFeatureCollection contextFeatures, IHttpServerContext httpSe var connectionFeature = contextFeatures.Get(); var requestFeature = contextFeatures.Get(); var header = new RequestHeaderFields(contextFeatures); - var baseUri = new UriBuilder(requestFeature.Scheme, header.Host, connectionFeature.LocalPort).Uri; + var baseUri = new UriBuilder(requestFeature.Scheme, HostNameOf(header.Host), connectionFeature.LocalPort).Uri; Features = contextFeatures; Id = connectionFeature.ConnectionId; @@ -81,5 +81,32 @@ public HttpContext(IFeatureCollection contextFeatures, IHttpServerContext httpSe Request = new Request(contextFeatures, header, httpServerContext); } + + /// + /// Returns the host name of a host header, without the port it may carry. + /// + /// + /// A client addressing a non-default port sends it in the host header + /// ("localhost:8080"), while the port of the connection is known separately. Handing + /// the header over unchanged makes append a second port and + /// the resulting "localhost:8080:8080" cannot be parsed - every request on such a port + /// then fails before its context exists. An ipv6 literal is bracketed ("[::1]:8080"), + /// so the separator only counts when it follows the closing bracket. + /// + /// The value of the host header. May be null or empty. + /// The bare host name. + internal static string HostNameOf(string host) + { + if (string.IsNullOrEmpty(host)) + { + return host; + } + + var separator = host.LastIndexOf(':'); + + return separator > host.LastIndexOf(']') + ? host[..separator] + : host; + } } } From f3f7b06a21c728828216e917bb77397422f5d320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Tue, 8 Sep 2026 20:56:56 +0200 Subject: [PATCH 33/69] feat: add like control, general improvements and minor bugs --- .../Html/UnitTestHtmlAttribute.cs | 111 ++++++++++++++++++ .../WebHtml/HtmlAttribute.cs | 41 ++++++- .../WebHtml/HtmlElementFieldInput.cs | 2 +- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs diff --git a/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs new file mode 100644 index 00000000..82b8c281 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Html/UnitTestHtmlAttribute.cs @@ -0,0 +1,111 @@ +using WebExpress.WebCore.WebHtml; + +namespace WebExpress.WebCore.Test.Html +{ + /// + /// Unit tests for the escaping an attribute value goes through on its way into the markup. + /// + /// + /// The writer used to emit the value verbatim, so a value carrying a double quote ended its + /// attribute early and the rest of it landed in the markup as stray attributes. Controls + /// worked around it by encoding before handing the value over; the escaping belongs to the + /// writer, and these tests pin it there. + /// + [Collection("NonParallelTests")] + public class UnitTestHtmlAttribute + { + /// + /// Tests that a double quote cannot end the attribute early. This is the case json in a + /// data attribute runs into, which is how a control hands structured state to its client. + /// + [Fact] + public void EscapesTheDoubleQuote() + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("data-payload", @"{""object"":""SD-1""}"); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that an ampersand cannot turn the text following it into an entity - a query + /// string of ?a=1&copy=2 would otherwise read a copyright sign. + /// + [Fact] + public void EscapesTheAmpersand() + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("href", "/search?a=1©=2"); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that nothing else is escaped: the apostrophe, the angle brackets and a + /// non-ascii character are all legal inside a double-quoted value on a utf-8 document, + /// and escaping them would only make the markup harder to read. + /// + /// The attribute value under test. + [Theory] + [InlineData("Guybrush's quest")] + [InlineData("a < b > c")] + [InlineData("Grüße aus Mêlée")] + public void LeavesEverythingElseAlone(string value) + { + // arrange + var html = new HtmlElementTextContentDiv() + .AddUserAttribute("title", value); + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal($@"
", res); + } + + /// + /// Tests that the standard attributes are escaped as well - they travel through the same + /// writer, so a class is no more verbatim than a data attribute. + /// + [Fact] + public void EscapesTheStandardAttributes() + { + // arrange + var html = new HtmlElementTextContentDiv() { Class = @"a ""b"" c" }; + + // act + var res = html.ToString().Trim(); + + // validation + Assert.Equal(@"
", res); + } + + /// + /// Tests that an empty value stays an empty attribute rather than disappearing into an + /// exception. + /// + [Fact] + public void EmptyValueIsWrittenAsAnEmptyAttribute() + { + // arrange + var builder = new System.Text.StringBuilder(); + var attribute = new HtmlAttribute("data-empty", string.Empty); + + // act + attribute.ToString(builder, 0); + + // validation + Assert.Equal(@"data-empty=""""", builder.ToString()); + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/HtmlAttribute.cs b/src/WebExpress.WebCore/WebHtml/HtmlAttribute.cs index 26473cfa..4c4299fd 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlAttribute.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlAttribute.cs @@ -48,14 +48,53 @@ public HtmlAttribute(string name, string value) /// /// Convert to a string using a string builder. /// + /// + /// The value is escaped here rather than by whoever supplied it. A value carrying a + /// double quote would otherwise end the attribute early and the rest of it would land in + /// the markup as stray attributes - which is not a theoretical risk, because json in a + /// data attribute is how a control hands structured state to its client counterpart. + /// Callers therefore pass their value as it is; encoding it before handing it over now + /// produces &amp;quot;. + /// /// The string builder. /// The call depth. public virtual void ToString(StringBuilder builder, int deep) { builder.Append(Name); builder.Append("=\""); - builder.Append(Value); + Escape(builder, Value); builder.Append('"'); } + + /// + /// Appends a value, escaping what a double-quoted attribute cannot carry. + /// + /// + /// Only the ampersand and the double quote are escaped, and deliberately not more. The + /// quote is what would end the attribute; the ampersand is what would otherwise turn the + /// text following it into an entity - ?a=1&copy=2 reads as a copyright sign. + /// <, > and the apostrophe are legal inside a double-quoted value, + /// and a non-ascii character is simply itself on a utf-8 document; escaping either would + /// only make the markup harder to read for no gain. + /// + /// The string builder. + /// The value to append, may be null. + private static void Escape(StringBuilder builder, string value) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + + foreach (var c in value) + { + switch (c) + { + case '&': builder.Append("&"); break; + case '"': builder.Append("""); break; + default: builder.Append(c); break; + } + } + } } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementFieldInput.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementFieldInput.cs index 461ef4a8..6d99fa2d 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementFieldInput.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementFieldInput.cs @@ -32,7 +32,7 @@ public string Type public string Value { get => GetAttribute("value"); - set => SetAttribute("value", value?.Replace("'", "'")?.Replace("\"", """)); + set => SetAttribute("value", value); } /// From 239cf29dc3e6b7694fef10ba3a0a89c543499641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Fri, 11 Sep 2026 05:47:34 +0200 Subject: [PATCH 34/69] feat: editor and graph improvements and minor bugs --- src/WebExpress.WebCore/HttpServer.cs | 19 +++++++- src/WebExpress.WebCore/WebEx.cs | 71 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index ebcff2d9..c1c73ede 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -349,6 +349,12 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) }; request.Uri = resourceUri; + // the request is made ambient for as long as it is being answered, so a layer that + // is several calls away from the endpoint - a manager, a store - can still ask what + // it is being asked on behalf of. It is closed with the response, so nothing reads + // it afterwards + using var current = WebEx.BeginRequest(request); + try { // execute resource @@ -375,7 +381,18 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) request.Session is not null ) { - var cookie = new Cookie("session", request.Session.Id.ToString()) { Expires = DateTime.MaxValue }; + // the path is named rather than left to the browser, which would default + // it to the directory of the request the cookie was handed out on: a + // visitor would then collect one session per directory they touch, the + // sign-in would bind the identity to whichever of them the login request + // happened to carry, and every page under a different directory would be + // served to a session that never signed in + var cookie = new Cookie("session", request.Session.Id.ToString()) + { + Expires = DateTime.MaxValue, + Path = "/" + }; + response.Header.Cookies.Add(cookie); } } diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 6b6447de..e661d3c7 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -10,6 +10,7 @@ using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPackage; [assembly: InternalsVisibleTo("WebExpress.WebCore.Test")] @@ -65,6 +66,76 @@ public sealed class WebEx /// public static IComponentHub ComponentHub => _componentHub; + /// + /// Gets the request currently being served on this call chain, or + /// outside a request. + /// + /// + /// The request is handed to endpoints, pages, controls and fragments, and passing it on + /// from there is the right way to reach it - a method that needs the request should say + /// so in its signature. This exists for the layers where that is not possible: a + /// manager, a component or a store several calls deep that has to answer a question + /// about the caller - who is signed in, which language they read, where they are + /// connecting from - and whose signature is shared with callers that have no request at + /// all. Threading a request through every one of them would mean changing every + /// implementation of an interface for the sake of one of them. + /// + /// It is an async local set for the duration of one request, so a call chain sees the + /// request it belongs to and two requests served at once never see each other's. It is + /// null outside a request - during startup, on a background worker, in a test - and + /// callers have to answer that case rather than assume a request. + /// + /// + public static IRequest CurrentRequest => _currentRequest.Value; + + /// + /// The backing store of . + /// + private static readonly AsyncLocal _currentRequest = new(); + + /// + /// Makes the supplied request the current one until the returned scope is closed. + /// + /// + /// Called by the server around the handling of one request. It is internal because the + /// span of a request is the server's to decide: a host that could open the scope itself + /// could also leave it open, and every layer reading would + /// then be told about a request that had long been answered. + /// + /// The request being served. + /// The scope. Closing it restores what was current before. + internal static IDisposable BeginRequest(IRequest request) + { + var previous = _currentRequest.Value; + + _currentRequest.Value = request; + + return new RequestScope(previous); + } + + /// + /// The scope handed out by . + /// + /// The request that was current when the scope was opened. + private sealed class RequestScope(IRequest previous) : IDisposable + { + private bool _closed; + + /// + /// Restores the request of the enclosing scope. + /// + public void Dispose() + { + if (_closed) + { + return; + } + + _closed = true; + _currentRequest.Value = previous; + } + } + /// /// Gets or sets the path to the favicon image used by the application. /// From 784e3fc3340445f472966c79a26fff7bfb36cc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Sat, 12 Sep 2026 07:40:34 +0200 Subject: [PATCH 35/69] fix: session security, lifecycle handling and login protection --- .../Manager/UnitTestIdentityManager.cs | 86 +++++ .../Manager/UnitTestSessionManager.cs | 295 ++++++++++++++++++ .../Server/UnitTestHttpServer.cs | 204 +++++++++++- .../Config/HttpServerConfig.cs | 7 + .../Config/SessionConfig.cs | 32 ++ src/WebExpress.WebCore/HttpServer.cs | 99 ++++-- src/WebExpress.WebCore/WebEx.cs | 7 + .../WebIdentity/IIdentityManager.cs | 9 + .../WebIdentity/IdentityManager.cs | 18 ++ .../WebSession/ISessionManager.cs | 28 +- .../WebSession/Model/Session.cs | 7 +- .../WebSession/SessionManager.cs | 134 ++++++-- 12 files changed, 880 insertions(+), 46 deletions(-) create mode 100644 src/WebExpress.WebCore/Config/SessionConfig.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index b798ab1d..e13ccb38 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -206,6 +206,92 @@ public void GetCurrentIdentity(string identityName) Assert.Equal(identity, res); } + /// + /// Signing in must move the session to an id the client did not hold before: the + /// request keeps its session and identity, the new id resolves to the signed-in + /// session, and the id in use before the sign-in resolves to nothing. + /// + [Fact] + public void Login_ReplacesSessionId() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var identityManager = componentHub.IdentityManager as IdentityManager; + var request = UnitTestFixture.CreateRequestMock(); + var identity = MockIdentityFactory.GetIdentity("Alice"); + var idBefore = request.Session.Id; + + // act + var session = identityManager.Login(identity, request); + + // validation + Assert.NotEqual(idBefore, session.Id); + Assert.Same(request.Session, session); + Assert.Equal(identity, identityManager.GetCurrentIdentity(request)); + + var withOldId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={idBefore}\n\n"); + Assert.Null(identityManager.GetCurrentIdentity(withOldId)); + + var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={session.Id}\n\n"); + Assert.Equal(identity, identityManager.GetCurrentIdentity(withNewId)); + } + + /// + /// The fixation scenario end to end: an attacker plants a session id in the victim's + /// browser, the victim signs in with it, and the attacker's requests carrying that id + /// must still see nobody signed in. + /// + [Fact] + public void Login_PlantedSessionId_DoesNotReachTheAttacker() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var identityManager = componentHub.IdentityManager as IdentityManager; + var planted = Guid.NewGuid(); + var victim = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); + var identity = MockIdentityFactory.GetIdentity("Alice"); + + // act + var session = identityManager.Login(identity, victim); + + // validation + Assert.NotNull(session); + Assert.NotEqual(planted, session.Id); + + var attacker = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); + Assert.Null(identityManager.GetCurrentIdentity(attacker)); + } + + /// + /// Signing out retires the id the session was signed in under: the request keeps its + /// (now anonymous) session, while a copy of the signed-in id resolves to nothing. + /// + [Fact] + public void Logout_ReplacesSessionId() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var identityManager = componentHub.IdentityManager as IdentityManager; + var request = UnitTestFixture.CreateRequestMock(); + var identity = MockIdentityFactory.GetIdentity("Alice"); + var session = identityManager.Login(identity, request); + var signedInId = session.Id; + + // act + identityManager.Logout(request); + + // validation + Assert.NotEqual(signedInId, session.Id); + Assert.Same(request.Session, session); + Assert.Null(identityManager.GetCurrentIdentity(request)); + + var withSignedInId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={signedInId}\n\n"); + Assert.NotSame(session, componentHub.SessionManager.GetSession(withSignedInId)); + + var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={session.Id}\n\n"); + Assert.Same(session, componentHub.SessionManager.GetSession(withNewId)); + } + /// /// Test that the IIdentityGroup interface has the Id and Name properties. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs index afd01d4d..5ec81df9 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSessionManager.cs @@ -94,5 +94,300 @@ public void RemovePropertyFromSession() Assert.Null(testProperty); } + + /// + /// A well-formed id the server never issued must not become a session id: a client + /// that could dictate the id would plant it in a victim's browser and read the + /// victim's session once they signed in. + /// + [Fact] + public void GetSession_UnknownCookieId_IsNotAdopted() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var planted = Guid.NewGuid(); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(session); + Assert.NotEqual(planted, session.Id); + Assert.NotEqual(Guid.Empty, session.Id); + } + + /// + /// A request without a session cookie gets a fresh session. + /// + [Fact] + public void GetSession_NoCookie_CreatesSession() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\nCookie:\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(session); + Assert.NotEqual(Guid.Empty, session.Id); + } + + /// + /// A cookie naming an id the server issued resolves to that session. + /// + [Fact] + public void GetSession_KnownCookieId_ReturnsThatSession() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var issued = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={issued.Id}\n\n"); + + // act + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(issued, session); + } + + /// + /// The id of a session created for a request only reaches the client with the response, + /// so every lookup made while answering that request - the request itself, the sign-in, + /// the cookie - must find the same session even though no cookie can name it yet. + /// Otherwise the identity would be bound to one session and the client sent the id of + /// another. + /// + /// The cookie header the request carries. + [Theory] + [InlineData("")] + [InlineData("session=")] + [InlineData("session=not-a-guid")] + [InlineData("session=6F9619FF-8B86-D011-B42D-00C04FC964FF")] + public void GetSession_SameRequest_ReturnsSameSession(string cookie) + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: {cookie}\n\n"); + + // act + var first = componentHub.SessionManager.GetSession(request); + var second = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotNull(first); + Assert.Same(first, second); + Assert.Same(request.Session, first); + } + + /// + /// Regenerating the id keeps the session's state, makes the new id resolve to it and + /// retires the old one - whoever holds the old id holds nothing. + /// + [Fact] + public void RegenerateId_ReplacesIdKeepsStateAndRetiresOldId() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var session = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var oldId = session.Id; + session.SetProperty(new SessionPropertyParameter(new Parameter("test", "test param", ParameterScope.Session))); + + // act + var newId = componentHub.SessionManager.RegenerateId(session); + + // validation + Assert.NotEqual(oldId, newId); + Assert.Equal(newId, session.Id); + Assert.NotNull(session.GetProperty()); + + var withOldId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={oldId}\n\n"); + Assert.NotSame(session, componentHub.SessionManager.GetSession(withOldId)); + + var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={newId}\n\n"); + Assert.Same(session, componentHub.SessionManager.GetSession(withNewId)); + } + + /// + /// Regenerating the id of a session the manager has never seen still registers it + /// under the new id, so a caller need not know where the session came from. + /// + [Fact] + public void RegenerateId_UnknownSession_RegistersIt() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var session = new Session(); + + // act + var newId = componentHub.SessionManager.RegenerateId(session); + + // validation + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={newId}\n\n"); + Assert.Same(session, componentHub.SessionManager.GetSession(request)); + } + + /// + /// Regenerating without a session is a programming error and must not pass silently. + /// + [Fact] + public void RegenerateId_Null_Throws() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // act & validation + Assert.Throws(() => componentHub.SessionManager.RegenerateId(null)); + } + + /// + /// A session left idle past the timeout must not revive on the next request even before + /// the periodic cleanup runs: its id is dropped and a fresh session takes its place, so a + /// stale cookie never carries access back. + /// + [Fact] + public void GetSession_ExpiredSession_IsReplaced() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var staleId = seeded.Id; + + // push the last-access point beyond the timeout + seeded.Updated = DateTime.Now.AddHours(-1); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.NotEqual(staleId, session.Id); + + // the stale id resolves to yet another fresh session, never back to the expired one + var again = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + Assert.NotEqual(staleId, componentHub.SessionManager.GetSession(again).Id); + } + + /// + /// An active session renews its idle deadline on each access (sliding window), so a user + /// who keeps using the site is never expired out from under an ongoing session. + /// + [Fact] + public void GetSession_ActiveSession_RenewsAndPersists() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + seeded.Updated = DateTime.Now.AddMinutes(-10); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={seeded.Id}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(seeded, session); + Assert.True((DateTime.Now - session.Updated).TotalMinutes < 1, "the access renewed the idle deadline"); + } + + /// + /// A non-positive timeout means expiry is switched off, so even a long-idle session is + /// returned unchanged. + /// + [Fact] + public void GetSession_TimeoutDisabled_NeverExpires() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.Zero; + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + seeded.Updated = DateTime.Now.AddDays(-3650); + + // act + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={seeded.Id}\n\n"); + var session = componentHub.SessionManager.GetSession(request); + + // validation + Assert.Same(seeded, session); + } + + /// + /// The default idle lifetime is finite and modest - neither the year-long window the + /// cleanup used to assume nor the never-expiring cookie that went with it. + /// + [Fact] + public void Timeout_DefaultIsBoundedAndModest() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + // validation + Assert.Equal(WebExpress.WebCore.WebSession.SessionManager.DefaultTimeout, componentHub.SessionManager.Timeout); + Assert.True(componentHub.SessionManager.Timeout > TimeSpan.Zero); + Assert.True(componentHub.SessionManager.Timeout <= TimeSpan.FromDays(31)); + } + + /// + /// With no explicit timeout, cleanup reclaims exactly what the access check would already + /// refuse - it sweeps by the manager's own + /// rather than by the old year-long default. + /// + [Fact] + public void CleanUp_WithoutExplicitTimeout_UsesTheManagerTimeout() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(30); + var seeded = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var staleId = seeded.Id; + seeded.Updated = DateTime.Now.AddHours(-1); + var applicationContext = new WebExpress.WebCore.WebApplication.ApplicationContext(); + + // act + componentHub.SessionManager.CleanUp(applicationContext); + + // validation - the swept id now resolves to a brand new session + var request = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={staleId}\n\n"); + Assert.NotEqual(staleId, componentHub.SessionManager.GetSession(request).Id); + } + + /// + /// The session directory is shared mutable state: many requests read and create sessions + /// at once. Under a single lock the lookups and creations stay consistent - a known id + /// keeps resolving to its one instance, and no concurrent access corrupts the directory. + /// + [Fact] + public void GetSession_UnderConcurrency_StaysConsistent() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.Zero; // isolate concurrency from expiry + var known = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var knownCookie = $"GET / HTTP/1.1\nCookie: session={known.Id}\n\n"; + + // act - hammer the manager: half the threads re-read the one known session while the + // other half force fresh creations, so lookups and inserts race on the directory. The + // count is high so the many inserts trigger dictionary resizes while reads are in + // flight - the exact window an unsynchronised read would tear on + System.Threading.Tasks.Parallel.For(0, 50000, i => + { + if (i % 2 == 0) + { + var resolved = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock(knownCookie)); + Assert.Same(known, resolved); + } + else + { + var fresh = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\nCookie:\n\n")); + Assert.NotNull(fresh); + } + }); + + // validation - the known session survived the storm intact + Assert.Same(known, componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock(knownCookie))); + } } } diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index f2c9edc3..01789eb0 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -1,4 +1,8 @@ -using WebExpress.WebCore.Test.Fixture; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Primitives; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; namespace WebExpress.WebCore.Test.Server { @@ -8,6 +12,41 @@ namespace WebExpress.WebCore.Test.Server [Collection("NonParallelTests")] public class UnitTestHttpServer { + // a policy-free endpoint, so the request is answered by its handler and nothing + // in between - the access check, the login prompt - decides the outcome + private const string Endpoint = "/server/appa/api/2/testrestapib"; + + /// + /// Answers a request through the server and returns what the client would see of it. + /// + /// + /// The fixture's context carries no response features, so the sender would log and + /// give up before writing a header; they are added here so the Set-Cookie header the + /// server emits can be read back. + /// + /// The hub the server answers on behalf of. + /// The raw request. + /// + /// What a handler would do with the request - a sign-in, say - before the response leaves. + /// + /// Optional server configuration, e.g. to force the cookie's Secure flag. + /// The response head and the request as the server materialised it. + private static async Task<(HttpResponseFeature Response, WebMessage.IRequest Request)> AnswerAsync(WebComponent.ComponentHub componentHub, string content, Action whileAnswering = null, WebExpress.WebCore.Config.HttpServerConfig config = null) + { + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); + var httpContext = UnitTestFixture.CreateHttpContextMock(content); + var responseFeature = new HttpResponseFeature(); + httpContext.Features.Set(responseFeature); + httpContext.Features.Set(new StreamResponseBodyFeature(new MemoryStream())); + var server = new HttpServer(httpServerContext) { Config = config }; + componentHub.SitemapManager.Refresh(); + + whileAnswering?.Invoke(httpContext.Request); + + await server.ProcessRequestAsync(httpContext); + + return (responseFeature, httpContext.Request); + } /// /// Asynchronously processes an HTTP request using the specified HTTP context. /// @@ -33,5 +72,168 @@ public async Task ProcessRequestAsync() // validation Assert.NotNull(server); } + + /// + /// A first visit is answered with the id of the session the server created for it - + /// site-wide, and out of reach of script, since the id is all that authenticates the + /// client. + /// + [Fact] + public async Task ProcessRequestAsync_NoSessionCookie_IssuesHttpOnlySessionCookie() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; + + // act + var (response, request) = await AnswerAsync(componentHub, content); + + // validation + var setCookie = response.Headers.SetCookie.ToString(); + Assert.Contains($"session={request.Session.Id}", setCookie); + Assert.Contains("Path=/", setCookie); + Assert.Contains("HttpOnly", setCookie); + + // the lifetime is bounded, not the year-9999 "never expires" it used to carry + Assert.Contains("Expires=", setCookie); + Assert.DoesNotContain("9999", setCookie); + + // over plain http the cookie is not marked secure, or the browser would never send it back + Assert.DoesNotContain("Secure", setCookie); + } + + /// + /// Behind a tls-terminating proxy the server sees plain http, so it cannot infer https + /// from the request; the configuration forces the Secure flag on so the cookie is still + /// marked https-only towards the browser. + /// + [Fact] + public async Task ProcessRequestAsync_ConfigForcesSecure_MarksCookieSecure() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; + var config = new WebExpress.WebCore.Config.HttpServerConfig { Session = new WebExpress.WebCore.Config.SessionConfig { Secure = true } }; + + // act + var (response, _) = await AnswerAsync(componentHub, content, config: config); + + // validation + Assert.Contains("Secure", response.Headers.SetCookie.ToString()); + } + + /// + /// With expiry switched off the cookie must still be bounded - it becomes a session + /// cookie with no Expires (dies when the browser closes) rather than one that never expires. + /// + [Fact] + public async Task ProcessRequestAsync_TimeoutDisabled_IssuesSessionCookie() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SessionManager.Timeout = TimeSpan.Zero; + var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; + + // act + var (response, request) = await AnswerAsync(componentHub, content); + + // validation + var setCookie = response.Headers.SetCookie.ToString(); + Assert.Contains($"session={request.Session.Id}", setCookie); + Assert.DoesNotContain("Expires=", setCookie); + } + + /// + /// A cookie carrying an id the server never issued is answered with a fresh id, not + /// with the one the client proposed - otherwise the client would choose the id of + /// the session it is about to sign in to. + /// + [Fact] + public async Task ProcessRequestAsync_UnknownSessionCookie_IssuesServerSideId() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var planted = Guid.NewGuid(); + var content = $"GET {Endpoint} HTTP/1.1\nCookie: session={planted}\n\n"; + + // act + var (response, request) = await AnswerAsync(componentHub, content); + + // validation + var setCookie = response.Headers.SetCookie.ToString(); + Assert.NotEqual(planted, request.Session.Id); + Assert.Contains($"session={request.Session.Id}", setCookie); + Assert.DoesNotContain(planted.ToString(), setCookie); + } + + /// + /// A cookie naming a session the server issued is left alone: the client already + /// holds the right id, and re-sending it on every response would be noise. + /// + [Fact] + public async Task ProcessRequestAsync_KnownSessionCookie_SetsNoCookie() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var issued = componentHub.SessionManager.GetSession(UnitTestFixture.CreateRequestMock()); + var content = $"GET {Endpoint} HTTP/1.1\nCookie: session={issued.Id}\n\n"; + + // act + var (response, request) = await AnswerAsync(componentHub, content); + + // validation + Assert.Same(issued, request.Session); + Assert.True(StringValues.IsNullOrEmpty(response.Headers.SetCookie)); + } + + /// + /// A client without a cookie signs in and gets back the id of the very session the + /// identity was bound to. The request creates its session before any handler runs and + /// the sign-in and the cookie must both refer to that one - were each to mint its own, + /// the client would come back with an id that never signed in. + /// + [Fact] + public async Task ProcessRequestAsync_SignInWithoutCookie_CookieNamesTheSignedInSession() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var identity = MockIdentityFactory.GetIdentity("Alice"); + var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; + WebSession.Model.Session signedIn = null; + + // act + var (response, request) = await AnswerAsync(componentHub, content, r => + { + signedIn = componentHub.IdentityManager.Login(identity, r); + }); + + // validation + Assert.NotNull(signedIn); + Assert.Same(signedIn, request.Session); + Assert.Contains($"session={signedIn.Id}", response.Headers.SetCookie.ToString()); + + var next = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={signedIn.Id}\n\n"); + Assert.Equal(identity, componentHub.IdentityManager.GetCurrentIdentity(next)); + } + + /// + /// A request that never reaches a handler - here an unknown route - still hands the + /// client its session id, since the login prompt and the redirect after a sign-in + /// take the same path and must carry the (new) id. + /// + [Fact] + public async Task ProcessRequestAsync_UnknownRoute_StillIssuesSessionCookie() + { + // arrange + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var content = $"GET /server/appa/no/such/route HTTP/1.1\nCookie: session={Guid.NewGuid()}\n\n"; + + // act + var (response, request) = await AnswerAsync(componentHub, content); + + // validation + Assert.Equal(404, response.StatusCode); + Assert.Contains($"session={request.Session.Id}", response.Headers.SetCookie.ToString()); + } } } diff --git a/src/WebExpress.WebCore/Config/HttpServerConfig.cs b/src/WebExpress.WebCore/Config/HttpServerConfig.cs index eba509d2..708b6160 100644 --- a/src/WebExpress.WebCore/Config/HttpServerConfig.cs +++ b/src/WebExpress.WebCore/Config/HttpServerConfig.cs @@ -36,6 +36,13 @@ public sealed class HttpServerConfig [XmlElement("kestrel")] public KestrelConfig Kestrel { get; set; } + /// + /// Optional configuration of the session and its cookie. When the element is omitted the + /// built-in defaults apply, so this block only ever changes values explicitly opted into. + /// + [XmlElement("session")] + public SessionConfig Session { get; set; } + /// /// Root directory of packages. /// diff --git a/src/WebExpress.WebCore/Config/SessionConfig.cs b/src/WebExpress.WebCore/Config/SessionConfig.cs new file mode 100644 index 00000000..fa87064b --- /dev/null +++ b/src/WebExpress.WebCore/Config/SessionConfig.cs @@ -0,0 +1,32 @@ +using System.Xml.Serialization; + +namespace WebExpress.WebCore.Config +{ + /// + /// Optional configuration of the session and its cookie. The whole <session> element and + /// every property in it is optional: a value left unset keeps the built-in default, so adding + /// the block never changes behavior a deployment did not opt into. + /// + [XmlRoot("session", IsNullable = false)] + public sealed class SessionConfig + { + /// + /// The idle lifetime of a session in minutes, applied as a sliding window. It bounds both + /// the server-side session and the cookie's lifetime. Left unset, the built-in default + /// applies; a non-positive value disables expiry, which also turns the cookie into a + /// session cookie that dies when the browser closes rather than one that never expires. + /// + [XmlElement("timeout")] + public int? TimeoutMinutes { get; set; } + + /// + /// Forces the Secure flag on the session cookie on or off. Left unset, the flag + /// tracks the request scheme - set for an https request, absent for plain http - which is + /// the right default behind most deployments. Set it to true when the server runs + /// behind a TLS-terminating proxy and therefore sees plain http itself, so the cookie is + /// still marked https-only towards the browser. + /// + [XmlElement("secure")] + public bool? Secure { get; set; } + } +} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index c1c73ede..652a1141 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -373,28 +373,6 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) searchResult ); } - - if - ( - !response.Header.Cookies.Any(x => x.Name.Equals("session")) && - !request.Header.Cookies.Any(x => x.Name.Equals("session")) && - request.Session is not null - ) - { - // the path is named rather than left to the browser, which would default - // it to the directory of the request the cookie was handed out on: a - // visitor would then collect one session per directory they touch, the - // sign-in would bind the identity to whichever of them the login request - // happened to carry, and every page under a different directory would be - // served to a session that never signed in - var cookie = new Cookie("session", request.Session.Id.ToString()) - { - Expires = DateTime.MaxValue, - Path = "/" - }; - - response.Header.Cookies.Add(cookie); - } } else { @@ -468,6 +446,78 @@ request.Session is not null return response; } + /// + /// Hands the client the id of its session whenever the cookie it sent does not name it. + /// + /// + /// That is the case on a first visit, for a stale or forged cookie the session manager + /// refused to adopt, and after a sign-in or sign-out replaced the id. It is applied to + /// every response - a redirect or a status page included - because a sign-in that ends + /// in a redirect would otherwise leave the browser with an id that no longer resolves, + /// and the user signed out again. + /// + /// The cookie carries the security attributes that match what the id is worth. It is + /// http-only, because the id is the whole of what authenticates the client and script on + /// the page - injected or not - must not be able to read it. It is secure whenever the + /// request arrived over https (or the configuration forces it, for a server behind a + /// tls-terminating proxy), so the id is never sent back in the clear. Its lifetime is + /// bounded by the session timeout rather than set to never expire, so a copy of the + /// cookie stops working once the session it names has lapsed. The path is named rather + /// than left to the browser, which would default it to the directory of the request the + /// cookie was handed out on: a visitor would then collect one session per directory they + /// touch, the sign-in would bind the identity to whichever of them the login request + /// happened to carry, and every page under a different directory would be served to a + /// session that never signed in. + /// + /// The request that was answered. + /// The response about to be sent. + private void IssueSessionCookie(IRequest request, IResponse response) + { + var session = request?.Session; + var cookies = response?.Header?.Cookies; + + if (session is null || cookies is null) + { + return; + } + + // a handler that set the cookie itself knows better + if (cookies.Any(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase))) + { + return; + } + + var sent = request.Header?.Cookies? + .FirstOrDefault(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase)); + + if (Guid.TryParse(sent?.Value, out var sentId) && sentId == session.Id) + { + return; + } + + // secure tracks the request scheme by default; a deployment behind a tls proxy that + // sees plain http can force it on through configuration + var secure = Config?.Session?.Secure ?? request.Scheme == UriScheme.Https; + + var cookie = new Cookie("session", session.Id.ToString()) + { + Path = "/", + HttpOnly = true, + Secure = secure + }; + + // a positive timeout bounds the cookie to the session's idle window; with expiry + // disabled the cookie has no Expires at all and so dies when the browser closes, + // which is still bounded, unlike a cookie that never expires + var timeout = WebEx.ComponentHub?.SessionManager?.Timeout ?? TimeSpan.Zero; + if (timeout > TimeSpan.Zero) + { + cookie.Expires = DateTime.Now + timeout; + } + + cookies.Add(cookie); + } + /// /// Updates the request statistics with ring buffer logic (max 24h). /// @@ -751,9 +801,12 @@ private async Task ProcessRequestCoreAsync(IHttpContext httpContext) // here rather than inside the handler: a request that never reaches a handler - // an unknown route, a denied or an unauthenticated one - produces a status code // the monitor has to account for just the same. Recording precedes the send, so - // a slow client does not end up counted as a slow server. + // a slow client does not end up counted as a slow server. The session cookie is + // issued here for the same reason: the login prompt and a redirect after sign-in + // never reach the handler either, yet must carry the session id. async Task SendAsync(IHttpContext context, IResponse response) { + IssueSessionCookie(context?.Request, response); UpdateStatistics(response, stopwatch.ElapsedMilliseconds); await sender.SendAsync(context, response); diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index e661d3c7..958ccbd9 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -303,6 +303,13 @@ private void OnInitialization(string args, string configFile) _componentHub = ComponentActivator.CreateInstance(_httpServer.HttpServerContext); + // apply the configured session lifetime once the manager exists; left unset, its + // built-in bounded default stands + if (config.Session?.TimeoutMinutes is int timeoutMinutes && _componentHub.SessionManager is not null) + { + _componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(timeoutMinutes); + } + // start logging _httpServer.HttpServerContext.Log?.Begin(config.Log); diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index e40ec71f..77536f24 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -67,6 +67,11 @@ public interface IIdentityManager : IComponentManager /// /// Login an identity. /// + /// + /// The session keeps its state but gets a new id, so an id the client held before the + /// sign-in never names the signed-in session (session fixation). The client learns the + /// new id from the cookie sent with the response; a caller must not hand it out itself. + /// /// The identity. /// The request. /// The session of the logged-in identity, or null if the login process failed. @@ -75,6 +80,10 @@ public interface IIdentityManager : IComponentManager /// /// Logout an identity. /// + /// + /// The mirror image of the sign-in: the identity is dropped and the session gets a new + /// id, so an id captured while it was signed in resolves to nothing afterwards. + /// /// The request. void Logout(IRequest request); diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index bac5cec0..f6493012 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -438,6 +438,11 @@ public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiato /// /// Login an identity. /// + /// + /// The session keeps its state but gets a new id, so the id the client signed in under - + /// one an attacker may have planted in the browser or observed on the wire - names + /// nothing once the identity is bound (session fixation). + /// /// The identity. /// The request. /// The session of the logged-in identity, or null if the login process failed. @@ -449,6 +454,11 @@ public Session Login(IIdentity identity, IRequest request) } var session = _componentHub?.SessionManager.GetSession(request); + + // the id is replaced before the identity is bound, so the identity never lives + // under an id the client chose + _componentHub?.SessionManager.RegenerateId(session); + var authentification = session.GetOrCreateProperty(identity); // verify that the identity was correctly bound to the session @@ -463,11 +473,19 @@ public Session Login(IIdentity identity, IRequest request) /// /// Logout an identity. /// + /// + /// The session keeps its state but gets a new id, the mirror image of the sign-in: a + /// copy of the id taken while the session was signed in - from a log, a leaked header, + /// a shared machine - resolves to nothing afterwards, not even to the anonymous + /// remainder of the session. + /// /// The request. public void Logout(IRequest request) { var session = _componentHub?.SessionManager.GetSession(request); session.RemoveProperty(); + + _componentHub?.SessionManager.RegenerateId(session); } /// diff --git a/src/WebExpress.WebCore/WebSession/ISessionManager.cs b/src/WebExpress.WebCore/WebSession/ISessionManager.cs index 4e947d6c..da40b5df 100644 --- a/src/WebExpress.WebCore/WebSession/ISessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/ISessionManager.cs @@ -1,4 +1,5 @@ -using WebExpress.WebCore.WebApplication; +using System; +using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebSession.Model; @@ -11,12 +12,35 @@ namespace WebExpress.WebCore.WebSession public interface ISessionManager : IComponentManager { /// - /// Creates a session or returns an existing session based on the provided request. + /// Gets or sets how long a session may stay idle before it expires. Applied as a sliding + /// window and used both to expire sessions on access and to bound the session cookie's + /// lifetime. A non-positive value disables expiry. /// + TimeSpan Timeout { get; set; } + + /// + /// Returns the session a request belongs to, creating one when it has none yet. + /// + /// + /// The id in the session cookie is only ever a lookup key; an id the server has not + /// issued is never adopted, so a client cannot fix the id of a session in advance. + /// /// The request. /// The session. Session GetSession(IRequest request); + /// + /// Replaces the id of a session while keeping its state. + /// + /// + /// Meant for the moment a session gains privilege - at sign-in - so that an id the client + /// held before no longer names the authenticated session. The client learns the new id + /// from the cookie sent with the response. + /// + /// The session whose id is to be replaced. + /// The new session id. + Guid RegenerateId(Session session); + /// /// Cleans up expired sessions from the session manager based on the specified session timeout. /// diff --git a/src/WebExpress.WebCore/WebSession/Model/Session.cs b/src/WebExpress.WebCore/WebSession/Model/Session.cs index 4b2cd730..7168b7c2 100644 --- a/src/WebExpress.WebCore/WebSession/Model/Session.cs +++ b/src/WebExpress.WebCore/WebSession/Model/Session.cs @@ -15,7 +15,12 @@ public class Session /// /// Gets the session id. /// - public Guid Id { get; private set; } + /// + /// The id is the only thing the client holds, so it is what an attacker would plant or + /// steal. The session manager therefore replaces it when the session changes privilege + /// - at sign-in - which is why the id must not be cached across such a change. + /// + public Guid Id { get; internal set; } /// /// Gets the creation time. diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index e4b96773..58551817 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -15,9 +15,30 @@ namespace WebExpress.WebCore.WebSession /// public class SessionManager : ISessionManager, ISystemComponent { + /// + /// The lifetime a session may sit idle before it is treated as gone, when nothing in the + /// configuration overrides it. Bounded on purpose: an unbounded session (the previous + /// year-long default, and a cookie that never expired) keeps a stolen id valid for as + /// long as the attacker cares to hold it. + /// + public static readonly TimeSpan DefaultTimeout = TimeSpan.FromDays(30); + private readonly IHttpServerContext _httpServerContext; private readonly SessionDictionary _dictionary = []; + // guards every read of and write to _dictionary. A plain Dictionary is not safe for a + // read concurrent with a write even on different keys, and RegenerateId's remove-then-add + // must be atomic against a lookup, so a single lock - not a ConcurrentDictionary - is what + // keeps the invariants + private readonly object _sync = new(); + + /// + /// Gets or sets how long a session may stay idle before it expires. Applied as a sliding + /// window - each access renews it - and used both to expire sessions on access and to + /// bound the lifetime of the session cookie. A non-positive value disables expiry. + /// + public TimeSpan Timeout { get; set; } = DefaultTimeout; + /// /// Initializes a new instance of the class. /// @@ -34,35 +55,100 @@ private SessionManager(IHttpServerContext context) } /// - /// Creates a session or returns an existing session based on the provided request. + /// Returns the session a request belongs to, creating one when it has none yet. /// + /// + /// The id in the session cookie is only ever a lookup key. An id the server has not + /// issued - however well-formed - is never adopted: a client that could dictate its + /// own id could plant that id in a victim's browser and read the victim's session once + /// they signed in (session fixation). Every id is therefore generated here. + /// + /// An expired session is treated as absent - dropped and replaced with a fresh one - + /// so a stale cookie never revives access, whether or not the monthly cleanup has run + /// yet. Lookup, expiry and creation happen under one lock: a plain dictionary cannot be + /// read and written concurrently, and splitting the check from the create would let two + /// parallel requests for the same id each create a session. + /// /// The request. /// The session. public Session GetSession(IRequest request) { - // determine session + // the session resolved for the request comes first: a session created for this + // request has an id the client only learns with the response, so the cookie + // cannot name it yet and a lookup by cookie would create a second one + if (request?.Session is not null) + { + return request.Session; + } + var sessionCookie = request?.Header .Cookies?.FirstOrDefault(x => x.Name.Equals("session", StringComparison.OrdinalIgnoreCase)); - // reuse the client-provided session id when it is a valid guid; otherwise allocate a new one - var guid = Guid.TryParse(sessionCookie?.Value, out var parsed) ? parsed : Guid.NewGuid(); + var hasId = Guid.TryParse(sessionCookie?.Value, out var id); + var now = DateTime.Now; - if (sessionCookie is not null && _dictionary.TryGetValue(guid, out Session value)) + lock (_sync) { - value.Updated = DateTime.Now; + if (hasId && _dictionary.TryGetValue(id, out var known)) + { + if (!IsExpired(known, now)) + { + // sliding window: an active session keeps renewing its idle deadline + known.Updated = now; + + return known; + } + + // past its idle window: drop it so the id stops resolving, then fall + // through to hand out a fresh session + _dictionary.Remove(id); + } - return value; + // no, invalid, unknown or expired session id => a fresh, server-generated one + var session = new Session(); + _dictionary[session.Id] = session; + + return session; } + } + + /// + /// Determines whether a session has sat idle past its timeout. + /// + /// The session to test. + /// The reference point for the idle span. + /// True when the session has expired; a non-positive timeout never expires. + private bool IsExpired(Session session, DateTime now) + { + return Timeout > TimeSpan.Zero && now - session.Updated > Timeout; + } - // no or invalid session => assign new session - var session = new Session(guid); + /// + /// Replaces the id of a session while keeping its state. + /// + /// + /// Called when a session gains privilege - at sign-in - so that an id the client held + /// before, which an attacker may have planted or observed, no longer names the + /// authenticated session. The old id stops resolving at once; the client learns the new + /// one from the cookie sent with the response. + /// + /// The session whose id is to be replaced. + /// The new session id. + public Guid RegenerateId(Session session) + { + ArgumentNullException.ThrowIfNull(session); + + var id = Guid.NewGuid(); - lock (_dictionary) + lock (_sync) { - _dictionary[guid] = session; + _dictionary.Remove(session.Id); + session.Id = id; + _dictionary[id] = session; + session.Updated = DateTime.Now; } - return session; + return id; } /// @@ -74,19 +160,29 @@ public Session GetSession(IRequest request) /// timeout duration. /// /// - /// The explicit session timeout in minutes; if non-positive, the configured - /// timeout is used. If the effective timeout is non-positive, cleanup is skipped. + /// The explicit session timeout in minutes; if non-positive, the configured + /// is used. If the effective timeout is non-positive, cleanup is + /// skipped. /// /// /// The current instance of the session manager, allowing for method chaining. /// - public ISessionManager CleanUp(IApplicationContext applicationContext, int timeoutMinutes = 60 * 24 * 365) + /// + /// Expiry is already enforced on access, so this only reclaims the memory held by + /// sessions no one has come back for. It shares with that access + /// check by default, so the two never disagree on what "expired" means - the periodic + /// sweep removes exactly what a lookup would already refuse. + /// + public ISessionManager CleanUp(IApplicationContext applicationContext, int timeoutMinutes = 0) { // validate input ArgumentNullException.ThrowIfNull(applicationContext); - // read timeout; non-positive values disable cleanup - if (timeoutMinutes <= 0) + // an explicit positive value wins; otherwise fall back to the configured sliding window + var effectiveMinutes = timeoutMinutes > 0 ? timeoutMinutes : Timeout.TotalMinutes; + + // a non-positive effective timeout means "sessions never expire" => nothing to sweep + if (effectiveMinutes <= 0) { return this; } @@ -98,10 +194,10 @@ public ISessionManager CleanUp(IApplicationContext applicationContext, int timeo // enumeration would mutate _dictionary.Values while iterating it (InvalidOperationException) // and the subsequent logging loop would re-evaluate to an empty result. List expiredIds; - lock (_dictionary) + lock (_sync) { expiredIds = _dictionary.Values - .Where(s => (now - s.Updated).TotalMinutes > timeoutMinutes) + .Where(s => (now - s.Updated).TotalMinutes > effectiveMinutes) .Select(s => s.Id) .ToList(); From 4067adb38fe925aa15e316d9c80bacc1f02003fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 17 Sep 2026 01:07:13 +0200 Subject: [PATCH 36/69] feat: general improvements and minor bugs --- .../UnitTestTimeSpanConverter.cs | 71 +++++++++++++++++++ .../WebSettingPage/Model/TimeSpanConverter.cs | 65 +++++++++++++++-- 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/WebSettingPage/UnitTestTimeSpanConverter.cs diff --git a/src/WebExpress.WebCore.Test/WebSettingPage/UnitTestTimeSpanConverter.cs b/src/WebExpress.WebCore.Test/WebSettingPage/UnitTestTimeSpanConverter.cs new file mode 100644 index 00000000..951133ef --- /dev/null +++ b/src/WebExpress.WebCore.Test/WebSettingPage/UnitTestTimeSpanConverter.cs @@ -0,0 +1,71 @@ +using System; +using System.Globalization; +using WebExpress.WebCore.WebSettingPage.Model; + +namespace WebExpress.WebCore.Test.WebSettingPage +{ + /// + /// Tests the converter that formats a time span for the setting pages and reads the + /// formatted string back. + /// + [Collection("NonParallelTests")] + public class UnitTestTimeSpanConverter + { + /// + /// A span survives the round trip through the formatted string. + /// + [Theory] + [InlineData("00:00:00")] + [InlineData("00:00:01.250")] + [InlineData("1.02:03:04.005")] + [InlineData("12:34:56")] + [InlineData("-1.02:03:04.005")] + public void ConvertBackReadsWhatConvertWrote(string literal) + { + // arrange + var converter = new TimeSpanConverter(); + var expected = TimeSpan.Parse(literal, CultureInfo.InvariantCulture); + + // act + var formatted = converter.Convert(expected, typeof(string), null, null); + var actual = converter.ConvertBack(formatted, typeof(TimeSpan), null, null); + + // validation + Assert.Equal(expected, actual); + } + + /// + /// A shortened form names only the units it needs, and a plain literal is accepted as + /// it is. + /// + [Theory] + [InlineData("5m", 0, 0, 5, 0, 0)] + [InlineData("2h 30m", 0, 2, 30, 0, 0)] + [InlineData("250ms", 0, 0, 0, 0, 250)] + [InlineData("1.02:03:04", 1, 2, 3, 4, 0)] + public void ConvertBackAcceptsShortenedAndLiteralForms(string value, int days, int hours, int minutes, int seconds, int milliseconds) + { + // act + var actual = new TimeSpanConverter().ConvertBack(value, typeof(TimeSpan), null, null); + + // validation + Assert.Equal(new TimeSpan(days, hours, minutes, seconds, milliseconds), actual); + } + + /// + /// Nothing is read into a null, and a string that is neither form is rejected rather + /// than silently turned into zero. + /// + [Fact] + public void ConvertBackRejectsWhatItCannotRead() + { + // arrange + var converter = new TimeSpanConverter(); + + // validation + Assert.Null(converter.ConvertBack(null, typeof(TimeSpan), null, null)); + Assert.Throws(() => converter.ConvertBack("", typeof(TimeSpan), null, null)); + Assert.Throws(() => converter.ConvertBack("soon", typeof(TimeSpan), null, null)); + } + } +} diff --git a/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs b/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs index 4f845608..1933862a 100644 --- a/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs +++ b/src/WebExpress.WebCore/WebSettingPage/Model/TimeSpanConverter.cs @@ -1,4 +1,7 @@ using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; namespace WebExpress.WebCore.WebSettingPage.Model { @@ -6,8 +9,16 @@ namespace WebExpress.WebCore.WebSettingPage.Model /// /// Converts a TimeSpan object to a formatted string and vice versa. /// - public class TimeSpanConverter + public partial class TimeSpanConverter { + /// + /// Matches the string produces. Every unit is optional so a + /// caller may hand back a shortened form, and each carries its own sign because a + /// negative span is formatted with the sign on every component. + /// + [GeneratedRegex(@"^\s*(?:(?-?\d+)d)?\s*(?:(?-?\d+)h)?\s*(?:(?-?\d+)m(?!s))?\s*(?:(?-?\d+)s)?\s*(?:(?-?\d+)ms)?\s*$", RegexOptions.CultureInvariant)] + private static partial Regex FormattedPattern(); + /// /// Converts a TimeSpan object to a formatted string. /// @@ -38,15 +49,61 @@ public object Convert(object value, Type targetType, object parameter, string la /// /// Converts a formatted string back to a TimeSpan object. /// + /// + /// The string produces is read back component by component; + /// a plain time span literal is accepted as well, so a value that was never formatted + /// - one stored as 1.02:03:04 - round-trips through the same converter. + /// /// The formatted string to convert. /// The type to convert to. /// Optional parameter for conversion. /// The language to use in the converter. - /// The TimeSpan object. - /// Thrown when the method is not implemented. + /// The TimeSpan object, or null for a null value. + /// Thrown when the string is neither the formatted + /// form nor a time span literal. public object ConvertBack(object value, Type targetType, object parameter, string language) { - throw new NotImplementedException(); + if (value is null) + { + return null; + } + + if (value is TimeSpan span) + { + return span; + } + + var text = value.ToString(); + var match = FormattedPattern().Match(text); + + // the pattern is all-optional, so the empty string matches it too and has to be + // handed on to the literal parser, which rejects it with the right message + if (match.Success && match.Groups.Values.Skip(1).Any(x => x.Success)) + { + return new TimeSpan + ( + Component(match, "d"), + Component(match, "h"), + Component(match, "m"), + Component(match, "s"), + Component(match, "ms") + ); + } + + return TimeSpan.Parse(text, CultureInfo.InvariantCulture); + } + + /// + /// Reads one unit of the formatted form. + /// + /// The match of the formatted pattern. + /// The unit's group name. + /// The unit's value, or zero when the unit is not present. + private static int Component(Match match, string group) + { + var value = match.Groups[group]; + + return value.Success ? int.Parse(value.Value, CultureInfo.InvariantCulture) : 0; } } } From c35e9760ffd81347339a917c043cb3607afc4d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 17 Sep 2026 20:17:43 +0200 Subject: [PATCH 37/69] feat: replace xml config with .NET configuration model and json settings directory --- .../Config/UnitTestKestrelConfig.cs | 219 --------------- .../Fixture/UnitTestFixture.cs | 11 +- .../Manager/UnitTestPackageManager.cs | 156 ++++++++++- .../Server/UnitTestHttpServer.cs | 11 +- .../WebLog/UnitTestLog.cs | 18 +- .../WebSetting/UnitTestKestrelSettings.cs | 221 +++++++++++++++ .../WebSetting/UnitTestSettingsDirectory.cs | 257 ++++++++++++++++++ .../Config/EndpointConfig.cs | 36 --- .../Config/HttpServerConfig.cs | 89 ------ src/WebExpress.WebCore/Config/PluginConfig.cs | 30 -- src/WebExpress.WebCore/HttpServer.cs | 19 +- src/WebExpress.WebCore/HttpServerContext.cs | 32 ++- src/WebExpress.WebCore/IHttpServerContext.cs | 21 +- .../Internationalization/de | 8 +- .../Internationalization/en | 8 +- .../Setting/ISettingItem.cs | 9 - .../Setting/SettingLogItem.cs | 54 ---- src/WebExpress.WebCore/WebEx.cs | 138 +++++----- src/WebExpress.WebCore/WebLog/ILog.cs | 4 +- src/WebExpress.WebCore/WebLog/Log.cs | 12 +- .../WebPackage/Model/PackageItemSpec.cs | 9 + .../WebPackage/PackageBuilder.cs | 31 +++ .../WebPackage/PackageManager.cs | 102 +++++++ .../WebPlugin/IPluginContext.cs | 10 +- .../WebPlugin/PluginContext.cs | 6 + .../WebPlugin/PluginManager.cs | 7 +- .../WebSetting/EndpointSettings.cs | 35 +++ .../WebSetting/HttpServerSettings.cs | 81 ++++++ .../KestrelSettings.cs} | 46 +--- .../WebSetting/LogSettings.cs | 44 +++ .../SessionSettings.cs} | 15 +- ...ettingsDirectoryConfigurationExtensions.cs | 36 +++ .../SettingsDirectoryConfigurationProvider.cs | 129 +++++++++ .../SettingsDirectoryConfigurationSource.cs | 59 ++++ .../WebSetting/SettingsLoader.cs | 76 ++++++ 35 files changed, 1438 insertions(+), 601 deletions(-) delete mode 100644 src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs create mode 100644 src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs create mode 100644 src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsDirectory.cs delete mode 100644 src/WebExpress.WebCore/Config/EndpointConfig.cs delete mode 100644 src/WebExpress.WebCore/Config/HttpServerConfig.cs delete mode 100644 src/WebExpress.WebCore/Config/PluginConfig.cs delete mode 100644 src/WebExpress.WebCore/Setting/ISettingItem.cs delete mode 100644 src/WebExpress.WebCore/Setting/SettingLogItem.cs create mode 100644 src/WebExpress.WebCore/WebSetting/EndpointSettings.cs create mode 100644 src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs rename src/WebExpress.WebCore/{Config/KestrelConfig.cs => WebSetting/KestrelSettings.cs} (74%) create mode 100644 src/WebExpress.WebCore/WebSetting/LogSettings.cs rename src/WebExpress.WebCore/{Config/SessionConfig.cs => WebSetting/SessionSettings.cs} (68%) create mode 100644 src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationExtensions.cs create mode 100644 src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationProvider.cs create mode 100644 src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationSource.cs create mode 100644 src/WebExpress.WebCore/WebSetting/SettingsLoader.cs diff --git a/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs b/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs deleted file mode 100644 index 12e03fdd..00000000 --- a/src/WebExpress.WebCore.Test/Config/UnitTestKestrelConfig.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System.IO; -using System.Xml.Serialization; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using WebExpress.WebCore.Config; - -namespace WebExpress.WebCore.Test.Config -{ - /// - /// Unit tests for the optional Kestrel configuration block and its deserialization, - /// mirroring how the server loads its configuration via XmlSerializer. - /// - public class UnitTestKestrelConfig - { - /// - /// Deserializes the given configuration document into an HttpServerConfig instance. - /// - /// The configuration document. - /// The deserialized configuration. - private static HttpServerConfig Deserialize(string xml) - { - var serializer = new XmlSerializer(typeof(HttpServerConfig)); - using var reader = new StringReader(xml); - - return serializer.Deserialize(reader) as HttpServerConfig; - } - - /// - /// Tests that a configuration without a kestrel block leaves the property null so the - /// server keeps its built-in defaults. - /// - [Fact] - public void MissingBlockIsNull() - { - // arrange - var xml = ""; - - // act - var config = Deserialize(xml); - - // validation - Assert.Null(config.Kestrel); - } - - /// - /// Tests that all kestrel settings are read when present. - /// - [Fact] - public void FullBlockIsDeserialized() - { - // arrange - var xml = - "" + - " " + - " " + - " 300" + - " 3000000000" + - " 65536" + - " false" + - " false" + - " false" + - " 1000" + - " 2097152" + - " 131072" + - " 16384" + - " 90" + - " 15" + - " " + - ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.NotNull(kestrel); - Assert.Equal(300, kestrel.MaxConcurrentConnections); - Assert.Equal(3000000000, kestrel.MaxRequestBodySize); - Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); - Assert.False(kestrel.AllowSynchronousIO); - Assert.False(kestrel.AllowResponseHeaderCompression); - Assert.False(kestrel.AddServerHeader); - Assert.Equal(1000, kestrel.MaxConcurrentUpgradedConnections); - Assert.Equal(2097152, kestrel.MaxRequestBufferSize); - Assert.Equal(131072, kestrel.MaxResponseBufferSize); - Assert.Equal(16384, kestrel.MaxRequestLineSize); - Assert.Equal(90, kestrel.KeepAliveTimeout); - Assert.Equal(15, kestrel.RequestHeadersTimeout); - } - - /// - /// Tests that individual settings are independently optional: elements that are not present - /// remain null, so only explicitly configured values override the defaults. - /// - [Fact] - public void OmittedElementsRemainNull() - { - // arrange - var xml = - "" + - " " + - " " + - " false" + - " " + - ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.NotNull(kestrel); - Assert.False(kestrel.AddServerHeader); - Assert.Null(kestrel.AllowSynchronousIO); - Assert.Null(kestrel.AllowResponseHeaderCompression); - Assert.Null(kestrel.MaxConcurrentConnections); - Assert.Null(kestrel.MaxRequestBodySize); - Assert.Null(kestrel.MaxRequestHeadersTotalSize); - Assert.Null(kestrel.MaxConcurrentUpgradedConnections); - Assert.Null(kestrel.MaxRequestBufferSize); - Assert.Null(kestrel.MaxResponseBufferSize); - Assert.Null(kestrel.MaxRequestLineSize); - Assert.Null(kestrel.KeepAliveTimeout); - Assert.Null(kestrel.RequestHeadersTimeout); - } - - /// - /// Tests that the request limits, which were previously configured through a separate limit - /// block, are read from the consolidated kestrel block. - /// - [Fact] - public void RequestLimitsAreDeserialized() - { - // arrange - var xml = - "" + - " " + - " " + - " 300" + - " 3000000000" + - " 65536" + - " " + - ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.NotNull(kestrel); - Assert.Equal(300, kestrel.MaxConcurrentConnections); - Assert.Equal(3000000000, kestrel.MaxRequestBodySize); - Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); - } - - /// - /// Tests that the protocols element is read and resolved to the matching Kestrel value, - /// including case-insensitive parsing. - /// - [Theory] - [InlineData("Http1", HttpProtocols.Http1)] - [InlineData("Http2", HttpProtocols.Http2)] - [InlineData("Http1AndHttp2", HttpProtocols.Http1AndHttp2)] - [InlineData("http2", HttpProtocols.Http2)] - public void ProtocolsAreResolved(string value, HttpProtocols expected) - { - // arrange - var xml = - "" + - " " + - $" {value}" + - ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.Equal(value, kestrel.Protocols); - Assert.Equal(expected, kestrel.ResolveProtocols()); - } - - /// - /// Tests that a missing protocols element resolves to null so the Kestrel default is kept. - /// - [Fact] - public void ProtocolsDefaultToNull() - { - // arrange - var xml = ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.Null(kestrel.Protocols); - Assert.Null(kestrel.ResolveProtocols()); - } - - /// - /// Tests that an unrecognised protocols value resolves to null instead of applying an - /// unintended restriction, so a typo cannot silently disable HTTP/2. - /// - [Theory] - [InlineData("Http9")] - [InlineData("999")] - [InlineData("nonsense")] - public void UnknownProtocolsResolveToNull(string value) - { - // arrange - var xml = - "" + - " " + - $" {value}" + - ""; - - // act - var kestrel = Deserialize(xml).Kestrel; - - // validation - Assert.Null(kestrel.ResolveProtocols()); - } - } -} diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 5e42c772..88ce7000 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Configuration; using System.Globalization; using System.Net; using System.Reflection; @@ -32,8 +33,13 @@ public UnitTestFixture() /// /// Create a fake server context. /// + /// + /// The settings directory. Defaults to a directory of its own, so a test that deploys a + /// settings file never sees one left behind by another. + /// + /// The configuration. Defaults to an empty one. /// The server context. - public static IHttpServerContext CreateHttpServerContextMock() + public static IHttpServerContext CreateHttpServerContextMock(string settingsPath = null, IConfigurationRoot configuration = null) { return new HttpServerContext ( @@ -42,7 +48,8 @@ public static IHttpServerContext CreateHttpServerContextMock() Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), Environment.CurrentDirectory, Environment.CurrentDirectory, - Environment.CurrentDirectory, + settingsPath ?? Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()), + configuration ?? new ConfigurationBuilder().Build(), CultureInfo.GetCultureInfo("en"), new Log() { LogMode = LogMode.Off }, null diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs index 5209ebbf..8e1ece41 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestPackageManager.cs @@ -1,11 +1,13 @@ using System.IO.Compression; using System.Reflection; using System.Security.Cryptography; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.Test.Fixture; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebPackage; using WebExpress.WebCore.WebPackage.Model; using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.Test.Manager { @@ -598,13 +600,158 @@ public void BuiltInPackageRefusesEveryLifecycleOperation() Assert.Equal(PackageCatalogeItemState.Active, packageManager.GetPackage(pluginId)?.State); } + /// + /// A package ships its settings file under settings/; installing it deploys the file to + /// the settings directory of the server and reloads the configuration, so the plugin's + /// section is there before the plugin boots. + /// + [Fact] + public void InstallDeploysSettingsFileAndReloadsConfiguration() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + var section = configuration.GetPluginSettings("withsettings"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", """{ "Plugins": { "withsettings": { "Greeting": "hello" } } }"""); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.True(File.Exists(Path.Combine(settingsPath, "withsettings.settings.json"))); + Assert.Equal("hello", section["Greeting"]); + } + finally + { + Directory.Delete(packagePath, true); + Directory.Delete(settingsPath, true); + } + } + + /// + /// A settings file the administrator already has is theirs: a package update must not + /// overwrite it with the shipped default. + /// + [Fact] + public void InstallKeepsExistingSettingsFile() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + Directory.CreateDirectory(settingsPath); + File.WriteAllText(Path.Combine(settingsPath, "withsettings.settings.json"), """{ "Plugins": { "withsettings": { "Greeting": "edited" } } }"""); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "withsettings.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "withsettings", "1.0.0", """{ "Plugins": { "withsettings": { "Greeting": "shipped" } } }"""); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.Equal("edited", configuration.GetPluginSettings("withsettings")["Greeting"]); + } + finally + { + Directory.Delete(packagePath, true); + Directory.Delete(settingsPath, true); + } + } + + /// + /// A settings file that does not parse is refused: once in the directory it would fail + /// every following start of the server, so the installation goes on without it. + /// + [Fact] + public void InstallRefusesInvalidSettingsFile() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "broken.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "broken", "1.0.0", "{ this is not json"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.False(Directory.Exists(settingsPath)); + } + finally + { + Directory.Delete(packagePath, true); + } + } + + /// + /// Only a json file directly below settings/ is a settings file; an entry that tries to + /// leave the directory is ignored and never written anywhere. + /// + [Fact] + public void InstallIgnoresNestedSettingsEntry() + { + // arrange + var settingsPath = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + var configuration = new ConfigurationBuilder().AddSettingsDirectory(settingsPath, reloadOnChange: false).Build(); + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(settingsPath, configuration); + var componentHub = UnitTestFixture.CreateComponentHubMock(httpServerContext); + var packageManager = componentHub.PackageManager as PackageManager; + var packagePath = httpServerContext.PackagePath; + var packageFile = Path.Combine(packagePath, "nested.1.0.0.wxp"); + + try + { + Directory.CreateDirectory(packagePath); + CreatePackageArchive(packageFile, "nested", "1.0.0", null, "settings/sub/escape.json"); + + // act + var install = packageManager.InstallPackage(packageFile, true); + + // validation + Assert.True(install.Success); + Assert.False(Directory.Exists(settingsPath)); + } + finally + { + Directory.Delete(packagePath, true); + } + } + /// /// Creates a simple package archive for tests. /// /// The package file path. /// The package id. /// The package version. - private static void CreatePackageArchive(string file, string id, string version) + /// The content of a settings file shipped as settings/{id}.settings.json, or null for none. + /// The entry name of the settings file, if it is to differ from the default. + private static void CreatePackageArchive(string file, string id, string version, string settings = null, string settingsEntry = null) { using var zip = ZipFile.Open(file, ZipArchiveMode.Create); var specEntry = zip.CreateEntry($"{id}.spec"); @@ -621,6 +768,13 @@ private static void CreatePackageArchive(string file, string id, string version) // add minimal lib folder marker to resemble package layout zip.CreateEntry("lib/"); + + if (settings is not null || settingsEntry is not null) + { + var entry = zip.CreateEntry(settingsEntry ?? $"settings/{id}.settings.json"); + using var writer = new StreamWriter(entry.Open()); + writer.Write(settings ?? "{}"); + } } /// diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index 01789eb0..d1a83eda 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Primitives; using WebExpress.WebCore.Test.Data; using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.Test.Server { @@ -29,16 +30,16 @@ public class UnitTestHttpServer /// /// What a handler would do with the request - a sign-in, say - before the response leaves. /// - /// Optional server configuration, e.g. to force the cookie's Secure flag. + /// Optional server settings, e.g. to force the cookie's Secure flag. /// The response head and the request as the server materialised it. - private static async Task<(HttpResponseFeature Response, WebMessage.IRequest Request)> AnswerAsync(WebComponent.ComponentHub componentHub, string content, Action whileAnswering = null, WebExpress.WebCore.Config.HttpServerConfig config = null) + private static async Task<(HttpResponseFeature Response, WebMessage.IRequest Request)> AnswerAsync(WebComponent.ComponentHub componentHub, string content, Action whileAnswering = null, HttpServerSettings settings = null) { var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(); var httpContext = UnitTestFixture.CreateHttpContextMock(content); var responseFeature = new HttpResponseFeature(); httpContext.Features.Set(responseFeature); httpContext.Features.Set(new StreamResponseBodyFeature(new MemoryStream())); - var server = new HttpServer(httpServerContext) { Config = config }; + var server = new HttpServer(httpServerContext) { Settings = settings }; componentHub.SitemapManager.Refresh(); whileAnswering?.Invoke(httpContext.Request); @@ -113,10 +114,10 @@ public async Task ProcessRequestAsync_ConfigForcesSecure_MarksCookieSecure() // arrange var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; - var config = new WebExpress.WebCore.Config.HttpServerConfig { Session = new WebExpress.WebCore.Config.SessionConfig { Secure = true } }; + var settings = new HttpServerSettings { Session = new SessionSettings { Secure = true } }; // act - var (response, _) = await AnswerAsync(componentHub, content, config: config); + var (response, _) = await AnswerAsync(componentHub, content, settings: settings); // validation Assert.Contains("Secure", response.Headers.SetCookie.ToString()); diff --git a/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs b/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs index 202a6b04..2879d199 100644 --- a/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs +++ b/src/WebExpress.WebCore.Test/WebLog/UnitTestLog.cs @@ -1,4 +1,4 @@ -using WebExpress.WebCore.Setting; +using WebExpress.WebCore.WebSetting; using WebExpress.WebCore.WebLog; namespace WebExpress.WebCore.Test.WebLog @@ -214,14 +214,14 @@ public void BeginWithInvalidModeFallsBack() try { var log = new Log(); - var settings = new SettingLogItem + var settings = new LogSettings { - Modus = "not-a-mode", + Mode = "not-a-mode", Debug = false, Path = dir, Encoding = "utf-8", - Filename = "settings.log", - Timepattern = "HH:mm:ss" + FileName = "settings.log", + TimePattern = "HH:mm:ss" }; // act @@ -249,14 +249,14 @@ public void BeginParsesModeCaseInsensitive() try { var log = new Log(); - var settings = new SettingLogItem + var settings = new LogSettings { - Modus = "override", + Mode = "override", Debug = false, Path = dir, Encoding = "utf-8", - Filename = "settings.log", - Timepattern = "HH:mm:ss" + FileName = "settings.log", + TimePattern = "HH:mm:ss" }; // act diff --git a/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs b/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs new file mode 100644 index 00000000..8ba5a6a3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs @@ -0,0 +1,221 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.WebSetting +{ + /// + /// Unit tests for the optional Kestrel settings block and its binding, mirroring how the + /// server binds its settings from the merged configuration. + /// + public class UnitTestKestrelSettings + { + /// + /// Binds the given settings document into an HttpServerSettings instance. + /// + /// The settings document. + /// The bound settings. + private static HttpServerSettings Bind(string json) + { + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)); + + return new ConfigurationBuilder() + .AddJsonStream(stream) + .Build() + .GetServerSettings(); + } + + /// + /// Tests that a settings document without a kestrel block leaves the property null so the + /// server keeps its built-in defaults. + /// + [Fact] + public void MissingBlockIsNull() + { + // arrange + var json = """{ "WebExpress": { "Endpoints": [ { "Uri": "http://localhost/" } ] } }"""; + + // act + var settings = Bind(json); + + // validation + Assert.Null(settings.Kestrel); + Assert.Single(settings.Endpoints); + Assert.Equal("http://localhost/", settings.Endpoints[0].Uri); + } + + /// + /// Tests that all kestrel settings are read when present. + /// + [Fact] + public void FullBlockIsBound() + { + // arrange + var json = """ + { + "WebExpress": { + "Endpoints": [ { "Uri": "http://localhost/" } ], + "Kestrel": { + "MaxConcurrentConnections": 300, + "MaxRequestBodySize": 3000000000, + "MaxRequestHeadersTotalSize": 65536, + "AllowSynchronousIO": false, + "AllowResponseHeaderCompression": false, + "AddServerHeader": false, + "MaxConcurrentUpgradedConnections": 1000, + "MaxRequestBufferSize": 2097152, + "MaxResponseBufferSize": 131072, + "MaxRequestLineSize": 16384, + "KeepAliveTimeout": 90, + "RequestHeadersTimeout": 15 + } + } + } + """; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.NotNull(kestrel); + Assert.Equal(300, kestrel.MaxConcurrentConnections); + Assert.Equal(3000000000, kestrel.MaxRequestBodySize); + Assert.Equal(65536, kestrel.MaxRequestHeadersTotalSize); + Assert.False(kestrel.AllowSynchronousIO); + Assert.False(kestrel.AllowResponseHeaderCompression); + Assert.False(kestrel.AddServerHeader); + Assert.Equal(1000, kestrel.MaxConcurrentUpgradedConnections); + Assert.Equal(2097152, kestrel.MaxRequestBufferSize); + Assert.Equal(131072, kestrel.MaxResponseBufferSize); + Assert.Equal(16384, kestrel.MaxRequestLineSize); + Assert.Equal(90, kestrel.KeepAliveTimeout); + Assert.Equal(15, kestrel.RequestHeadersTimeout); + } + + /// + /// Tests that individual settings are independently optional: keys that are not present + /// remain null, so only explicitly configured values override the defaults. + /// + [Fact] + public void OmittedKeysRemainNull() + { + // arrange + var json = """{ "WebExpress": { "Kestrel": { "AddServerHeader": false } } }"""; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.NotNull(kestrel); + Assert.False(kestrel.AddServerHeader); + Assert.Null(kestrel.AllowSynchronousIO); + Assert.Null(kestrel.AllowResponseHeaderCompression); + Assert.Null(kestrel.MaxConcurrentConnections); + Assert.Null(kestrel.MaxRequestBodySize); + Assert.Null(kestrel.MaxRequestHeadersTotalSize); + Assert.Null(kestrel.MaxConcurrentUpgradedConnections); + Assert.Null(kestrel.MaxRequestBufferSize); + Assert.Null(kestrel.MaxResponseBufferSize); + Assert.Null(kestrel.MaxRequestLineSize); + Assert.Null(kestrel.KeepAliveTimeout); + Assert.Null(kestrel.RequestHeadersTimeout); + } + + /// + /// Tests that keys are matched regardless of their casing, so a hand-written file does + /// not have to follow the property names to the letter. + /// + [Fact] + public void KeysAreCaseInsensitive() + { + // arrange + var json = """{ "webexpress": { "kestrel": { "maxconcurrentconnections": 300 } } }"""; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.Equal(300, kestrel?.MaxConcurrentConnections); + } + + /// + /// Tests that the protocols key is read and resolved to the matching Kestrel value, + /// including case-insensitive parsing. + /// + [Theory] + [InlineData("Http1", HttpProtocols.Http1)] + [InlineData("Http2", HttpProtocols.Http2)] + [InlineData("Http1AndHttp2", HttpProtocols.Http1AndHttp2)] + [InlineData("http2", HttpProtocols.Http2)] + public void ProtocolsAreResolved(string value, HttpProtocols expected) + { + // arrange + var json = $$"""{ "WebExpress": { "Kestrel": { "Protocols": "{{value}}" } } }"""; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.Equal(value, kestrel.Protocols); + Assert.Equal(expected, kestrel.ResolveProtocols()); + } + + /// + /// Tests that a missing protocols key resolves to null so the Kestrel default is kept. + /// + [Fact] + public void ProtocolsDefaultToNull() + { + // arrange + var json = """{ "WebExpress": { "Kestrel": { "AddServerHeader": true } } }"""; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.Null(kestrel.Protocols); + Assert.Null(kestrel.ResolveProtocols()); + } + + /// + /// Tests that an unrecognised protocols value resolves to null instead of applying an + /// unintended restriction, so a typo cannot silently disable HTTP/2. + /// + [Theory] + [InlineData("Http9")] + [InlineData("999")] + [InlineData("nonsense")] + public void UnknownProtocolsResolveToNull(string value) + { + // arrange + var json = $$"""{ "WebExpress": { "Kestrel": { "Protocols": "{{value}}" } } }"""; + + // act + var kestrel = Bind(json).Kestrel; + + // validation + Assert.Null(kestrel.ResolveProtocols()); + } + + /// + /// Tests that a document without a server section still yields usable settings, every + /// value at its default, rather than nothing to start from. + /// + [Fact] + public void MissingServerSectionYieldsDefaults() + { + // arrange + var json = """{ "Plugins": { "some.plugin": { "Key": "value" } } }"""; + + // act + var settings = Bind(json); + + // validation + Assert.NotNull(settings); + Assert.Empty(settings.Endpoints); + Assert.Null(settings.Kestrel); + Assert.Null(settings.Session); + Assert.Equal("Off", settings.Log.Mode); + } + } +} diff --git a/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsDirectory.cs b/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsDirectory.cs new file mode 100644 index 00000000..b92763c4 --- /dev/null +++ b/src/WebExpress.WebCore.Test/WebSetting/UnitTestSettingsDirectory.cs @@ -0,0 +1,257 @@ +using Microsoft.Extensions.Configuration; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.WebSetting +{ + /// + /// Unit tests for the settings directory: how the files of the directory are merged into one + /// configuration, which file wins, and how a plugin sees its own section. + /// + public class UnitTestSettingsDirectory : IDisposable + { + private readonly string _directory = Path.Combine(Environment.CurrentDirectory, Guid.NewGuid().ToString()); + + /// + /// Initializes a new instance of the class with an empty settings directory. + /// + public UnitTestSettingsDirectory() + { + Directory.CreateDirectory(_directory); + } + + /// + /// Removes the settings directory. + /// + public void Dispose() + { + Directory.Delete(_directory, true); + GC.SuppressFinalize(this); + } + + /// + /// Writes a file into the settings directory. + /// + /// The file name. + /// The content. + private void Write(string name, string json) + { + File.WriteAllText(Path.Combine(_directory, name), json); + } + + /// + /// Builds the configuration of the directory without a file watcher, so a test never + /// races the watcher's delayed reload. + /// + /// The configuration. + private IConfigurationRoot Build() + { + return new ConfigurationBuilder() + .AddSettingsDirectory(_directory, reloadOnChange: false) + .Build(); + } + + /// + /// Tests that the values of every file of the directory end up in one configuration, + /// each plugin under its own section. + /// + [Fact] + public void AllFilesAreMerged() + { + // arrange + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + Write("plugin.a.settings.json", """{ "Plugins": { "plugin.a": { "Greeting": "hello" } } }"""); + Write("plugin.b.settings.json", """{ "Plugins": { "plugin.b": { "Greeting": "hallo" } } }"""); + + // act + var configuration = Build(); + + // validation + Assert.Equal("en-US", configuration.GetServerSettings().Culture); + Assert.Equal("hello", configuration.GetPluginSettings("plugin.a")["Greeting"]); + Assert.Equal("hallo", configuration.GetPluginSettings("plugin.b")["Greeting"]); + } + + /// + /// Tests that a plugin only sees its own section, so nothing of the server or of + /// another plugin leaks into it. + /// + [Fact] + public void PluginSectionIsIsolated() + { + // arrange + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + Write("plugin.a.settings.json", """{ "Plugins": { "plugin.a": { "Greeting": "hello" } } }"""); + + // act + var section = Build().GetPluginSettings("plugin.b"); + + // validation + Assert.False(section.Exists()); + Assert.Null(section["Greeting"]); + Assert.Null(section["Culture"]); + } + + /// + /// Tests that the main file is merged last: a value the administrator puts there wins + /// over the default a plugin ships in its own file, whatever the file names sort to. + /// + [Fact] + public void MainFileOverridesPluginFile() + { + // arrange - "a..." sorts before "webexpress..." and "z..." after it + Write("a.settings.json", """{ "Plugins": { "plugin.a": { "Greeting": "from a" } } }"""); + Write("z.settings.json", """{ "Plugins": { "plugin.z": { "Greeting": "from z" } } }"""); + Write("webexpress.settings.json", """{ "Plugins": { "plugin.a": { "Greeting": "from main" }, "plugin.z": { "Greeting": "from main" } } }"""); + + // act + var configuration = Build(); + + // validation + Assert.Equal("from main", configuration.GetPluginSettings("plugin.a")["Greeting"]); + Assert.Equal("from main", configuration.GetPluginSettings("plugin.z")["Greeting"]); + } + + /// + /// Tests that among the other files the later name wins, so precedence can be read off + /// a directory listing. + /// + [Fact] + public void LaterFileNameOverridesEarlier() + { + // arrange + Write("a.json", """{ "Plugins": { "plugin": { "Greeting": "from a" } } }"""); + Write("b.json", """{ "Plugins": { "plugin": { "Greeting": "from b" } } }"""); + + // act + var configuration = Build(); + + // validation + Assert.Equal("from b", configuration.GetPluginSettings("plugin")["Greeting"]); + } + + /// + /// Tests that files which are not json are left alone, so a readme or a backup in the + /// directory does not break the start-up. + /// + [Fact] + public void OtherFilesAreIgnored() + { + // arrange + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + Write("readme.txt", "not json at all"); + Write("webexpress.settings.json.bak", "{ broken"); + + // act + var configuration = Build(); + + // validation + Assert.Equal("en-US", configuration.GetServerSettings().Culture); + } + + /// + /// Tests that a broken file fails the load with the file named, rather than being + /// skipped silently. + /// + [Fact] + public void BrokenFileFailsLoad() + { + // arrange + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + Write("plugin.settings.json", "{ this is not json"); + + // act + var exception = Record.Exception(Build); + + // validation + Assert.NotNull(exception); + Assert.Contains("plugin.settings.json", exception.Message); + } + + /// + /// Tests that a reload picks up a file that did not exist when the configuration was + /// built, which is how a package installed at runtime gets its settings in. + /// + [Fact] + public void ReloadPicksUpNewFile() + { + // arrange + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + var configuration = Build(); + var section = configuration.GetPluginSettings("plugin.new"); + Assert.False(section.Exists()); + + // act + Write("plugin.new.settings.json", """{ "Plugins": { "plugin.new": { "Greeting": "hello" } } }"""); + configuration.Reload(); + + // validation - the section handed out before the reload sees the new value + Assert.Equal("hello", section["Greeting"]); + } + + /// + /// Tests that a missing directory yields an empty configuration instead of an error, so + /// a server without any settings file still binds its defaults. + /// + [Fact] + public void MissingDirectoryIsEmpty() + { + // act + var configuration = new ConfigurationBuilder() + .AddSettingsDirectory(Path.Combine(_directory, "missing"), reloadOnChange: false) + .Build(); + + // validation + Assert.Empty(configuration.AsEnumerable()); + } + + /// + /// Tests that the environment overrides every file, so a container can change a value + /// without touching a file. + /// + [Fact] + public void EnvironmentOverridesFiles() + { + // arrange + var variable = SettingsLoader.EnvironmentVariablePrefix + "WebExpress__Culture"; + Write("webexpress.settings.json", """{ "WebExpress": { "Culture": "en-US" } }"""); + Environment.SetEnvironmentVariable(variable, "de-DE"); + + try + { + // act + var configuration = SettingsLoader.Load(Path.Combine(_directory, "webexpress.settings.json")); + + // validation + Assert.Equal("de-DE", configuration.GetServerSettings().Culture); + } + finally + { + Environment.SetEnvironmentVariable(variable, null); + } + } + + /// + /// Tests that the files are listed in the order they are merged, with the main file + /// last, so the start-up log tells the administrator which file wins. + /// + [Fact] + public void FilesAreListedInMergeOrder() + { + // arrange + Write("webexpress.settings.json", "{}"); + Write("b.json", "{}"); + Write("a.json", "{}"); + var configuration = Build(); + + // act + var files = configuration.Providers + .OfType() + .SelectMany(x => x.EnumerateFiles()) + .Select(x => Path.GetFileName(x)) + .ToList(); + + // validation + Assert.Equal(["a.json", "b.json", "webexpress.settings.json"], files); + } + } +} diff --git a/src/WebExpress.WebCore/Config/EndpointConfig.cs b/src/WebExpress.WebCore/Config/EndpointConfig.cs deleted file mode 100644 index d3421b75..00000000 --- a/src/WebExpress.WebCore/Config/EndpointConfig.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Xml.Serialization; - -namespace WebExpress.WebCore.Config -{ - /// - /// Class for reading certificate properties. - /// - [XmlRoot("endpoint", IsNullable = false)] - public sealed class EndpointConfig - { - /// - /// The uri (e.g. https://localhost:443/). - /// - [XmlAttribute("uri")] - public string Uri { get; set; } - - /// - /// The certificate as a pfx file. - /// - [XmlAttribute("pfx")] - public string PfxFile { get; set; } - - /// - /// The password. - /// - [XmlAttribute("password")] - public string Password { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public EndpointConfig() - { - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/Config/HttpServerConfig.cs b/src/WebExpress.WebCore/Config/HttpServerConfig.cs deleted file mode 100644 index 708b6160..00000000 --- a/src/WebExpress.WebCore/Config/HttpServerConfig.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using System.Xml.Serialization; -using WebExpress.WebCore.Setting; - -namespace WebExpress.WebCore.Config -{ - /// - /// Class for reading the configuration file. - /// - [XmlRoot("config", IsNullable = false)] - public sealed class HttpServerConfig - { - /// - /// The configuration version. - /// - [XmlAttribute("version", DataType = "int")] - public int Version { get; set; } - - /// - /// The endpoints of the web server. - /// - [XmlElement("endpoint")] - public List Endpoints { get; set; } - - /// - /// The route of the web server. - /// - [XmlElement("route")] - public string Route { get; set; } - - /// - /// Optional fine-tuning of the underlying Kestrel server, including all request limits. When - /// the element is omitted the web server keeps its built-in defaults, so this block only ever - /// applies values that are explicitly opted into. - /// - [XmlElement("kestrel")] - public KestrelConfig Kestrel { get; set; } - - /// - /// Optional configuration of the session and its cookie. When the element is omitted the - /// built-in defaults apply, so this block only ever changes values explicitly opted into. - /// - [XmlElement("session")] - public SessionConfig Session { get; set; } - - /// - /// Root directory of packages. - /// - [XmlElement("packages")] - public string PackageBase { get; set; } - - /// - /// Root directory of assets. - /// - [XmlElement("assets")] - public string AssetBase { get; set; } - - /// - /// Root directory of the data. - /// - [XmlElement("data")] - public string DataBase { get; set; } - - /// - /// The uri base path of the web server. - /// - [XmlElement("contextpath")] - public string ContextPath { get; set; } - - /// - /// The culture - /// - [XmlElement("culture")] - public string Culture { get; set; } - - /// - /// The log settings. - /// - [XmlElement("log")] - public SettingLogItem Log { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public HttpServerConfig() - { - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/Config/PluginConfig.cs b/src/WebExpress.WebCore/Config/PluginConfig.cs deleted file mode 100644 index 2ecd89a5..00000000 --- a/src/WebExpress.WebCore/Config/PluginConfig.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Xml.Serialization; - -namespace WebExpress.WebCore.Config -{ - /// - /// Class for reading the configuration file. - /// - [XmlRoot("config", IsNullable = false)] - public sealed class PluginConfig - { - /// - /// The configuration version. - /// - [XmlAttribute("version", DataType = "int")] - public int Version { get; set; } - - /// - /// The uri base path of the plugin. - /// - [XmlElement("contextpath")] - public string ContextPath { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public PluginConfig() - { - } - } -} \ No newline at end of file diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 652a1141..e6b0953b 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -17,7 +17,6 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; -using WebExpress.WebCore.Config; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; @@ -25,6 +24,7 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebParameter; +using WebExpress.WebCore.WebSetting; using WebExpress.WebCore.WebSitemap; using WebExpress.WebCore.WebSocket; using WebExpress.WebCore.WebStatusPage; @@ -55,9 +55,9 @@ public class HttpServer : IHost, IHttpApplication private CancellationTokenSource ServerTokenSource { get; } = new CancellationTokenSource(); /// - /// Gets or sets the configuration. + /// Gets or sets the settings of the server. Left unset, every value keeps its built-in default. /// - public HttpServerConfig Config { get; set; } + public HttpServerSettings Settings { get; set; } /// /// Gets the context. @@ -108,7 +108,8 @@ public HttpServer(IHttpServerContext context) context.PackagePath, context.AssetPath, context.DataPath, - context.ConfigPath, + context.SettingsPath, + context.Configuration, context.Culture, context.Log, this @@ -154,8 +155,8 @@ public void Start() } ); - // the configuration block is optional; a missing block or property keeps the built-in defaults - var kestrel = Config?.Kestrel; + // the kestrel settings block is optional; a missing block or property keeps the built-in defaults + var kestrel = Settings?.Kestrel; var serverOptions = new OptionsWrapper(new KestrelServerOptions() { @@ -206,7 +207,7 @@ public void Start() var protocols = kestrel?.ResolveProtocols(); - foreach (var endpoint in Config.Endpoints) + foreach (var endpoint in Settings?.Endpoints ?? []) { AddEndpoint(serverOptions, endpoint, protocols); } @@ -229,7 +230,7 @@ public void Start() /// The server options. /// The endpoint. /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. - private void AddEndpoint(OptionsWrapper serverOptions, EndpointConfig endPoint, HttpProtocols? protocols) + private void AddEndpoint(OptionsWrapper serverOptions, EndpointSettings endPoint, HttpProtocols? protocols) { try { @@ -497,7 +498,7 @@ private void IssueSessionCookie(IRequest request, IResponse response) // secure tracks the request scheme by default; a deployment behind a tls proxy that // sees plain http can force it on through configuration - var secure = Config?.Session?.Secure ?? request.Scheme == UriScheme.Https; + var secure = Settings?.Session?.Secure ?? request.Scheme == UriScheme.Https; var cookie = new Cookie("session", session.Id.ToString()) { diff --git a/src/WebExpress.WebCore/HttpServerContext.cs b/src/WebExpress.WebCore/HttpServerContext.cs index c2c90159..1e2d07e3 100644 --- a/src/WebExpress.WebCore/HttpServerContext.cs +++ b/src/WebExpress.WebCore/HttpServerContext.cs @@ -1,16 +1,18 @@ using System.Collections.Generic; using System.Globalization; using System.Reflection; -using WebExpress.WebCore.Config; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore { /// /// Default implementation of . It bundles the server-wide - /// information (routing, endpoints, version, directories, culture, log) that is created once - /// at start-up and handed to plugins and components throughout the server's lifetime. + /// information (routing, endpoints, version, directories, configuration, culture, log) that + /// is created once at start-up and handed to plugins and components throughout the server's + /// lifetime. /// public class HttpServerContext : IHttpServerContext { @@ -22,7 +24,7 @@ public class HttpServerContext : IHttpServerContext /// /// Gets the endpoints to which the web server responds. /// - public ICollection Endpoints { get; protected set; } + public ICollection Endpoints { get; protected set; } /// /// Gets the version of the http(s) server. @@ -45,9 +47,14 @@ public class HttpServerContext : IHttpServerContext public string DataPath { get; protected set; } /// - /// Gets the configuration directory. + /// Gets the settings directory. /// - public string ConfigPath { get; protected set; } + public string SettingsPath { get; protected set; } + + /// + /// Gets the merged configuration of the server and all plugins. + /// + public IConfigurationRoot Configuration { get; protected set; } /// /// Gets the culture. @@ -69,21 +76,23 @@ public class HttpServerContext : IHttpServerContext /// /// The uri of the route server. /// The endpoints to which the web server responds. - /// The package home directory.chnis + /// The package home directory. /// The asset home directory. /// The data home directory. - /// The configuration directory. + /// The settings directory. + /// The merged configuration of the server and all plugins. /// The culture. /// The log. /// The host. public HttpServerContext ( IRoute route, - ICollection endpoints, + ICollection endpoints, string packageBaseFolder, string assetBaseFolder, string dataBaseFolder, - string configBaseFolder, + string settingsBaseFolder, + IConfigurationRoot configuration, CultureInfo culture, ILog log, IHost host @@ -97,7 +106,8 @@ IHost host PackagePath = packageBaseFolder; AssetPath = assetBaseFolder; DataPath = dataBaseFolder; - ConfigPath = configBaseFolder; + SettingsPath = settingsBaseFolder; + Configuration = configuration; Culture = culture; Log = log; Host = host; diff --git a/src/WebExpress.WebCore/IHttpServerContext.cs b/src/WebExpress.WebCore/IHttpServerContext.cs index edebbf77..f4627d38 100644 --- a/src/WebExpress.WebCore/IHttpServerContext.cs +++ b/src/WebExpress.WebCore/IHttpServerContext.cs @@ -1,16 +1,17 @@ using System.Collections.Generic; using System.Globalization; -using WebExpress.WebCore.Config; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore { /// /// Provides the server-wide information that plugins and components need to do their work: /// the routing entry point, the configured endpoints, the server version, the well-known - /// directories (packages, assets, data, configuration), the culture, and the central log. - /// A single instance is shared for the lifetime of the running server. + /// directories (packages, assets, data, settings), the culture, the configuration and the + /// central log. A single instance is shared for the lifetime of the running server. /// public interface IHttpServerContext { @@ -22,7 +23,7 @@ public interface IHttpServerContext /// /// Gets the endpoints to which the web server responds. /// - ICollection Endpoints { get; } + ICollection Endpoints { get; } /// /// Gets the version of the http(s) server. @@ -45,9 +46,17 @@ public interface IHttpServerContext string DataPath { get; } /// - /// Gets the configuration directory. + /// Gets the settings directory, the one place every settings file - the server's and the + /// plugins' - is read from. /// - string ConfigPath { get; } + string SettingsPath { get; } + + /// + /// Gets the merged configuration of the server and all plugins. A plugin reads its own + /// section through ; the root is exposed + /// so the framework can reload it after a package deployed a new settings file. + /// + IConfigurationRoot Configuration { get; } /// /// Gets the culture. diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index d6427eda..b3c3f652 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -7,8 +7,8 @@ app.workingdirectory=Arbeitsverzeichnis: {0} app.packagebase=Paketverzeichnis: {0} app.assetbase=Assetverzeichnis: {0} app.database=Datenverzeichnis: {0} -app.configurationdirectory=Konfigurationsverzeichnis: {0} -app.configuration=Konfiguration: {0} +app.settingsdirectory=Einstellungsverzeichnis: {0} +app.settings=Einstellungen: {0} app.logdirectory=Logverzeichnis: {0} app.log=Log: {0} app.uri=Uri: {0} @@ -57,6 +57,10 @@ packagemanager.save=Der Katalog wird gespeichert. packagemanager.packagenotfound=Das Paket '{0}' wurde nicht im Dateisystem gefunden. packagemanager.boot.notfound=Das Plugin '{0}' ist nicht bekannt. packagemanager.package=Package: '{0}' +packagemanager.settings.deployed=Die Einstellungsdatei '{0}' wurde in das Einstellungsverzeichnis übernommen. +packagemanager.settings.existing=Die Einstellungsdatei '{0}' existiert bereits im Einstellungsverzeichnis und wurde beibehalten. +packagemanager.settings.ignored=Der Paketeintrag '{0}' ist keine Einstellungsdatei und wurde ignoriert. +packagemanager.settings.invalid=Die Einstellungsdatei '{0}' des Pakets ist kein gültiges JSON und wurde nicht übernommen. pluginmanager.initialization=Der Pluginmanager wurde initialisiert. pluginmanager.load={0}.dll wird geladen. Version = '{1}' diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index d17c27a5..536d2790 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -7,8 +7,8 @@ app.workingdirectory=Working directory: {0} app.packagebase=Package directory: {0} app.assetbase=Asset directory: {0} app.database=Data directory: {0} -app.configurationdirectory=Configuration directory: {0} -app.configuration=Configuration: {0} +app.settingsdirectory=Settings directory: {0} +app.settings=Settings: {0} app.logdirectory=Log directory: {0} app.log=Log: {0} app.uri=Uri: {0} @@ -57,6 +57,10 @@ packagemanager.save=The catalog is saved. packagemanager.packagenotfound=The package '{0}' was not found in the file system. packagemanager.boot.notfound=The plugin '{0}' is unknown. packagemanager.package=Package: '{0}' +packagemanager.settings.deployed=The settings file '{0}' was deployed to the settings directory. +packagemanager.settings.existing=The settings file '{0}' already exists in the settings directory and was kept. +packagemanager.settings.ignored=The package entry '{0}' is not a settings file and was ignored. +packagemanager.settings.invalid=The settings file '{0}' of the package is not valid json and was not deployed. pluginmanager.initialization=The plugin manager has been initialized. pluginmanager.load={0}.dll is loading. Version = '{1}' diff --git a/src/WebExpress.WebCore/Setting/ISettingItem.cs b/src/WebExpress.WebCore/Setting/ISettingItem.cs deleted file mode 100644 index eb2fdd59..00000000 --- a/src/WebExpress.WebCore/Setting/ISettingItem.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace WebExpress.WebCore.Setting -{ - /// - /// Interface for a settings object. - /// - public interface ISettingItem - { - } -} diff --git a/src/WebExpress.WebCore/Setting/SettingLogItem.cs b/src/WebExpress.WebCore/Setting/SettingLogItem.cs deleted file mode 100644 index 88c66ff7..00000000 --- a/src/WebExpress.WebCore/Setting/SettingLogItem.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Xml.Serialization; - -namespace WebExpress.WebCore.Setting -{ - /// - /// Class for managing log settings. - /// - [XmlType("log")] - public class SettingLogItem : ISettingItem - { - /// - /// The log mode. - /// - [XmlAttribute(AttributeName = "modus")] - public string Modus { get; set; } - - /// - /// Determines whether to display debug output. - /// - [XmlAttribute(AttributeName = "debug")] - public bool Debug { get; set; } = false; - - /// - /// The directory where the log is created. - /// - [XmlAttribute(AttributeName = "path")] - public string Path { get; set; } - - /// - /// The encoding settings. - /// - [XmlAttribute(AttributeName = "encoding")] - public string Encoding { get; set; } - - /// - /// The file name of the log. - /// - [XmlAttribute(AttributeName = "filename")] - public string Filename { get; set; } - - /// - /// The format of the timestamp. - /// - [XmlAttribute(AttributeName = "timepattern")] - public string Timepattern { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public SettingLogItem() - { - } - } -} diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index 958ccbd9..d0c11564 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -1,17 +1,18 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Threading; -using System.Xml.Serialization; -using WebExpress.WebCore.Config; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPackage; +using WebExpress.WebCore.WebSetting; [assembly: InternalsVisibleTo("WebExpress.WebCore.Test")] @@ -161,7 +162,7 @@ public int Execution(string[] args) if (argumentDict.ContainsKey("help")) { - Console.WriteLine(Name + " [-port number | -config dateiname | -help]"); + Console.WriteLine(Name + " [-port number | -config filename | -help]"); Console.WriteLine("Version: " + Version); return 0; @@ -203,22 +204,21 @@ public int Execution(string[] args) return 0; } - // configuration - if (!argumentDict.ContainsKey("config")) - { - // check if there is a file called config.xml - if (!File.Exists(Path.Combine(Path.Combine(Environment.CurrentDirectory, "config"), "webexpress.config.xml"))) - { - Console.WriteLine("No configuration file was specified. Usage: " + Name + " -config filename"); + // settings + var settingsFile = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, SettingsLoader.DefaultDirectory, argumentDict.TryGetValue("config", out var configArgument) ? configArgument : SettingsLoader.DefaultMainFile)); - return 1; - } + if (!File.Exists(settingsFile)) + { + Console.WriteLine($"The settings file '{settingsFile}' was not found. Usage: {Name} -config filename"); - argumentDict.Add("config", "webexpress.config.xml"); + return 1; } // initialization of the web server - OnInitialization(ArgumentParser.Current.GetValidArguments(args), Path.Combine(Path.Combine(Environment.CurrentDirectory, "config"), argumentDict["config"])); + if (!OnInitialization(ArgumentParser.Current.GetValidArguments(args), settingsFile)) + { + return 1; + } // start the manager (_componentHub as ComponentHub).Execute(); @@ -246,20 +246,33 @@ private void OnCancel(object sender, ConsoleCancelEventArgs e) /// Initialization /// /// The valid arguments. - /// The configuration file. - private void OnInitialization(string args, string configFile) + /// The main settings file; its directory is the settings directory. + /// when the server is ready to start, when the settings could not be read. + private bool OnInitialization(string args, string settingsFile) { - // load configuration - using var reader = new FileStream(configFile, FileMode.Open); - var serializer = new XmlSerializer(typeof(HttpServerConfig)); - var config = serializer.Deserialize(reader) as HttpServerConfig; var log = new Log(); + IConfigurationRoot configuration; + + // a broken settings file is the most likely reason for a failed start, so it is + // reported by name and stops the start instead of surfacing as a stack trace + try + { + configuration = SettingsLoader.Load(settingsFile, ex => log.Exception(ex)); + } + catch (Exception ex) + { + Console.WriteLine($"The settings could not be read: {ex.Message}"); + return false; + } + + var settings = configuration.GetServerSettings(); + var settingsPath = Path.GetDirectoryName(settingsFile); var culture = CultureInfo.CurrentCulture; try { - culture = new CultureInfo(config.Culture); + culture = new CultureInfo(settings.Culture); CultureInfo.CurrentCulture = culture; } @@ -268,29 +281,19 @@ private void OnInitialization(string args, string configFile) } - var packageBase = string.IsNullOrWhiteSpace(config.PackageBase) ? - Environment.CurrentDirectory : Path.IsPathRooted(config.PackageBase) ? - config.PackageBase : - Path.Combine(Environment.CurrentDirectory, config.PackageBase); - - var assetBase = string.IsNullOrWhiteSpace(config.AssetBase) ? - Environment.CurrentDirectory : Path.IsPathRooted(config.AssetBase) ? - config.AssetBase : - Path.Combine(Environment.CurrentDirectory, config.AssetBase); - - var dataBase = string.IsNullOrWhiteSpace(config.DataBase) ? - Environment.CurrentDirectory : Path.IsPathRooted(config.DataBase) ? - config.DataBase : - Path.Combine(Environment.CurrentDirectory, config.DataBase); + var packageBase = ResolveDirectory(settings.PackagePath); + var assetBase = ResolveDirectory(settings.AssetPath); + var dataBase = ResolveDirectory(settings.DataPath); var context = new HttpServerContext ( - new RouteEndpoint(config.Route), - config.Endpoints, - Path.GetFullPath(packageBase), - Path.GetFullPath(assetBase), - Path.GetFullPath(dataBase), - Path.GetDirectoryName(configFile), + new RouteEndpoint(settings.ContextPath), + settings.Endpoints, + packageBase, + assetBase, + dataBase, + settingsPath, + configuration, culture, log, null @@ -298,20 +301,20 @@ private void OnInitialization(string args, string configFile) _httpServer = new HttpServer(context) { - Config = config + Settings = settings }; _componentHub = ComponentActivator.CreateInstance(_httpServer.HttpServerContext); // apply the configured session lifetime once the manager exists; left unset, its // built-in bounded default stands - if (config.Session?.TimeoutMinutes is int timeoutMinutes && _componentHub.SessionManager is not null) + if (settings.Session?.TimeoutMinutes is int timeoutMinutes && _componentHub.SessionManager is not null) { _componentHub.SessionManager.Timeout = TimeSpan.FromMinutes(timeoutMinutes); } // start logging - _httpServer.HttpServerContext.Log?.Begin(config.Log); + _httpServer.HttpServerContext.Log?.Begin(settings.Log); // log program start _httpServer.HttpServerContext.Log?.Separator('/'); @@ -320,38 +323,45 @@ private void OnInitialization(string args, string configFile) _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.version"), args: Version); _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.arguments"), args: args); _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.workingdirectory"), args: Environment.CurrentDirectory); - _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.packagebase"), args: config.PackageBase); - _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.assetbase"), args: config.AssetBase); - _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.database"), args: config.DataBase); - _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.configurationdirectory"), args: Path.GetDirectoryName(configFile)); - _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.configuration"), args: Path.GetFileName(configFile)); + _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.packagebase"), args: packageBase); + _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.assetbase"), args: assetBase); + _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.database"), args: dataBase); + _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.settingsdirectory"), args: settingsPath); + foreach (var file in configuration.Providers.OfType().SelectMany(x => x.EnumerateFiles())) + { + _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.settings"), args: Path.GetFileName(file)); + } _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.logdirectory"), args: Path.GetDirectoryName(_httpServer.HttpServerContext.Log?.Filename)); _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.log"), args: Path.GetFileName(_httpServer.HttpServerContext.Log?.Filename)); - foreach (var v in config.Endpoints) + foreach (var v in settings.Endpoints) { _httpServer.HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:app.uri"), args: v.Uri); } _httpServer.HttpServerContext.Log?.Separator('='); - if (!Directory.Exists(config.PackageBase)) - { - Directory.CreateDirectory(config.PackageBase); - } - - if (!Directory.Exists(config.AssetBase)) - { - Directory.CreateDirectory(config.AssetBase); - } - - if (!Directory.Exists(config.DataBase)) - { - Directory.CreateDirectory(config.DataBase); - } + Directory.CreateDirectory(packageBase); + Directory.CreateDirectory(assetBase); + Directory.CreateDirectory(dataBase); Console.CancelKeyPress += OnCancel; Initialization?.Invoke(this, EventArgs.Empty); + + return true; + } + + /// + /// Turns a configured directory into an absolute one. A relative directory is taken + /// relative to the working directory; an empty one is the working directory itself. + /// + /// The configured directory. + /// The absolute directory. + private static string ResolveDirectory(string directory) + { + return Path.GetFullPath(string.IsNullOrWhiteSpace(directory) + ? Environment.CurrentDirectory + : Path.Combine(Environment.CurrentDirectory, directory)); } /// diff --git a/src/WebExpress.WebCore/WebLog/ILog.cs b/src/WebExpress.WebCore/WebLog/ILog.cs index dcfb8b16..f0a6da4f 100644 --- a/src/WebExpress.WebCore/WebLog/ILog.cs +++ b/src/WebExpress.WebCore/WebLog/ILog.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; -using WebExpress.WebCore.Setting; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.WebLog { @@ -121,7 +121,7 @@ public interface ILog : ILogger /// Starts logging /// /// The log settings - public void Begin(SettingLogItem settings); + public void Begin(LogSettings settings); /// /// A dividing line with * characters diff --git a/src/WebExpress.WebCore/WebLog/Log.cs b/src/WebExpress.WebCore/WebLog/Log.cs index 3d2f2516..4cb4f7e7 100644 --- a/src/WebExpress.WebCore/WebLog/Log.cs +++ b/src/WebExpress.WebCore/WebLog/Log.cs @@ -6,7 +6,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading; -using WebExpress.WebCore.Setting; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.WebLog { @@ -190,12 +190,12 @@ public void Begin(string path) /// Starts logging /// /// The log settings - public void Begin(SettingLogItem settings) + public void Begin(LogSettings settings) { - Filename = settings.Filename; + Filename = settings.FileName; // a malformed configuration value must not crash startup; fall back to the current value - if (Enum.TryParse(settings.Modus, true, out var mode)) + if (Enum.TryParse(settings.Mode, true, out var mode)) { LogMode = mode; } @@ -209,9 +209,9 @@ public void Begin(SettingLogItem settings) Encoding = Encoding.UTF8; } - if (!string.IsNullOrWhiteSpace(settings.Timepattern)) + if (!string.IsNullOrWhiteSpace(settings.TimePattern)) { - TimePattern = settings.Timepattern; + TimePattern = settings.TimePattern; } DebugMode = settings.Debug; diff --git a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs index 5b3dffbf..f876138b 100644 --- a/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs +++ b/src/WebExpress.WebCore/WebPackage/Model/PackageItemSpec.cs @@ -91,5 +91,14 @@ public class PackageItemSpec /// [XmlElement("artifact", IsNullable = true)] public string[] Artifacts { get; set; } + + /// + /// Gets or sets the settings files (json) the package ships. On installation they are + /// deployed to the settings directory of the server, where every plugin's file is merged + /// into one configuration - which is why a file must keep its values under + /// Plugins:<plugin id>. + /// + [XmlElement("settings", IsNullable = true)] + public string[] Settings { get; set; } } } diff --git a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs index 0c9e7a5c..6091ea93 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageBuilder.cs @@ -14,6 +14,11 @@ namespace WebExpress.WebCore.WebPackage /// public static class PackageBuilder { + /// + /// The directory inside a package that holds the settings files. + /// + public const string SettingsDirectory = "settings"; + /// /// Creates a webex package. /// @@ -129,6 +134,7 @@ public static void Create(string specFile, string config, string targets, string ProjectToZip(archive, package, rootDirectory, config, targets); ArtifactsToZip(archive, package, rootDirectory); + SettingsToZip(archive, package, rootDirectory); } /// @@ -215,6 +221,7 @@ private static void SpecToZip(ZipArchive archive, PackageItemSpec package) Tags = package?.Tags, Plugins = package?.Plugins?.Select(x => $"{zipBinarys}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray(), Dependencies = package?.Dependencies, + Settings = package?.Settings?.Select(x => $"{SettingsDirectory}/{SanitizeFileNameComponent(Path.GetFileName(x))}").ToArray() }; serializer.Serialize(zipStream, newPackage); @@ -311,6 +318,30 @@ private static void ArtifactsToZip(ZipArchive archive, PackageItemSpec package, } } + /// + /// Create the settings files. They are kept apart from the libraries under their own + /// directory, so the package manager can deploy them to the settings directory of the + /// server without extracting them next to the code. + /// + /// The zip archive. + /// The package. + /// The root path. + private static void SettingsToZip(ZipArchive archive, PackageItemSpec package, string path) + { + foreach (var item in package?.Settings ?? Enumerable.Empty()) + { + var fileName = Find(path, item); + + if (!string.IsNullOrWhiteSpace(fileName) && File.Exists(fileName)) + { + var entryPath = SanitizeEntryPath($"{SettingsDirectory}/{Path.GetFileName(fileName)}"); + AddFileToZip(archive, entryPath, fileName); + + Console.WriteLine($"*** PackageBuilder: Create the settings file '{fileName}'."); + } + } + } + /// /// Find a file by walking up the directory tree and searching recursively at each level. /// diff --git a/src/WebExpress.WebCore/WebPackage/PackageManager.cs b/src/WebExpress.WebCore/WebPackage/PackageManager.cs index 0f5bb639..e6c9b108 100644 --- a/src/WebExpress.WebCore/WebPackage/PackageManager.cs +++ b/src/WebExpress.WebCore/WebPackage/PackageManager.cs @@ -8,6 +8,7 @@ using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -918,10 +919,19 @@ private void ExtractPackage(PackageCatalogItem package) Directory.CreateDirectory(extractedPath); } + var deployedSettings = false; + foreach (var entry in zip.Entries) { var normalized = (entry.FullName ?? string.Empty).Replace('\\', '/').TrimStart('/'); + if (normalized.StartsWith(PackageBuilder.SettingsDirectory + "/", StringComparison.OrdinalIgnoreCase)) + { + deployedSettings |= DeploySettings(entry, normalized); + + continue; + } + if (!normalized.StartsWith("lib/", StringComparison.OrdinalIgnoreCase)) { continue; @@ -963,6 +973,98 @@ private void ExtractPackage(PackageCatalogItem package) entry.ExtractToFile(targetFilePath, true); } + + // the configuration everyone holds is reloaded in place, so the plugin about to + // boot finds its settings without a restart + if (deployedSettings) + { + _httpServerContext?.Configuration?.Reload(); + } + } + } + + /// + /// Deploys a settings file of a package to the settings directory of the server. An + /// existing file is left alone: it is the administrator's by then, and a package update + /// must not undo the changes made to it. + /// + /// The archive entry of the settings file. + /// The entry path with forward slashes and no leading slash. + /// when the file was written, when it was skipped. + private bool DeploySettings(ZipArchiveEntry entry, string normalized) + { + var settingsPath = _httpServerContext?.SettingsPath; + var segments = normalized.Split('/'); + + // directory entries carry no file + if (string.IsNullOrEmpty(entry.Name) || string.IsNullOrWhiteSpace(settingsPath)) + { + return false; + } + + // only json files directly below the settings directory are settings; a nested path + // could escape the directory and another extension would never be merged + if (segments.Length != 2 + || !segments[1].EndsWith(".json", StringComparison.OrdinalIgnoreCase) + || segments[1].IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + _httpServerContext?.Log?.Warning(I18N.Translate("webexpress.webcore:packagemanager.settings.ignored", entry.FullName)); + + return false; + } + + var targetFilePath = Path.Combine(settingsPath, segments[1]); + + if (File.Exists(targetFilePath)) + { + _httpServerContext?.Log?.Debug(I18N.Translate("webexpress.webcore:packagemanager.settings.existing", segments[1])); + + return false; + } + + using var stream = entry.Open(); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + var content = buffer.ToArray(); + + // a file that does not parse is refused before it reaches the directory: once there, + // it would fail every following start of the server, not just this installation + if (!IsJson(content)) + { + _httpServerContext?.Log?.Warning(I18N.Translate("webexpress.webcore:packagemanager.settings.invalid", entry.FullName)); + + return false; + } + + Directory.CreateDirectory(settingsPath); + File.WriteAllBytes(targetFilePath, content); + + _httpServerContext?.Log?.Info(I18N.Translate("webexpress.webcore:packagemanager.settings.deployed", segments[1])); + + return true; + } + + /// + /// Checks whether the content is a json document the configuration would accept: comments + /// and trailing commas included, as the json configuration provider allows them too. + /// + /// The content to check. + /// when the content parses as json. + private static bool IsJson(byte[] content) + { + try + { + using var document = JsonDocument.Parse(content, new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }); + + return true; + } + catch (JsonException) + { + return false; } } diff --git a/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs b/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs index 86b5093e..ad7633f2 100644 --- a/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs +++ b/src/WebExpress.WebCore/WebPlugin/IPluginContext.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; @@ -7,7 +8,7 @@ namespace WebExpress.WebCore.WebPlugin /// /// Read-only descriptor of a loaded plugin that the framework passes around so components can /// learn which plugin they came from and read its metadata — id, name, manufacturer, version, - /// description, copyright, license, icon, and the .NET assembly it lives in. + /// description, copyright, license, icon, the .NET assembly it lives in - and its own settings. /// public interface IPluginContext : IContext { @@ -55,5 +56,12 @@ public interface IPluginContext : IContext /// Gets the icon of the plugin. /// IRoute Icon { get; } + + /// + /// Gets the settings of the plugin: its own section of the merged configuration, so a + /// plugin reads Settings["Key"] or binds Settings.Get<MyOptions>() + /// without seeing - or clashing with - the values of the server or of another plugin. + /// + IConfiguration Settings { get; } } } diff --git a/src/WebExpress.WebCore/WebPlugin/PluginContext.cs b/src/WebExpress.WebCore/WebPlugin/PluginContext.cs index 62a2add6..0ef071d4 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginContext.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginContext.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Microsoft.Extensions.Configuration; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; @@ -54,6 +55,11 @@ public class PluginContext : IPluginContext /// public IRoute Icon { get; internal set; } + /// + /// Gets the settings of the plugin. + /// + public IConfiguration Settings { get; internal set; } + /// /// Initializes a new instance of the class. /// diff --git a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs index c32ff6c7..0f6e7250 100644 --- a/src/WebExpress.WebCore/WebPlugin/PluginManager.cs +++ b/src/WebExpress.WebCore/WebPlugin/PluginManager.cs @@ -12,6 +12,7 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebPlugin.Model; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.WebPlugin { @@ -190,7 +191,8 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex PluginName = assembly.GetName().Name.ToLower(), Manufacturer = assembly.GetCustomAttribute()?.Company, Copyright = assembly.GetCustomAttribute()?.Copyright, - Version = assembly.GetCustomAttribute()?.InformationalVersion + Version = assembly.GetCustomAttribute()?.InformationalVersion, + Settings = _httpServerContext?.Configuration?.GetPluginSettings(id) }; var hasUnfulfilledDependencies = HasUnfulfilledDependencies(id, dependencies.Select(x => new ComponentId(x))); @@ -316,7 +318,8 @@ private IEnumerable Register(Assembly assembly, PluginLoadContex ? RouteEndpoint.Combine(_httpServerContext?.Route, icon) : null, Description = description, - Version = type.Assembly.GetCustomAttribute()?.InformationalVersion + Version = type.Assembly.GetCustomAttribute()?.InformationalVersion, + Settings = _httpServerContext?.Configuration?.GetPluginSettings(id) }; hasUnfulfilledDependencies = HasUnfulfilledDependencies(id, dependencies.Select(x => new ComponentId(x))); diff --git a/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs b/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs new file mode 100644 index 00000000..2662a455 --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs @@ -0,0 +1,35 @@ +namespace WebExpress.WebCore.WebSetting +{ + /// + /// One address the web server listens on. An https endpoint additionally names the pfx file + /// that holds its certificate, because Kestrel needs the certificate before the first + /// connection can be accepted. + /// + public sealed class EndpointSettings + { + /// + /// The uri to listen on, e.g. http://localhost/ or https://*:443/. The host + /// * stands for every address of the machine. + /// + public string Uri { get; set; } + + /// + /// The certificate as a pfx file. Only needed for https. + /// + public string PfxFile { get; set; } + + /// + /// The password that unlocks the pfx file. + /// + public string Password { get; set; } + + /// + /// Conversion into its string representation. + /// + /// The uri of the endpoint. + public override string ToString() + { + return Uri; + } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs new file mode 100644 index 00000000..a72ba4b3 --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// The settings of the web server itself, bound from the section of the + /// merged configuration. Everything that is not about the server - a plugin's own settings - + /// lives under instead, so the two can never collide even though + /// every file in the settings directory is merged into one configuration. + /// + /// + /// The class is a plain options object: it carries no knowledge of where the values came from + /// and is filled once at start-up by the configuration binder. Optional blocks stay + /// when they are absent, so a missing block never changes a default the + /// deployment did not opt into. + /// + public sealed class HttpServerSettings + { + /// + /// The name of the configuration section the server settings are read from. + /// + public const string Section = "WebExpress"; + + /// + /// The name of the configuration section that holds the settings of all plugins, one + /// child section per plugin id. + /// + public const string PluginSection = "Plugins"; + + /// + /// The endpoints the web server listens on. At least one is needed for the server to + /// answer anything. + /// + public List Endpoints { get; set; } = []; + + /// + /// Optional fine-tuning of the underlying Kestrel server, including all request limits. When + /// the block is omitted the web server keeps its built-in defaults, so this block only ever + /// applies values that are explicitly opted into. + /// + public KestrelSettings Kestrel { get; set; } + + /// + /// Optional settings of the session and its cookie. When the block is omitted the built-in + /// defaults apply, so this block only ever changes values explicitly opted into. + /// + public SessionSettings Session { get; set; } + + /// + /// The log settings. A missing block keeps logging switched off. + /// + public LogSettings Log { get; set; } = new(); + + /// + /// The directory the packages are installed to, relative to the working directory or absolute. + /// + public string PackagePath { get; set; } + + /// + /// The directory of the static files, relative to the working directory or absolute. + /// + public string AssetPath { get; set; } + + /// + /// The directory the applications keep their data in, relative to the working directory or absolute. + /// + public string DataPath { get; set; } + + /// + /// The path prefix every application is served under, e.g. wx to reach an + /// application at http://host/wx/app. Empty for none. + /// + public string ContextPath { get; set; } + + /// + /// The culture the server runs in, as a culture name such as en-US. Empty to keep + /// the culture of the operating system. + /// + public string Culture { get; set; } + } +} diff --git a/src/WebExpress.WebCore/Config/KestrelConfig.cs b/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs similarity index 74% rename from src/WebExpress.WebCore/Config/KestrelConfig.cs rename to src/WebExpress.WebCore/WebSetting/KestrelSettings.cs index 0ff222a8..8d51af92 100644 --- a/src/WebExpress.WebCore/Config/KestrelConfig.cs +++ b/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs @@ -1,36 +1,31 @@ using System; -using System.Xml.Serialization; using Microsoft.AspNetCore.Server.Kestrel.Core; -namespace WebExpress.WebCore.Config +namespace WebExpress.WebCore.WebSetting { /// - /// Optional fine-tuning of the underlying Kestrel server. The whole <kestrel> element as well - /// as every individual property is optional: any value that is left unset (null) keeps the - /// behavior the web server applied before this configuration block existed, so adding the element - /// can never change defaults a deployment did not explicitly opt into. + /// Optional fine-tuning of the underlying Kestrel server. The whole block as well as every + /// individual property is optional: any value that is left unset () keeps + /// the behavior the web server applied before this block existed, so adding the block can never + /// change defaults a deployment did not explicitly opt into. /// - [XmlRoot("kestrel", IsNullable = false)] - public sealed class KestrelConfig + public sealed class KestrelSettings { /// /// Allows synchronous IO on the request and response streams. WebExpress renders and sends /// responses synchronously, which is why this defaults to true when not specified. /// - [XmlElement("allowsynchronousio")] public bool? AllowSynchronousIO { get; set; } /// /// Allows the response headers to be compressed. Defaults to true when not specified. /// - [XmlElement("allowresponseheadercompression")] public bool? AllowResponseHeaderCompression { get; set; } /// /// Controls whether the Server response header is emitted. Disabling it reduces the /// information exposed about the host. Defaults to true when not specified. /// - [XmlElement("addserverheader")] public bool? AddServerHeader { get; set; } /// @@ -40,28 +35,28 @@ public sealed class KestrelConfig /// HTTP/2 over TLS via ALPN and serves plain HTTP as HTTP/1.1. Set Http2 on a plain /// (non-TLS) endpoint to enable cleartext HTTP/2 (h2c), which has no automatic upgrade path. /// - [XmlElement("protocols")] + /// + /// Kept as text rather than bound to the enum directly, so a typo keeps the default instead + /// of failing the start-up - see . + /// public string Protocols { get; set; } /// /// The maximum number of concurrent client connections. When not specified the Kestrel /// default (unlimited) is kept. /// - [XmlElement("maxconcurrentconnections")] public long? MaxConcurrentConnections { get; set; } /// /// The maximum allowed size of a request body, in bytes. When not specified the Kestrel /// default is kept. /// - [XmlElement("maxrequestbodysize")] public long? MaxRequestBodySize { get; set; } /// /// The maximum allowed size of the combined request headers, in bytes. When not specified /// the Kestrel default (32 KiB) is kept. /// - [XmlElement("maxrequestheaderstotalsize")] public int? MaxRequestHeadersTotalSize { get; set; } /// @@ -69,49 +64,43 @@ public sealed class KestrelConfig /// are not counted against the regular connection limit. When not specified the Kestrel /// default is kept. /// - [XmlElement("maxconcurrentupgradedconnections")] public long? MaxConcurrentUpgradedConnections { get; set; } /// /// The maximum size of the request buffer, in bytes. When not specified the Kestrel default is kept. /// - [XmlElement("maxrequestbuffersize")] public long? MaxRequestBufferSize { get; set; } /// /// The maximum size of the response buffer, in bytes. When not specified the Kestrel default is kept. /// - [XmlElement("maxresponsebuffersize")] public long? MaxResponseBufferSize { get; set; } /// /// The maximum allowed size of the request line (request method, uri and protocol), in bytes. /// When not specified the Kestrel default is kept. /// - [XmlElement("maxrequestlinesize")] public int? MaxRequestLineSize { get; set; } /// /// The keep-alive timeout, in seconds. A value that closes idle connections after a period of /// inactivity. When not specified the Kestrel default is kept. /// - [XmlElement("keepalivetimeout")] public int? KeepAliveTimeout { get; set; } /// /// The amount of time, in seconds, the server waits for the request headers to be received in /// full before closing the connection. When not specified the Kestrel default is kept. /// - [XmlElement("requestheaderstimeout")] public int? RequestHeadersTimeout { get; set; } /// /// Resolves the configured name to the corresponding Kestrel - /// value. Returns null when nothing was configured or the - /// value is not a recognised protocol name, so the caller keeps the Kestrel default instead - /// of silently applying an unintended restriction from a typo. + /// value. Returns when nothing was + /// configured or the value is not a recognised protocol name, so the caller keeps the + /// Kestrel default instead of silently applying an unintended restriction from a typo. /// - /// The parsed protocols, or null to keep the Kestrel default. + /// The parsed protocols, or to keep the Kestrel default. public HttpProtocols? ResolveProtocols() { if (string.IsNullOrWhiteSpace(Protocols)) @@ -126,12 +115,5 @@ public sealed class KestrelConfig ? result : null; } - - /// - /// Initializes a new instance of the class. - /// - public KestrelConfig() - { - } } } diff --git a/src/WebExpress.WebCore/WebSetting/LogSettings.cs b/src/WebExpress.WebCore/WebSetting/LogSettings.cs new file mode 100644 index 00000000..0921e85f --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/LogSettings.cs @@ -0,0 +1,44 @@ +namespace WebExpress.WebCore.WebSetting +{ + /// + /// The settings of the log file. The defaults describe a server that logs to the console only, + /// so a deployment that says nothing about logging never writes to disk by accident. + /// + public sealed class LogSettings + { + /// + /// How the log file is written: Off for no file, Append to keep adding to the + /// existing file or Override to start afresh on every start. + /// + /// + /// Kept as text rather than bound to the enum directly, so a typo falls back to the + /// previous mode instead of failing the start-up. + /// + public string Mode { get; set; } = "Off"; + + /// + /// Determines whether debug output is written as well. + /// + public bool Debug { get; set; } + + /// + /// The directory the log file is created in, relative to the working directory or absolute. + /// + public string Path { get; set; } = "./log"; + + /// + /// The text encoding of the log file. + /// + public string Encoding { get; set; } = "utf-8"; + + /// + /// The file name of the log. + /// + public string FileName { get; set; } = "webexpress.log"; + + /// + /// The format of the timestamp in front of every entry. + /// + public string TimePattern { get; set; } = "dd.MM.yyyy HH:mm:ss"; + } +} diff --git a/src/WebExpress.WebCore/Config/SessionConfig.cs b/src/WebExpress.WebCore/WebSetting/SessionSettings.cs similarity index 68% rename from src/WebExpress.WebCore/Config/SessionConfig.cs rename to src/WebExpress.WebCore/WebSetting/SessionSettings.cs index fa87064b..97ad929f 100644 --- a/src/WebExpress.WebCore/Config/SessionConfig.cs +++ b/src/WebExpress.WebCore/WebSetting/SessionSettings.cs @@ -1,14 +1,11 @@ -using System.Xml.Serialization; - -namespace WebExpress.WebCore.Config +namespace WebExpress.WebCore.WebSetting { /// - /// Optional configuration of the session and its cookie. The whole <session> element and - /// every property in it is optional: a value left unset keeps the built-in default, so adding - /// the block never changes behavior a deployment did not opt into. + /// Optional settings of the session and its cookie. The whole block and every property in it + /// is optional: a value left unset keeps the built-in default, so adding the block never + /// changes behavior a deployment did not opt into. /// - [XmlRoot("session", IsNullable = false)] - public sealed class SessionConfig + public sealed class SessionSettings { /// /// The idle lifetime of a session in minutes, applied as a sliding window. It bounds both @@ -16,7 +13,6 @@ public sealed class SessionConfig /// applies; a non-positive value disables expiry, which also turns the cookie into a /// session cookie that dies when the browser closes rather than one that never expires. /// - [XmlElement("timeout")] public int? TimeoutMinutes { get; set; } /// @@ -26,7 +22,6 @@ public sealed class SessionConfig /// behind a TLS-terminating proxy and therefore sees plain http itself, so the cookie is /// still marked https-only towards the browser. /// - [XmlElement("secure")] public bool? Secure { get; set; } } } diff --git a/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationExtensions.cs b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationExtensions.cs new file mode 100644 index 00000000..314e076b --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationExtensions.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Adds the settings directory of the server to a configuration builder, in the same way the + /// framework's own AddJsonFile adds a single file. + /// + public static class SettingsDirectoryConfigurationExtensions + { + /// + /// Adds every json file of the given directory as one configuration source. + /// + /// The configuration builder. + /// The settings directory. + /// + /// The file name of the main settings file, which is merged last and therefore overrides + /// every other file. Defaults to . + /// + /// Whether a change to a file reloads the configuration. + /// Called when a reload triggered by a file change fails. + /// The configuration builder, for chaining. + public static IConfigurationBuilder AddSettingsDirectory(this IConfigurationBuilder builder, string path, string mainFile = null, bool reloadOnChange = true, Action onLoadException = null) + { + return builder.Add(new SettingsDirectoryConfigurationSource + { + Path = Path.GetFullPath(path), + MainFile = string.IsNullOrWhiteSpace(mainFile) ? SettingsLoader.DefaultMainFile : mainFile, + ReloadOnChange = reloadOnChange, + OnLoadException = onLoadException + }); + } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationProvider.cs b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationProvider.cs new file mode 100644 index 00000000..e38915fd --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationProvider.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Merges every json file of a directory into one set of configuration values. Files are + /// merged in alphabetical order of their names, the main file last, so precedence is + /// predictable from a directory listing alone. + /// + /// + /// Each file is parsed by the regular json provider, so the files follow the same rules as an + /// appsettings.json - comments and trailing commas included. A load builds the complete + /// new set first and swaps it in only when every file could be read, so a file that is being + /// saved at that moment never leaves the configuration half-updated. + /// + public sealed class SettingsDirectoryConfigurationProvider : ConfigurationProvider, IDisposable + { + private readonly SettingsDirectoryConfigurationSource _source; + private readonly PhysicalFileProvider _fileProvider; + private readonly IDisposable _changeRegistration; + + /// + /// Initializes a new instance of the class. + /// + /// The source describing the directory. + public SettingsDirectoryConfigurationProvider(SettingsDirectoryConfigurationSource source) + { + _source = source; + + // the watcher needs an existing directory; a missing one has nothing to watch and is + // reported by the load instead + if (source.ReloadOnChange && Directory.Exists(source.Path)) + { + _fileProvider = new PhysicalFileProvider(source.Path); + _changeRegistration = ChangeToken.OnChange(() => _fileProvider.Watch("*.json"), ReloadOnChange); + } + } + + /// + /// Reads all json files of the directory. A file that cannot be read fails the whole load, + /// so a broken file is noticed at start-up rather than silently ignored. + /// + public override void Load() + { + var data = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var file in EnumerateFiles()) + { + var configuration = new ConfigurationBuilder() + .AddJsonFile(file, optional: false, reloadOnChange: false) + .Build(); + + using (configuration as IDisposable) + { + // sections without a value are only containers; the keys below them carry the + // values, so copying them would add nothing but empty entries + foreach (var (key, value) in configuration.AsEnumerable().Where(x => x.Value is not null)) + { + data[key] = value; + } + } + } + + Data = data; + } + + /// + /// Lists the files to merge in the order of their precedence, lowest first. + /// + /// The full paths of the files. + public IEnumerable EnumerateFiles() + { + if (string.IsNullOrWhiteSpace(_source.Path) || !Directory.Exists(_source.Path)) + { + return []; + } + + var files = Directory.EnumerateFiles(_source.Path, "*.json", SearchOption.TopDirectoryOnly) + .Where(x => Path.GetExtension(x).Equals(".json", StringComparison.OrdinalIgnoreCase)) + .OrderBy(x => Path.GetFileName(x), StringComparer.OrdinalIgnoreCase) + .ToList(); + + var main = files.FirstOrDefault(x => Path.GetFileName(x).Equals(_source.MainFile, StringComparison.OrdinalIgnoreCase)); + + if (main is not null) + { + files.Remove(main); + files.Add(main); + } + + return files; + } + + /// + /// Releases the file watcher. + /// + public void Dispose() + { + _changeRegistration?.Dispose(); + _fileProvider?.Dispose(); + } + + /// + /// Reloads after a file changed. The previous values stay in place when the reload fails, + /// because the failure happens on the watcher's thread where nobody could catch it. + /// + private void ReloadOnChange() + { + Thread.Sleep(_source.ReloadDelay); + + try + { + Load(); + OnReload(); + } + catch (Exception ex) + { + _source.OnLoadException?.Invoke(ex); + } + } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationSource.cs b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationSource.cs new file mode 100644 index 00000000..3cfed5fb --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SettingsDirectoryConfigurationSource.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.Extensions.Configuration; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Describes a directory of json files that together form one configuration: the settings + /// directory of the server, into which every deployed plugin drops its own file. + /// + /// + /// A directory rather than a fixed list of files, because the set of files is not known when + /// the server starts: a package installed at runtime adds one. Enumerating the directory on + /// every load lets a later pick it up without + /// rebuilding the configuration everyone already holds a reference to. + /// + public sealed class SettingsDirectoryConfigurationSource : IConfigurationSource + { + /// + /// The directory whose json files are merged. + /// + public string Path { get; set; } + + /// + /// The file name of the main settings file within the directory. It is merged last, so + /// its values take precedence over those of every other file - the one place an + /// administrator can override a plugin's shipped default. + /// + public string MainFile { get; set; } + + /// + /// Whether a change to any json file in the directory reloads the configuration. + /// + public bool ReloadOnChange { get; set; } + + /// + /// The time to wait after a change before reloading, in milliseconds. Editors write a file + /// in several steps, so reading it the instant the first change is seen would find it + /// half-written. + /// + public int ReloadDelay { get; set; } = 250; + + /// + /// Called when a reload triggered by a file change fails. The previous values are kept + /// in that case; the callback is the only place the failure surfaces, because there is no + /// caller to throw to on the watcher's thread. + /// + public Action OnLoadException { get; set; } + + /// + /// Builds the provider that reads the directory. + /// + /// The configuration builder. + /// The provider. + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new SettingsDirectoryConfigurationProvider(this); + } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/SettingsLoader.cs b/src/WebExpress.WebCore/WebSetting/SettingsLoader.cs new file mode 100644 index 00000000..fd52482d --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SettingsLoader.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Builds the configuration of the server. It is the one place that knows which sources make + /// up the configuration and in which order they override each other, so a host and a test + /// end up with the same composition the server runs on. + /// + /// + /// The sources, lowest precedence first: every json file of the settings directory in + /// alphabetical order, then the main settings file, then the environment variables carrying + /// the . A plugin ships its defaults in its own file, + /// an administrator overrides them in the main file, and a container overrides both from the + /// environment without touching a file at all. + /// + public static class SettingsLoader + { + /// + /// The name of the settings directory, relative to the working directory. + /// + public const string DefaultDirectory = "settings"; + + /// + /// The file name of the main settings file. + /// + public const string DefaultMainFile = "webexpress.settings.json"; + + /// + /// The prefix of the environment variables that override settings, e.g. + /// WEBEXPRESS_WebExpress__Culture. The prefix keeps unrelated variables of the host + /// out of the configuration. + /// + public const string EnvironmentVariablePrefix = "WEBEXPRESS_"; + + /// + /// Builds the configuration from the given main settings file, its directory and the + /// environment. + /// + /// The path of the main settings file; its directory is the settings directory. + /// Called when a reload triggered by a file change fails. + /// The configuration. + public static IConfigurationRoot Load(string settingsFile, Action onLoadException = null) + { + var fullPath = Path.GetFullPath(settingsFile); + + return new ConfigurationBuilder() + .AddSettingsDirectory(Path.GetDirectoryName(fullPath), Path.GetFileName(fullPath), onLoadException: onLoadException) + .AddEnvironmentVariables(EnvironmentVariablePrefix) + .Build(); + } + + /// + /// Binds the server settings from the configuration. + /// + /// The configuration. + /// The server settings; every block that is absent in the configuration stays at its default. + public static HttpServerSettings GetServerSettings(this IConfiguration configuration) + { + return configuration.GetSection(HttpServerSettings.Section).Get() ?? new HttpServerSettings(); + } + + /// + /// Returns the section a plugin's own settings live in. + /// + /// The configuration. + /// The id of the plugin. + /// The section; it exists even when nothing is configured, then simply without values. + public static IConfigurationSection GetPluginSettings(this IConfiguration configuration, string pluginId) + { + return configuration.GetSection(ConfigurationPath.Combine(HttpServerSettings.PluginSection, pluginId)); + } + } +} From d5b73ec903a56fbb852fbc9a0eecdc200e7c2889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 21 Sep 2026 05:58:20 +0200 Subject: [PATCH 38/69] feat: replace server-side sessions with token-based authentication --- .../Fixture/AuthenticationFixture.cs | 96 +++++ .../Manager/UnitTestAuthenticationEndpoint.cs | 223 ++++++++++++ .../Manager/UnitTestIdentityAuthentication.cs | 222 ++++++++++++ .../Manager/UnitTestIdentityManager.cs | 173 +-------- .../UnitTestOpenIdConnectIdentityProvider.cs | 212 +++++++++++ .../Server/UnitTestHttpServer.cs | 117 ++++-- src/WebExpress.WebCore/HttpServer.cs | 44 ++- .../WebComponent/ComponentHub.cs | 9 + .../WebComponent/IComponentHub.cs | 5 + .../WebExpress.WebCore.csproj | 5 + .../WebIdentity/AuthenticationEndpoint.cs | 224 ++++++++++++ .../WebIdentity/FileIdentityTokenStore.cs | 83 +++++ .../WebIdentity/IIdentity.cs | 17 + .../WebIdentity/IIdentityManager.cs | 87 ++--- .../WebIdentity/IIdentityProviderManager.cs | 32 ++ .../WebIdentity/IIdentityTokenStore.cs | 31 ++ .../WebIdentity/Identity.cs | 74 ++++ .../IdentityManager.Authentication.cs | 246 +++++++++++++ .../WebIdentity/IdentityManager.cs | 133 +------ .../WebIdentity/IdentityProviderManager.cs | 179 ++++++++++ .../WebIdentity/IdentityTokenPair.cs | 30 ++ .../WebIdentity/IdentityTokenService.cs | 337 ++++++++++++++++++ .../WebIdentity/LocalIdentityProvider.cs | 65 ++++ .../OpenIdConnectIdentityProvider.cs | 292 +++++++++++++++ .../WebMessage/RequestBase.cs | 15 +- .../WebMessage/RequestHeaderFields.cs | 12 + .../WebSession/AuthorizationService.cs | 18 - .../WebSession/Model/Session.cs | 5 +- .../Model/SessionPropertyAuthentification.cs | 27 -- .../Model/SessionPropertyAuthorization.cs | 10 - .../WebSession/SessionManager.cs | 7 +- .../WebSetting/AuthenticationSettings.cs | 57 +++ .../WebSetting/HttpServerSettings.cs | 5 + .../WebSetting/OpenIdConnectSettings.cs | 45 +++ 34 files changed, 2697 insertions(+), 440 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/FileIdentityTokenStore.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IIdentityProviderManager.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IIdentityTokenStore.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/Identity.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityTokenPair.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/LocalIdentityProvider.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs delete mode 100644 src/WebExpress.WebCore/WebSession/AuthorizationService.cs delete mode 100644 src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthentification.cs delete mode 100644 src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthorization.cs create mode 100644 src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs create mode 100644 src/WebExpress.WebCore/WebSetting/OpenIdConnectSettings.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs new file mode 100644 index 00000000..a0f461cb --- /dev/null +++ b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.Configuration; +using System.Security.Cryptography; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPlugin; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Fixture +{ + /// + /// Provides isolated authentication configuration and durable markers for regression tests. + /// + internal sealed class AuthenticationFixture : IDisposable + { + internal AuthenticationSettings Settings { get; } = new() + { + Issuer = "https://webexpress.test", Audience = "webexpress-tests", + SigningKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)), + TokenStorePath = Path.Combine(Path.GetTempPath(), "webexpress-auth-" + Guid.NewGuid().ToString("N")) + }; + internal ComponentHub Hub { get; } + internal IHttpServerContext Server { get; } + internal IApplicationContext Application { get; } + internal IdentityManager Manager => (IdentityManager)Hub.IdentityManager; + internal IdentityTokenService Tokens { get; } + internal TestClock Clock { get; } = new(); + + /// + /// Creates an isolated signing authority and durable token directory for each test. + /// + /// Whether the test retains the production transport requirement. + internal AuthenticationFixture(bool requireHttps = true) + { + Settings.RequireHttps = requireHttps; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["WebExpress:Authentication:Issuer"] = Settings.Issuer, + ["WebExpress:Authentication:Audience"] = Settings.Audience, + ["WebExpress:Authentication:SigningKey"] = Settings.SigningKey, + ["WebExpress:Authentication:RequireHttps"] = requireHttps.ToString(), + ["WebExpress:Authentication:TokenStorePath"] = Settings.TokenStorePath + }).Build(); + Server = UnitTestFixture.CreateHttpServerContextMock(configuration: configuration); + Hub = UnitTestFixture.CreateComponentHubMock(Server); + ((PluginManager)Hub.PluginManager).Register(); + Application = Hub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + configuration["WebExpress:Authentication:ApplicationId"] = Application.ApplicationId; + Tokens = new IdentityTokenService(Settings, new FileIdentityTokenStore(Settings.TokenStorePath), Clock); + } + + /// + /// Builds a request with explicit transport semantics without allocating application session state. + /// + /// The HTTP headers required for the boundary scenario. + /// The HTTP method used to exercise the endpoint contract. + /// The request route or cookie path that constrains credential use. + /// The payload serialized or supplied for the authentication request. + /// Whether the request uses HTTPS rather than development HTTP. + /// The request configured for the isolated application and selected transport. + internal RequestBase Request(string headers = "", string method = "GET", string path = "/", string body = null, bool https = true) + { + var content = $"{method} {path} HTTP/1.1\r\nCookie:\r\n{headers}"; + if (body is not null) { content += $"Content-Type: application/json\r\nContent-Length: {System.Text.Encoding.UTF8.GetByteCount(body)}\r\n"; } + var request = (RequestBase)UnitTestFixture.CreateRequestMock(content + "\r\n" + body); + request.ApplicationContext = Application; + var scheme = https ? global::WebExpress.WebCore.WebUri.UriScheme.Https : global::WebExpress.WebCore.WebUri.UriScheme.Http; + typeof(RequestBase).GetProperty(nameof(RequestBase.Scheme)).SetValue(request, scheme); + request.Uri.Scheme = scheme; + return request; + } + + /// + /// Removes only the isolated marker directory created by this fixture. + /// + public void Dispose() + { + Hub.IdentityProviderManager.Dispose(); + if (Directory.Exists(Settings.TokenStorePath)) { Directory.Delete(Settings.TokenStorePath, true); } + } + + /// + /// Makes signed token expiration deterministic without delaying tests. + /// + internal sealed class TestClock : TimeProvider + { + internal DateTimeOffset Now = DateTimeOffset.UtcNow; + /// + /// Allows expiry boundaries to be exercised without sleeping. + /// + /// The current simulated UTC time. + public override DateTimeOffset GetUtcNow() => Now; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs new file mode 100644 index 00000000..c2ced942 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs @@ -0,0 +1,223 @@ +using Microsoft.AspNetCore.Identity; +using System.Text.Json; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises the public authentication boundary, including cookie transport and browser-origin checks. + /// + [Collection("NonParallelTests")] + public class UnitTestAuthenticationEndpoint + { + /// + /// Keeps HTTPS mandatory when a deployment has not explicitly enabled development HTTP. + /// + /// A task that completes after the default transport rejection has been verified. + [Fact] + public async Task HttpIsRejectedWithoutDevelopmentOverride() + { + // arrange + using var fixture = new AuthenticationFixture(); + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = null; + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + + // act + var response = await endpoint.HandleAsync(fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", + "/api/auth/login", "{}", https: false)); + + // validation + Assert.Equal(400, response.Status); + Assert.Contains("https_required", (string)response.Content); + Assert.Empty(response.Header.Cookies.Cast()); + } + + /// + /// Allows development HTTP without weakening token validation, cookie isolation, rotation, or logout. + /// + /// A task that completes after the development authentication lifecycle has been verified. + [Fact] + public async Task DevelopmentHttpSupportsLoginRefreshAndLogout() + { + using var fixture = new AuthenticationFixture(requireHttps: false); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var login = await endpoint.HandleAsync(fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", + "/api/auth/login", "{\"username\":\"alice\",\"password\":\"correct\"}", https: false)); + + Assert.Equal(200, login.Status); + var access = login.Header.Cookies[IdentityManager.DevelopmentAccessCookieName]; + var refresh = login.Header.Cookies[IdentityManager.DevelopmentRefreshCookieName]; + Assert.NotNull(access); + Assert.NotNull(refresh); + Assert.True(access.HttpOnly && refresh.HttpOnly); + Assert.False(access.Secure || refresh.Secure); + Assert.Equal("/", access.Path); + Assert.Equal(IdentityManager.RefreshPath, refresh.Path); + var authenticatedRequest = fixture.Request($"Cookie: {access.Name}={access.Value}\r\n", https: false); + Assert.Equal("alice", fixture.Manager.GetCurrentIdentity(authenticatedRequest)?.Name); + + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = "true"; + Assert.Null(fixture.Manager.GetCurrentIdentity(authenticatedRequest)); + fixture.Server.Configuration["WebExpress:Authentication:RequireHttps"] = "false"; + + var renewed = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {refresh.Name}={refresh.Value}\r\n", + "POST", IdentityManager.RefreshPath, https: false)); + Assert.Equal(200, renewed.Status); + var nextAccess = renewed.Header.Cookies[access.Name]; + var nextRefresh = renewed.Header.Cookies[refresh.Name]; + Assert.NotEqual(refresh.Value, nextRefresh.Value); + var logout = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {nextAccess.Name}={nextAccess.Value}\r\n", + "POST", "/api/auth/logout", https: false)); + Assert.Equal(204, logout.Status); + Assert.All(logout.Header.Cookies.Cast(), cookie => Assert.True(cookie.Expires < DateTime.UtcNow)); + var revoked = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {nextRefresh.Name}={nextRefresh.Value}\r\n", + "POST", IdentityManager.RefreshPath, https: false)); + Assert.Equal(401, revoked.Status); + } + + /// + /// The server answers the root login route before sitemap lookup and serializes protected cookies on the wire. + /// + /// Whether the hub is already available when the server is constructed. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RootLoginIsIntegratedIntoTheHttpPipeline(bool hubAvailableAtConstruction) + { + using var fixture = new AuthenticationFixture(); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + const string body = "{\"username\":\"alice\",\"password\":\"correct\"}"; + var context = UnitTestFixture.CreateHttpContextMock($"POST /api/auth/login HTTP/1.1\r\nCookie:\r\nX-WebExpress-Auth: 1\r\nContent-Type: application/json\r\nContent-Length: {body.Length}\r\n\r\n{body}"); + typeof(WebMessage.RequestBase).GetProperty(nameof(WebMessage.RequestBase.Scheme)) + .SetValue(context.Request, global::WebExpress.WebCore.WebUri.UriScheme.Https); + context.Request.Uri.Scheme = global::WebExpress.WebCore.WebUri.UriScheme.Https; + var head = new Microsoft.AspNetCore.Http.Features.HttpResponseFeature(); + using var output = new MemoryStream(); + context.Features.Set(head); + context.Features.Set( + new Microsoft.AspNetCore.Http.StreamResponseBodyFeature(output)); + var hubField = typeof(WebEx).GetField("_componentHub", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + HttpServer server; + try + { + if (!hubAvailableAtConstruction) { hubField.SetValue(null, null); } + server = new HttpServer(fixture.Server); + } + finally + { + hubField.SetValue(null, fixture.Hub); + } + await server.ProcessRequestAsync(context); + Assert.Equal(200, head.StatusCode); + Assert.Contains(IdentityManager.AccessCookieName, head.Headers.SetCookie.ToString()); + Assert.Contains("Path=/api/auth/refresh", head.Headers.SetCookie.ToString()); + Assert.Contains("SameSite=Lax", head.Headers.SetCookie.ToString()); + Assert.DoesNotContain("session=", head.Headers.SetCookie.ToString()); + Assert.True(JsonDocument.Parse(output.ToArray()).RootElement.GetProperty("authenticated").GetBoolean()); + } + + /// + /// Local login, renewal, and logout use the same protected cookies and expose no credentials in JSON. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task LocalLoginRefreshAndLogoutUseTokenCookies() + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var request = fixture.Request("X-WebExpress-Auth: 1\r\n", "POST", "/api/auth/login", "{\"username\":\"alice\",\"password\":\"correct\"}"); + var login = await endpoint.HandleAsync(request); + Assert.Equal(200, login.Status); + Assert.True(JsonDocument.Parse((string)login.Content).RootElement.GetProperty("authenticated").GetBoolean()); + Assert.Null(request.ExistingSession); + Assert.Equal(2, login.Header.Cookies.Count); + var refresh = login.Header.Cookies[IdentityManager.RefreshCookieName].Value; + var access = login.Header.Cookies[IdentityManager.AccessCookieName].Value; + Assert.DoesNotContain(access, (string)login.Content); + Assert.DoesNotContain(refresh, (string)login.Content); + var renewed = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={refresh}\r\n", "POST", IdentityManager.RefreshPath)); + Assert.Equal(200, renewed.Status); + var rotated = renewed.Header.Cookies[IdentityManager.RefreshCookieName].Value; + Assert.NotEqual(refresh, rotated); + var logout = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={rotated}\r\n", "DELETE", IdentityManager.RefreshPath)); + Assert.Equal(204, logout.Status); + var revoked = await endpoint.HandleAsync(fixture.Request($"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.RefreshCookieName}={rotated}\r\n", "POST", IdentityManager.RefreshPath)); + Assert.Equal(401, revoked.Status); + } + + /// + /// Rejected credentials and browser requests cannot cause token issuance or leak password validation details. + /// + /// The HTTP headers required for the boundary scenario. + /// The HTTP method used to exercise the endpoint contract. + /// The payload serialized or supplied for the authentication request. + /// The expected HTTP status for the rejected boundary input. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData("", "POST", "{\"username\":\"alice\",\"password\":\"correct\"}", 403)] + [InlineData("X-WebExpress-Auth: 1\r\nOrigin: https://other.test\r\n", "POST", "{}", 403)] + [InlineData("X-WebExpress-Auth: 1\r\n", "GET", null, 405)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "not-json", 400)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "{\"username\":\"alice\",\"password\":\"wrong\"}", 401)] + [InlineData("X-WebExpress-Auth: 1\r\n", "POST", "{\"username\":\"missing\",\"password\":\"correct\"}", 401)] + public async Task InvalidLoginDoesNotIssueCredentials(string headers, string method, string body, int status) + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var response = await endpoint.HandleAsync(fixture.Request(headers, method, "/api/auth/login", body)); + Assert.Equal(status, response.Status); + Assert.Empty(response.Header.Cookies.Cast()); + Assert.Equal("no-store", response.Header.CacheControl); + } + + /// + /// PAT issuance and revocation require a browser identity and preserve the requested permission subset. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task PersonalCredentialEndpointRequiresOwner() + { + using var fixture = new AuthenticationFixture(); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + var owner = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var pair = fixture.Manager.Login(owner, fixture.Request()); + var headers = $"X-WebExpress-Auth: 1\r\nCookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + var response = await endpoint.HandleAsync(fixture.Request(headers, "POST", "/api/auth/pat", "{\"lifetimeSeconds\":3600,\"permissions\":[\"read\"]}")); + Assert.Equal(201, response.Status); + var token = JsonDocument.Parse((string)response.Content).RootElement.GetProperty("token").GetString(); + Assert.Equal(owner.Id, fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {token}\r\n")).Id); + var forbidden = await endpoint.HandleAsync(fixture.Request(headers, "POST", "/api/auth/pat", "{\"lifetimeSeconds\":3600,\"permissions\":[\"write\"]}")); + Assert.Equal(400, forbidden.Status); + var revoked = await endpoint.HandleAsync(fixture.Request(headers, "DELETE", "/api/auth/pat", JsonSerializer.Serialize(new { token }))); + Assert.Equal(204, revoked.Status); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {token}\r\n"))); + } + + /// + /// Exercises the production local password verifier with an isolated user directory. + /// + private sealed class PasswordProvider : LocalIdentityProvider + { + private readonly MockIdentity _user; + /// + /// Creates a salted password verifier for the local authentication boundary tests. + /// + internal PasswordProvider() + { + var hash = new PasswordHasher().HashPassword(null, "correct"); + _user = new MockIdentity(Guid.NewGuid(), "alice", "alice@example.test", hash); + } + /// + /// Supplies a salted local password verifier to exercise the real provider validation path. + /// + /// The identities supplied by the local directory, or an empty collection for external providers. + public override IEnumerable GetIdentities() => [_user]; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs new file mode 100644 index 00000000..e415068f --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs @@ -0,0 +1,222 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Security.Cryptography; +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises credential trust boundaries independently of any server-side identity session. + /// + [Collection("NonParallelTests")] + public class UnitTestIdentityAuthentication + { + /// + /// Proves that login cookies authenticate on another instance and contain no password verifier. + /// + [Fact] + public void LoginSurvivesInstanceChangeWithoutSession() + { + using var fixture = new AuthenticationFixture(); + var source = MockIdentityFactory.GetIdentity("Alice"); + var request = fixture.Request(); + var pair = fixture.Manager.Login(source, request); + Assert.Null(request.ExistingSession); + Assert.Null(fixture.Manager.Login(null, request)); + var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath)); + var identity = other.ValidateAccessToken(pair.AccessToken, fixture.Application.ApplicationId); + Assert.Equal(source.Id, identity.Id); + Assert.Equal(source.Roles, identity.Roles); + Assert.Null(identity.PasswordHash); + var payload = new JsonWebToken(pair.AccessToken).EncodedPayload; + Assert.DoesNotContain(source.PasswordHash, Base64UrlEncoder.Decode(payload)); + Assert.Contains(typeof(TestIdentityPermissionC).FullName, identity.Permissions); + Assert.True(fixture.Manager.CheckAccess(fixture.Application, identity, typeof(TestIdentityPermissionC))); + Assert.True(fixture.Manager.CheckAccess(identity, new TestIdentityPolicyA())); + + var response = new ResponseOK(); + fixture.Manager.ApplyAuthenticationCookies(request, response); + var access = response.Header.Cookies[IdentityManager.AccessCookieName]; + var refresh = response.Header.Cookies[IdentityManager.RefreshCookieName]; + Assert.True(access.Secure && access.HttpOnly && refresh.Secure && refresh.HttpOnly); + Assert.Equal("/", access.Path); + Assert.Equal("/api/auth/refresh", refresh.Path); + Assert.Equal("no-store", response.Header.CacheControl); + } + + /// + /// A session identifier, a refresh cookie, or a rejected explicit bearer credential cannot impersonate a user. + /// + [Fact] + public void RequestAuthenticationRejectsWrongCredentialChannels() + { + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), request); + var cookie = $"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"; + Assert.NotNull(fixture.Manager.GetCurrentIdentity(fixture.Request(cookie))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: session={Guid.NewGuid()}\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.RefreshToken}\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request(cookie + "Authorization: Bearer invalid\r\n"))); + Assert.Null(fixture.Manager.GetCurrentIdentity(fixture.Request($"Authorization: Bearer {pair.AccessToken}\r\n"))); + var anotherApplication = fixture.Request(cookie); + anotherApplication.ApplicationContext = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + Assert.Null(fixture.Manager.GetCurrentIdentity(anotherApplication)); + } + + /// + /// Signature, audience, issuer, expiration, and token purpose remain mandatory even when claims look plausible. + /// + [Fact] + public void TokenValidationEnforcesEveryTrustBoundary() + { + using var fixture = new AuthenticationFixture(); + var identity = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var app = fixture.Application.ApplicationId; + var pair = fixture.Tokens.Issue(identity, app); + var parts = pair.AccessToken.Split('.'); + parts[1] = Base64UrlEncoder.Encode(Base64UrlEncoder.Decode(parts[1]).Replace("alice", "admin")); + Assert.Null(fixture.Tokens.ValidateAccessToken(string.Join('.', parts), app)); + Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, "another-application")); + Assert.Null(fixture.Tokens.ValidateAccessToken(pair.RefreshToken, app)); + Assert.Null(fixture.Tokens.Refresh(pair.AccessToken, app)); + Assert.Null(fixture.Tokens.ValidateAccessToken("not.a.jwt", app)); + fixture.Settings.Issuer = "https://another-authority.test"; + var wrongIssuer = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + Assert.Null(wrongIssuer.ValidateAccessToken(pair.AccessToken, app)); + fixture.Settings.Issuer = "https://webexpress.test"; + fixture.Settings.SigningKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var wrongKey = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + Assert.Null(wrongKey.ValidateAccessToken(pair.AccessToken, app)); + fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, app)); + Assert.NotNull(fixture.Tokens.Refresh(pair.RefreshToken, app)); + } + + /// + /// Refresh replay revokes its successor across instances and renewal never moves the absolute grant deadline. + /// + [Fact] + public void RefreshRotatesOnceAndRetainsAbsoluteExpiry() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application.ApplicationId; + var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + fixture.Clock.Now += TimeSpan.FromMinutes(10); + var next = fixture.Tokens.Refresh(pair.RefreshToken, app); + Assert.NotEqual(pair.RefreshToken, next.RefreshToken); + Assert.Equal(pair.RefreshTokenExpiresAt.ToUnixTimeSeconds(), next.RefreshTokenExpiresAt.ToUnixTimeSeconds()); + var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + Assert.Null(other.Refresh(pair.RefreshToken, app)); + Assert.Null(fixture.Tokens.Refresh(next.RefreshToken, app)); + var fresh = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "bob"), app); + fixture.Clock.Now = fresh.RefreshTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Tokens.Refresh(fresh.RefreshToken, app)); + } + + /// + /// Exclusive marker creation permits only one winner when different nodes redeem the same refresh token. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task ConcurrentRefreshHasOnlyOneWinner() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application.ApplicationId; + var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => + new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock) + .Refresh(pair.RefreshToken, app)))); + Assert.Single(results, x => x is not null); + } + + /// + /// PAT permissions cannot grow through role or policy claims, renewal, or issuance of broader credentials. + /// + [Fact] + public void PersonalTokensAreScopedExpiringAndRevocable() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application.ApplicationId; + var owner = new Identity(Guid.NewGuid(), "alice", roles: ["admin"], permissions: ["read", "write"], policyNames: ["admin-policy"]); + var token = fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); + var restricted = fixture.Tokens.ValidatePersonalAccessToken(token, app); + Assert.Equal(["read"], restricted.Permissions); + Assert.Empty(restricted.Roles); + Assert.Empty(restricted.PolicyNames); + Assert.Null(fixture.Tokens.ValidateAccessToken(token, app)); + Assert.Null(fixture.Tokens.Refresh(token, app)); + Assert.Throws(() => fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["delete"])); + Assert.Throws(() => fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.Zero, ["read"])); + Assert.True(fixture.Tokens.RevokePersonalAccessToken(token, app)); + var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + Assert.Null(other.ValidatePersonalAccessToken(token, app)); + var expiring = fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromSeconds(1), ["read"]); + fixture.Clock.Now += TimeSpan.FromSeconds(2); + Assert.Null(fixture.Tokens.ValidatePersonalAccessToken(expiring, app)); + } + + /// + /// Logout removes browser credentials and prevents renewal without promising immediate access-token revocation. + /// + [Fact] + public void LogoutRevokesRenewalAndExpiresBothCookiePaths() + { + using var fixture = new AuthenticationFixture(); + var pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), fixture.Request()); + var request = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); + fixture.Manager.Logout(request); + Assert.Null(fixture.Manager.GetCurrentIdentity(request)); + Assert.Null(fixture.Tokens.Refresh(pair.RefreshToken, fixture.Application.ApplicationId)); + var response = new ResponseOK(); + fixture.Manager.ApplyAuthenticationCookies(request, response); + Assert.All(response.Header.Cookies.Cast(), x => Assert.True(x.Expires < DateTime.UtcNow)); + Assert.Equal(IdentityManager.RefreshPath, response.Header.Cookies[IdentityManager.RefreshCookieName].Path); + Assert.Null(request.ExistingSession); + } + + /// + /// An expired access credential can revoke its grant but cannot regain authorization by reaching logout. + /// + [Fact] + public void ExpiredAccessCanOnlyRevokeRenewal() + { + using var fixture = new AuthenticationFixture(); + var app = fixture.Application.ApplicationId; + var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); + Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, app)); + fixture.Tokens.RevokeGrant(pair.AccessToken, app); + Assert.Null(fixture.Tokens.Refresh(pair.RefreshToken, app)); + } + + /// + /// External policy mappings produce the same signed permission snapshot as local policy-bearing groups. + /// + [Fact] + public void MappedExternalPoliciesCaptureEffectivePermissions() + { + using var fixture = new AuthenticationFixture(); + var identity = new Identity(Guid.NewGuid(), "external", policyNames: [typeof(TestIdentityPolicyA).FullName]); + var pair = fixture.Manager.Login(identity, fixture.Request()); + var verified = fixture.Tokens.ValidateAccessToken(pair.AccessToken, fixture.Application.ApplicationId); + Assert.Contains(typeof(TestIdentityPermissionC).FullName, verified.Permissions); + } + + /// + /// Provider discovery and removal follow the plugin lifecycle instead of leaving stale authentication sources. + /// + [Fact] + public void ProviderLifecycleFollowsPluginRemoval() + { + using var fixture = new AuthenticationFixture(); + var providers = fixture.Hub.IdentityProviderManager.GetProviders(fixture.Application).ToArray(); + Assert.Contains(providers, x => x is MockIdentityProvider); + ((WebPlugin.PluginManager)fixture.Hub.PluginManager).Remove(fixture.Application.PluginContext); + Assert.Empty(fixture.Hub.IdentityProviderManager.GetProviders(fixture.Application)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs index e13ccb38..263204f6 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityManager.cs @@ -60,6 +60,10 @@ public void IsIComponentManager() /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The identity selected for the authorization scenario. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), "Alice", typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), "Alice", typeof(TestIdentityPermissionB), true)] @@ -88,6 +92,10 @@ public void CheckAccessIdentity(Type application, string identityName, Type perm /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The group selected for the authorization scenario. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), "Admins", typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), "Admins", typeof(TestIdentityPermissionB), true)] @@ -116,6 +124,10 @@ public void CheckAccessGroup(Type application, string groupName, Type permission /// /// Test the CheckAccess function of the identity manager. /// + /// The application whose permission bindings are evaluated. + /// The policy type whose permission binding is evaluated. + /// The required permission type. + /// The expected authorization result. [Theory] [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionA), true)] [InlineData(typeof(TestApplicationA), typeof(TestIdentityPolicyA), typeof(TestIdentityPermissionB), true)] @@ -137,161 +149,6 @@ public void CheckAccess(Type application, Type policy, Type permission, bool exp Assert.Equal(expected, access); } - /// - /// Test the Login function of the identity manager. - /// - [Theory] - [InlineData(null, false)] - [InlineData("Alice", true)] - [InlineData("Bob", true)] - public void Login(string identityName, bool expected) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - - // act - var res = identityManager.Login(identity, request); - - // validation - Assert.Equal(expected, res is not null); - } - - /// - /// Test the Login function of the identity manager. - /// - [Theory] - [InlineData("Alice")] - [InlineData("Bob")] - [InlineData("Charlie")] - public void Logout(string identityName) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - identityManager.Login(identity, request); - - // act - identityManager.Logout(request); - - // validation - var res = identityManager.GetCurrentIdentity(request); - Assert.Null(res); - } - - /// - /// Test the GetCurrentIdentity function of the identity manager. - /// - [Theory] - [InlineData("Alice")] - [InlineData("Bob")] - [InlineData("Charlie")] - public void GetCurrentIdentity(string identityName) - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity(identityName); - identityManager.Login(identity, request); - - // act - var res = identityManager.GetCurrentIdentity(request); - - // validation - Assert.Equal(identity, res); - } - - /// - /// Signing in must move the session to an id the client did not hold before: the - /// request keeps its session and identity, the new id resolves to the signed-in - /// session, and the id in use before the sign-in resolves to nothing. - /// - [Fact] - public void Login_ReplacesSessionId() - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity("Alice"); - var idBefore = request.Session.Id; - - // act - var session = identityManager.Login(identity, request); - - // validation - Assert.NotEqual(idBefore, session.Id); - Assert.Same(request.Session, session); - Assert.Equal(identity, identityManager.GetCurrentIdentity(request)); - - var withOldId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={idBefore}\n\n"); - Assert.Null(identityManager.GetCurrentIdentity(withOldId)); - - var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={session.Id}\n\n"); - Assert.Equal(identity, identityManager.GetCurrentIdentity(withNewId)); - } - - /// - /// The fixation scenario end to end: an attacker plants a session id in the victim's - /// browser, the victim signs in with it, and the attacker's requests carrying that id - /// must still see nobody signed in. - /// - [Fact] - public void Login_PlantedSessionId_DoesNotReachTheAttacker() - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var planted = Guid.NewGuid(); - var victim = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); - var identity = MockIdentityFactory.GetIdentity("Alice"); - - // act - var session = identityManager.Login(identity, victim); - - // validation - Assert.NotNull(session); - Assert.NotEqual(planted, session.Id); - - var attacker = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={planted}\n\n"); - Assert.Null(identityManager.GetCurrentIdentity(attacker)); - } - - /// - /// Signing out retires the id the session was signed in under: the request keeps its - /// (now anonymous) session, while a copy of the signed-in id resolves to nothing. - /// - [Fact] - public void Logout_ReplacesSessionId() - { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identityManager = componentHub.IdentityManager as IdentityManager; - var request = UnitTestFixture.CreateRequestMock(); - var identity = MockIdentityFactory.GetIdentity("Alice"); - var session = identityManager.Login(identity, request); - var signedInId = session.Id; - - // act - identityManager.Logout(request); - - // validation - Assert.NotEqual(signedInId, session.Id); - Assert.Same(request.Session, session); - Assert.Null(identityManager.GetCurrentIdentity(request)); - - var withSignedInId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={signedInId}\n\n"); - Assert.NotSame(session, componentHub.SessionManager.GetSession(withSignedInId)); - - var withNewId = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={session.Id}\n\n"); - Assert.Same(session, componentHub.SessionManager.GetSession(withNewId)); - } - /// /// Test that the IIdentityGroup interface has the Id and Name properties. /// @@ -325,7 +182,7 @@ public void RegisterIdentityProvider() provider.Identities.Add(identity); // act - identityManager.RegisterIdentityProvider(provider, applicationContext); + componentHub.IdentityProviderManager.Register(provider, applicationContext); var identities = identityManager.GetIdentities(applicationContext).ToList(); // validation @@ -352,14 +209,14 @@ public void UnregisterIdentityProvider() provider.Identities.Add(identity); - identityManager.RegisterIdentityProvider(provider, applicationContext); + componentHub.IdentityProviderManager.Register(provider, applicationContext); var identitiesBefore = identityManager.GetIdentities(applicationContext).ToList(); Assert.Contains(identity, identitiesBefore); Assert.Single(identitiesBefore); // act - var removed = identityManager.UnregisterIdentityProvider(provider, applicationContext); + var removed = componentHub.IdentityProviderManager.Unregister(provider, applicationContext); var identitiesAfter = identityManager.GetIdentities(applicationContext).ToList(); // validation diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs new file mode 100644 index 00000000..a30f2ac2 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs @@ -0,0 +1,212 @@ +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises real discovery, JWKS, code exchange, and JWT validation over a deterministic backchannel. + /// + [Collection("NonParallelTests")] + public class UnitTestOpenIdConnectIdentityProvider + { + /// + /// A plugin can map its verified external roles into the common identity without changing protocol validation. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task CodeFlowUsesPkceAndProducesCommonIdentity() + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel(); + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + var redirect = await provider.CreateChallengeAsync(fixture.Tokens, fixture.Application.ApplicationId); + var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); + Assert.Equal("code", parameters["response_type"]); + Assert.Equal("S256", parameters["code_challenge_method"]); + Assert.False(parameters.ContainsKey("code_verifier")); + var cookie = redirect.Header.Cookies[OpenIdConnectIdentityProvider.ChallengeCookieName]; + Assert.True(cookie.Secure && cookie.HttpOnly); + var challenge = new JsonWebToken(cookie.Value); + backchannel.Nonce = parameters["nonce"]; + var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", + $"/api/auth/callback?state={parameters["state"]}&code=single-use-code"); + var identity = await provider.AuthenticateCallbackAsync(callback, fixture.Tokens); + Assert.NotNull(identity); + Assert.Equal("alice", identity.Name); + Assert.Equal(["read", "write"], identity.Permissions.Order()); + Assert.DoesNotContain("unmapped", identity.Roles); + Assert.Equal(challenge.GetPayloadValue("verifier"), backchannel.Verifier); + Assert.Equal(parameters["code_challenge"], Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(backchannel.Verifier)))); + var pair = fixture.Manager.Login(identity, callback); + var authenticatedRequest = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); + Assert.Equal(identity.Id, fixture.Manager.GetCurrentIdentity(authenticatedRequest)?.Id); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Tokens)); + Assert.Equal(1, backchannel.Exchanges); + } + + /// + /// Invalid state is rejected before code exchange; untrusted signatures and claims never reach local issuance. + /// + /// The trust boundary deliberately violated by the simulated external response. + /// A task that completes after the asynchronous assertions have finished. + [Theory] + [InlineData("state")] + [InlineData("nonce")] + [InlineData("issuer")] + [InlineData("audience")] + [InlineData("signature")] + [InlineData("expired")] + [InlineData("azp")] + [InlineData("unsigned")] + public async Task InvalidExternalAuthenticationIsRejected(string failure) + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel { Failure = failure }; + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + var redirect = await provider.CreateChallengeAsync(fixture.Tokens, fixture.Application.ApplicationId); + var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); + backchannel.Nonce = parameters["nonce"]; + var cookie = redirect.Header.Cookies[OpenIdConnectIdentityProvider.ChallengeCookieName]; + var state = failure == "state" ? "attacker-state" : parameters["state"].ToString(); + var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", $"/api/auth/callback?state={state}&code=code"); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Tokens)); + Assert.Equal(failure == "state" ? 0 : 1, backchannel.Exchanges); + } + + /// + /// A callback cannot supply a bare ID token or exchange a code without the initiating browser cookie. + /// + /// A task that completes after the asynchronous assertions have finished. + [Fact] + public async Task MissingBrowserCorrelationNeverCallsTokenEndpoint() + { + using var fixture = new AuthenticationFixture(); + using var backchannel = new Backchannel(); + using var client = new HttpClient(backchannel); + using var provider = new PluginIdentityProvider(Settings(fixture), client); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?code=stolen&state=stolen"), fixture.Tokens)); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?id_token=jwt"), fixture.Tokens)); + Assert.Equal(0, backchannel.Exchanges); + } + + /// + /// Binds the simulated external provider to the application under test. + /// + /// The isolated authentication context used by the test. + /// The authority configuration bound to the application under test. + private static OpenIdConnectSettings Settings(AuthenticationFixture fixture) => new() + { + ProviderId = "external", Authority = "https://identity.test/realm", ClientId = "webexpress", + RedirectUri = $"https://webexpress.test/api/auth/callback?application={fixture.Application.ApplicationId}&provider=external", + RolePermissions = new() { ["reader"] = ["read"], ["editor"] = ["write"] } + }; + + /// + /// Demonstrates provider extension in a plugin without a vendor implementation in WebCore. + /// + private sealed class PluginIdentityProvider : OpenIdConnectIdentityProvider + { + /// + /// Reuses the generic code flow with plugin-specific configuration. + /// + /// The authority and role mappings trusted by this plugin. + /// The controlled transport for the protocol test. + internal PluginIdentityProvider(OpenIdConnectSettings settings, HttpClient client) : base(settings, client) { } + + /// + /// Translates the plugin's role claim after the common pipeline has validated the token. + /// + /// The verified external ID token supplied by the base provider. + /// The external role labels used by the configured local authorization mappings. + protected override IEnumerable ReadRoles(JsonWebToken token) + { + return token.TryGetPayloadValue("plugin_roles", out var roles) ? roles : []; + } + } + /// + /// Simulates an external authority while retaining real cryptographic token validation. + /// + private sealed class Backchannel : HttpMessageHandler + { + private readonly RSA _rsa = RSA.Create(2048); + internal string Nonce; + internal string Failure; + internal string Verifier; + internal int Exchanges; + + /// + /// Supplies protocol documents and a freshly signed ID token while preserving real cryptographic validation. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The cancellation token governing the simulated backchannel operation. + /// The simulated discovery, signing-key, or token response. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + object payload; + if (request.RequestUri.AbsolutePath.EndsWith("openid-configuration")) + { + payload = new + { + issuer = "https://identity.test/realm", authorization_endpoint = "https://identity.test/authorize", + token_endpoint = "https://identity.test/token", jwks_uri = "https://identity.test/keys" + }; + } + else if (request.RequestUri.AbsolutePath == "/keys") + { + var parameters = _rsa.ExportParameters(false); + payload = new { keys = new[] { new { kty = "RSA", kid = "test-key", use = "sig", alg = "RS256", + n = Base64UrlEncoder.Encode(parameters.Modulus), e = Base64UrlEncoder.Encode(parameters.Exponent) } } }; + } + else + { + Assert.Equal("/token", request.RequestUri.AbsolutePath); + Assert.Equal(HttpMethod.Post, request.Method); + var form = QueryHelpers.ParseQuery(await request.Content.ReadAsStringAsync(cancellationToken)); + Assert.Equal("authorization_code", form["grant_type"]); + Verifier = form["code_verifier"]; + Exchanges++; + using var wrongKey = RSA.Create(2048); + var key = new RsaSecurityKey(Failure == "signature" ? wrongKey : _rsa) { KeyId = "test-key" }; + var descriptor = new SecurityTokenDescriptor + { + Issuer = Failure == "issuer" ? "https://attacker.test" : "https://identity.test/realm", + Audience = Failure == "audience" ? "other-client" : "webexpress", + IssuedAt = DateTime.UtcNow.AddMinutes(-5), NotBefore = DateTime.UtcNow.AddMinutes(-5), + Expires = Failure == "expired" ? DateTime.UtcNow.AddMinutes(-1) : DateTime.UtcNow.AddMinutes(5), + SigningCredentials = Failure == "unsigned" ? null : new SigningCredentials(key, SecurityAlgorithms.RsaSha256), + Claims = new Dictionary + { + ["sub"] = "external-user", ["preferred_username"] = "alice", + ["nonce"] = Failure == "nonce" ? "attacker-nonce" : Nonce, + ["azp"] = Failure == "azp" ? "attacker-client" : "webexpress", + ["plugin_roles"] = new[] { "reader", "editor" }, + ["roles"] = new[] { "unmapped" } + } + }; + payload = new { id_token = new JsonWebTokenHandler().CreateToken(descriptor) }; + } + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") }; + } + + /// + /// Releases test signing material after each independent authorization flow. + /// + /// A value indicating whether managed test resources should be released. + protected override void Dispose(bool disposing) + { + if (disposing) { _rsa.Dispose(); } + base.Dispose(disposing); + } + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index d1a83eda..a4a3d4ba 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -17,6 +17,73 @@ public class UnitTestHttpServer // in between - the access check, the login prompt - decides the outcome private const string Endpoint = "/server/appa/api/2/testrestapib"; + /// + /// Prevents a missing HTTPS certificate from silently registering an unencrypted listener. + /// + /// The configured HTTPS spelling normalized by the URI parser. + [Theory] + [InlineData("https")] + [InlineData("HTTPS")] + public void HttpsWithoutCertificateNeverRegistersPlainHttpListener(string scheme) + { + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()); + var options = new Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions(); + var wrapper = new Microsoft.Extensions.Options.OptionsWrapper(options); + var endpoint = new EndpointSettings + { + Uri = $"{scheme}://localhost:5001/", + PfxFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pfx") + }; + var flags = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic; + var addEndpoint = typeof(HttpServer).GetMethod("AddEndpoint", flags, null, + [wrapper.GetType(), typeof(EndpointSettings), typeof(Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols?)], null); + + addEndpoint.Invoke(server, [wrapper, endpoint, null]); + + var listeners = typeof(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions) + .GetProperty("CodeBackedListenOptions", flags).GetValue(options); + Assert.Empty((System.Collections.IEnumerable)listeners); + } + + /// + /// Preserves normal and missing-route responses when startup constructs the server before its component hub. + /// + /// The application route whose response must survive the production startup order. + /// The expected response status after the component hub becomes available. + /// A task that completes after the HTTP response has been verified. + [Theory] + [InlineData(Endpoint, 400)] + [InlineData("/server/appa/no/such/route", 404)] + public async Task ProcessRequestAsync_ServerCreatedBeforeHub_PreservesResponse(string path, int status) + { + var hubField = typeof(WebEx).GetField("_componentHub", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); + var previousHub = WebEx.ComponentHub; + WebComponent.ComponentHub componentHub = null; + try + { + hubField.SetValue(null, null); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()); + componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + componentHub.SitemapManager.Refresh(); + var context = UnitTestFixture.CreateHttpContextMock($"GET {path} HTTP/1.1\r\nCookie:\r\n\r\n"); + var response = new HttpResponseFeature(); + using var output = new MemoryStream(); + context.Features.Set(response); + context.Features.Set(new StreamResponseBodyFeature(output)); + + await server.ProcessRequestAsync(context); + + Assert.Equal(status, response.StatusCode); + Assert.Null(((WebMessage.RequestBase)context.Request).ExistingSession); + Assert.DoesNotContain("session=", response.Headers.SetCookie.ToString()); + } + finally + { + componentHub?.IdentityProviderManager.Dispose(); + hubField.SetValue(null, previousHub); + } + } + /// /// Answers a request through the server and returns what the client would see of it. /// @@ -87,7 +154,7 @@ public async Task ProcessRequestAsync_NoSessionCookie_IssuesHttpOnlySessionCooki var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; // act - var (response, request) = await AnswerAsync(componentHub, content); + var (response, request) = await AnswerAsync(componentHub, content, r => _ = r.Session); // validation var setCookie = response.Headers.SetCookie.ToString(); @@ -117,7 +184,7 @@ public async Task ProcessRequestAsync_ConfigForcesSecure_MarksCookieSecure() var settings = new HttpServerSettings { Session = new SessionSettings { Secure = true } }; // act - var (response, _) = await AnswerAsync(componentHub, content, settings: settings); + var (response, _) = await AnswerAsync(componentHub, content, r => _ = r.Session, settings: settings); // validation Assert.Contains("Secure", response.Headers.SetCookie.ToString()); @@ -136,7 +203,7 @@ public async Task ProcessRequestAsync_TimeoutDisabled_IssuesSessionCookie() var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; // act - var (response, request) = await AnswerAsync(componentHub, content); + var (response, request) = await AnswerAsync(componentHub, content, r => _ = r.Session); // validation var setCookie = response.Headers.SetCookie.ToString(); @@ -158,7 +225,7 @@ public async Task ProcessRequestAsync_UnknownSessionCookie_IssuesServerSideId() var content = $"GET {Endpoint} HTTP/1.1\nCookie: session={planted}\n\n"; // act - var (response, request) = await AnswerAsync(componentHub, content); + var (response, request) = await AnswerAsync(componentHub, content, r => _ = r.Session); // validation var setCookie = response.Headers.SetCookie.ToString(); @@ -180,7 +247,7 @@ public async Task ProcessRequestAsync_KnownSessionCookie_SetsNoCookie() var content = $"GET {Endpoint} HTTP/1.1\nCookie: session={issued.Id}\n\n"; // act - var (response, request) = await AnswerAsync(componentHub, content); + var (response, request) = await AnswerAsync(componentHub, content, r => _ = r.Session); // validation Assert.Same(issued, request.Session); @@ -188,35 +255,27 @@ public async Task ProcessRequestAsync_KnownSessionCookie_SetsNoCookie() } /// - /// A client without a cookie signs in and gets back the id of the very session the - /// identity was bound to. The request creates its session before any handler runs and - /// the sign-in and the cookie must both refer to that one - were each to mint its own, - /// the client would come back with an id that never signed in. + /// Token cookies reach the wire even when login completes before a handler returns its response. /// [Fact] - public async Task ProcessRequestAsync_SignInWithoutCookie_CookieNamesTheSignedInSession() + public async Task ProcessRequestAsync_SignInIssuesTokenCookiesWithoutSession() { - // arrange - var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); - var identity = MockIdentityFactory.GetIdentity("Alice"); - var content = $"GET {Endpoint} HTTP/1.1\nCookie:\n\n"; - WebSession.Model.Session signedIn = null; - - // act - var (response, request) = await AnswerAsync(componentHub, content, r => + using var fixture = new AuthenticationFixture(); + var pair = default(WebIdentity.IdentityTokenPair); + var (response, request) = await AnswerAsync(fixture.Hub, $"GET {Endpoint} HTTP/1.1\nCookie:\n\n", r => { - signedIn = componentHub.IdentityManager.Login(identity, r); + ((WebMessage.RequestBase)r).ApplicationContext = fixture.Application; + pair = fixture.Manager.Login(MockIdentityFactory.GetIdentity("Alice"), r); }); - - // validation - Assert.NotNull(signedIn); - Assert.Same(signedIn, request.Session); - Assert.Contains($"session={signedIn.Id}", response.Headers.SetCookie.ToString()); - - var next = UnitTestFixture.CreateRequestMock($"GET / HTTP/1.1\nCookie: session={signedIn.Id}\n\n"); - Assert.Equal(identity, componentHub.IdentityManager.GetCurrentIdentity(next)); + var cookies = response.Headers.SetCookie.ToString(); + Assert.Contains($"{WebIdentity.IdentityManager.AccessCookieName}={pair.AccessToken}", cookies); + Assert.Contains($"{WebIdentity.IdentityManager.RefreshCookieName}={pair.RefreshToken}", cookies); + Assert.Contains("SameSite=Lax", cookies); + Assert.Contains("Secure", cookies); + Assert.Contains("HttpOnly", cookies); + Assert.DoesNotContain("session=", cookies); + Assert.Null(((WebMessage.RequestBase)request).ExistingSession); } - /// /// A request that never reaches a handler - here an unknown route - still hands the /// client its session id, since the login prompt and the redirect after a sign-in @@ -230,7 +289,7 @@ public async Task ProcessRequestAsync_UnknownRoute_StillIssuesSessionCookie() var content = $"GET /server/appa/no/such/route HTTP/1.1\nCookie: session={Guid.NewGuid()}\n\n"; // act - var (response, request) = await AnswerAsync(componentHub, content); + var (response, request) = await AnswerAsync(componentHub, content, r => _ = r.Session); // validation Assert.Equal(404, response.StatusCode); diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index e6b0953b..eb9fca85 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -18,8 +18,8 @@ using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Internationalization; -using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebIdentity; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; @@ -37,7 +37,7 @@ namespace WebExpress.WebCore /// public class HttpServer : IHost, IHttpApplication { - private static readonly IComponentHub _componentHub = WebEx.ComponentHub; + private readonly Lazy _authenticationEndpoint; /// /// Event is triggered after the web server is started. @@ -116,6 +116,9 @@ public HttpServer(IHttpServerContext context) ); Culture = HttpServerContext.Culture; + // webex creates the hub after this server because its managers require the server context + _authenticationEndpoint = new Lazy(() => + new AuthenticationEndpoint(WebEx.ComponentHub, HttpServerContext)); } /// @@ -251,7 +254,7 @@ private void AddEndpoint(OptionsWrapper serverOptions, End switch (uri.Scheme) { - case "HTTPS": + case "https": { AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password, protocols); break; @@ -321,6 +324,7 @@ public void Stop() // signal cancellation and stop server ServerTokenSource.Cancel(); Kestrel.StopAsync(ServerTokenSource.Token); + if (_authenticationEndpoint.IsValueCreated) { _authenticationEndpoint.Value.Dispose(); } } /// @@ -474,7 +478,7 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) /// The response about to be sent. private void IssueSessionCookie(IRequest request, IResponse response) { - var session = request?.Session; + var session = request is RequestBase concrete ? concrete.ExistingSession : request?.Session; var cookies = response?.Header?.Cookies; if (session is null || cookies is null) @@ -798,16 +802,17 @@ private async Task ProcessRequestCoreAsync(IHttpContext httpContext) var sender = new ResponseSender(); var stopwatch = Stopwatch.StartNew(); - // every response leaves through this local send, so the statistics are recorded - // here rather than inside the handler: a request that never reaches a handler - - // an unknown route, a denied or an unauthenticated one - produces a status code - // the monitor has to account for just the same. Recording precedes the send, so - // a slow client does not end up counted as a slow server. The session cookie is - // issued here for the same reason: the login prompt and a redirect after sign-in - // never reach the handler either, yet must carry the session id. + /* + * Applies pending authentication and optional session cookies even when routing bypasses a handler. + * Records statistics before transmission so client latency does not inflate server processing time. + * The context parameter identifies the HTTP exchange and its pending credential changes. + * The response parameter carries the result of routing, authentication, or an application handler. + * Returns a task that completes after the response has been sent. + */ async Task SendAsync(IHttpContext context, IResponse response) { IssueSessionCookie(context?.Request, response); + WebEx.ComponentHub.IdentityManager.ApplyAuthenticationCookies(context?.Request, response); UpdateStatistics(response, stopwatch.ElapsedMilliseconds); await sender.SendAsync(context, response); @@ -826,6 +831,13 @@ async Task SendAsync(IHttpContext context, IResponse response) return; } + var authenticationResponse = await _authenticationEndpoint.Value.HandleAsync(httpContext.Request); + if (authenticationResponse is not null) + { + await SendAsync(httpContext, authenticationResponse); + return; + } + var culture = httpContext?.Request?.Culture; var searchResult = WebEx.ComponentHub.SitemapManager.SearchResource(httpContext?.Uri, new SearchContext() { @@ -874,10 +886,10 @@ async Task SendAsync(IHttpContext context, IResponse response) return; } - var identity = _componentHub?.IdentityManager.GetCurrentIdentity(httpContext.Request); + var identity = WebEx.ComponentHub.IdentityManager.GetCurrentIdentity(httpContext.Request); // if access is granted - if (_componentHub.IdentityManager.CheckAccess(identity, searchResult.EndpointContext)) + if (WebEx.ComponentHub.IdentityManager.CheckAccess(identity, searchResult.EndpointContext)) { var response = HandleClient(httpContext, searchResult); await SendAsync(httpContext, response); @@ -890,7 +902,7 @@ async Task SendAsync(IHttpContext context, IResponse response) // if the user is authenticated but lacks the required permissions, show the forbidden page if (identity is not null && searchResult.EndpointContext is IPageContext) { - var forbiddenResponse = _componentHub?.IdentityManager.CreateForbiddenResponse + var forbiddenResponse = WebEx.ComponentHub.IdentityManager.CreateForbiddenResponse ( httpContext.Request, searchResult.EndpointContext as IPageContext, @@ -925,7 +937,7 @@ async Task SendAsync(IHttpContext context, IResponse response) else if (searchResult.EndpointContext is IPageContext pageContext) { // if the user is not authenticated, show the login prompt - var loginResponse = _componentHub?.IdentityManager.CreateAuthenticationPrompt + var loginResponse = WebEx.ComponentHub.IdentityManager.CreateAuthenticationPrompt ( httpContext.Request, searchResult.EndpointContext as IPageContext, @@ -1049,4 +1061,4 @@ private static bool IsWebSocketRequest(IHttpRequestFeature requestFeature) return isWebSocket; } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index deec77a6..70ca3c66 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -54,6 +54,7 @@ public class ComponentHub : IComponentHub private readonly JobManager _jobManager; private readonly TaskManager _taskManager; private readonly IdentityManager _identityManager; + private readonly IdentityProviderManager _identityProviderManager; private readonly SocketManager _socketManager; private readonly ThemeManager _themeManager; private int _lastCounter = 0; @@ -91,6 +92,7 @@ public class ComponentHub : IComponentHub _statusPageManager, _internationalizationManager, _identityManager, + _identityProviderManager, _sessionManager, _taskManager, _socketManager, @@ -211,6 +213,11 @@ public class ComponentHub : IComponentHub /// The instance of the identity manager. public IIdentityManager IdentityManager => _identityManager; + /// + /// Keeps provider discovery independent of credential issuance. + /// + public IIdentityProviderManager IdentityProviderManager => _identityProviderManager; + /// /// Gets the session manager. /// @@ -276,6 +283,8 @@ protected ComponentHub(IHttpServerContext httpServerContext) ?? throw new InvalidOperationException("Failed to create SessionManager."); _taskManager = CreateInstance(typeof(TaskManager)) as TaskManager ?? throw new InvalidOperationException("Failed to create TaskManager."); + _identityProviderManager = CreateInstance(typeof(IdentityProviderManager)) as IdentityProviderManager + ?? throw new InvalidOperationException("Failed to create IdentityProviderManager."); _identityManager = CreateInstance(typeof(IdentityManager)) as IdentityManager ?? throw new InvalidOperationException("Failed to create IdentityManager."); _socketManager = CreateInstance(typeof(SocketManager)) as SocketManager diff --git a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs index eabc06de..a5b450b6 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs @@ -159,6 +159,11 @@ public interface IComponentHub : IComponentManager /// The instance of the identity manager. IIdentityManager IdentityManager { get; } + /// + /// Provides application-scoped authentication sources. + /// + IIdentityProviderManager IdentityProviderManager { get; } + /// /// Gets the session manager. /// diff --git a/src/WebExpress.WebCore/WebExpress.WebCore.csproj b/src/WebExpress.WebCore/WebExpress.WebCore.csproj index 2fec11f6..3d1aa8d4 100644 --- a/src/WebExpress.WebCore/WebExpress.WebCore.csproj +++ b/src/WebExpress.WebCore/WebExpress.WebCore.csproj @@ -27,6 +27,11 @@ true + + + + + diff --git a/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs b/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs new file mode 100644 index 00000000..4abe83f2 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs @@ -0,0 +1,224 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.RateLimiting; +using System.Threading.Tasks; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebUri; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Provides one credential interface for local and external identities before normal application routing. + /// Unsafe browser operations require a same-origin custom header and never accept credentials in URLs. + /// + public sealed class AuthenticationEndpoint : IDisposable + { + private readonly IComponentHub _hub; + private readonly IHttpServerContext _server; + private readonly PartitionedRateLimiter _loginLimiter = PartitionedRateLimiter.Create( + key => RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, Window = TimeSpan.FromMinutes(1), AutoReplenishment = true, QueueLimit = 0 + })); + + /// + /// Connects endpoint handling to the same manager used by application login forms. + /// + /// The component hub that supplies application-scoped authentication services. + /// The server context that supplies deployment configuration and framework services. + public AuthenticationEndpoint(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _hub = componentHub; + _server = httpServerContext; + } + + /// + /// Returns null for ordinary routes so the server's normal routing remains authoritative. + /// Authentication errors receive stable responses that contain neither passwords nor tokens. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The authentication response, or null when normal application routing should handle the request. + public async Task HandleAsync(IRequest request) + { + var path = "/" + string.Join("/", request.Uri.PathSegments.Where(x => x is not UriPathSegmentRoot).Select(x => x.Value)); + if (!path.StartsWith("/api/auth/", StringComparison.Ordinal)) { return null; } + var response = await HandleCoreAsync(request, path); + response.Header.CacheControl = "no-store"; + response.Header.CustomHeader["Pragma"] = "no-cache"; + response.Header.CustomHeader["Referrer-Policy"] = "no-referrer"; + _hub.IdentityManager.ApplyAuthenticationCookies(request, response); + return response; + } + + /// + /// Enforces transport and browser trust boundaries before dispatching authentication operations. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The request route or cookie path that constrains credential use. + /// The response for the selected authentication operation. + private async Task HandleCoreAsync(IRequest request, string path) + { + var manager = (IdentityManager)_hub.IdentityManager; + if (manager.RequiresHttps && request.Scheme != UriScheme.Https) { return Error(new ResponseBadRequest(), "https_required"); } + var expectedMethod = path is "/api/auth/authorize" or "/api/auth/callback" ? RequestMethod.GET : RequestMethod.POST; + var isDelete = request.Method == RequestMethod.DELETE && path is IdentityManager.RefreshPath or "/api/auth/pat"; + if (request.Method != expectedMethod && !isDelete) + { + var method = Error(new ResponseMethodNotAllowed(), "method_not_allowed"); + method.Header.CustomHeader["Allow"] = expectedMethod.ToString(); + return method; + } + if (request.Method != RequestMethod.GET && + (request.Header.AuthenticationRequest != "1" || !SameOrigin(request))) + { + return Error(new ResponseForbidden(), "invalid_origin"); + } + var applicationId = OpenIdConnectIdentityProvider.Query(request, "application") ?? + _server.Configuration["WebExpress:Authentication:ApplicationId"]; + var application = _hub.ApplicationManager.Applications.SingleOrDefault(x => x.ApplicationId == applicationId); + if (application is null || request is not RequestBase concrete) { return Error(new ResponseBadRequest(), "unknown_application"); } + concrete.ApplicationContext = application; + if (manager.Tokens is null) { return Error(new ResponseServiceUnavailable(), "authentication_not_configured"); } + + try + { + switch (path) + { + case "/api/auth/login": + using (var lease = _loginLimiter.AttemptAcquire((request.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "unknown")) + { + if (!lease.IsAcquired) { return Error(new ResponseTooManyRequests(), "try_again_later"); } + using var body = ReadBody(request); + var username = body.RootElement.GetProperty("username").GetString(); + var password = body.RootElement.GetProperty("password").GetString(); + var identity = _hub.IdentityProviderManager.GetProviders(application).OfType() + .Select(x => x.Authenticate(username, password)).FirstOrDefault(x => x is not null); + if (identity is null) { return Unauthorized(); } + return SignedIn(manager.Login(identity, request)); + } + case IdentityManager.RefreshPath: + if (isDelete) { manager.Logout(request); return new ResponseNoContent(); } + var pair = manager.Refresh(request); + return pair is null ? Unauthorized() : SignedIn(pair); + case "/api/auth/logout": + manager.Logout(request); + return new ResponseNoContent(); + case "/api/auth/authorize": + case "/api/auth/callback": + var providerId = OpenIdConnectIdentityProvider.Query(request, "provider"); + var providers = _hub.IdentityProviderManager.GetProviders(application).OfType() + .Where(x => x.ProviderId == providerId).ToArray(); + if (providers.Length != 1) { return Error(new ResponseBadRequest(), "unknown_provider"); } + if (path == "/api/auth/authorize") { return await providers[0].CreateChallengeAsync(manager.Tokens, applicationId); } + var external = await providers[0].AuthenticateCallbackAsync(request, manager.Tokens); + var callback = external is null ? Unauthorized() : SignedIn(manager.Login(external, request)); + callback.Header.Cookies.Add(IdentityManager.CreateCookie(OpenIdConnectIdentityProvider.ChallengeCookieName, + null, "/api/auth/callback", null)); + return callback; + case "/api/auth/pat": + var owner = manager.GetCurrentIdentity(request); + if (owner is null || request.Header.Authorization is not null) { return Unauthorized(); } + using (var body = ReadBody(request)) + { + if (isDelete) + { + var token = body.RootElement.GetProperty("token").GetString(); + if (manager.Tokens.ValidatePersonalAccessToken(token, applicationId)?.Id != owner.Id) { return Unauthorized(); } + manager.RevokePersonalAccessToken(token, application); + return new ResponseNoContent(); + } + var permissions = body.RootElement.GetProperty("permissions").EnumerateArray().Select(x => x.GetString()).ToArray(); + var lifetime = body.RootElement.GetProperty("lifetimeSeconds").GetInt32(); + var personal = manager.CreatePersonalAccessToken(owner, application, TimeSpan.FromSeconds(lifetime), permissions); + return Json(new ResponseCreated(), new { token = personal }); + } + default: + return Error(new ResponseNotFound(), "unknown_authentication_endpoint"); + } + } + catch (Exception exception) when (exception is JsonException or ArgumentException or InvalidOperationException or + System.Collections.Generic.KeyNotFoundException or FormatException) + { + return Error(new ResponseBadRequest(), "invalid_authentication_request"); + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + return Error(new ResponseServiceUnavailable(), "identity_provider_unavailable"); + } + } + + /// + /// Bounds credential input and accepts only explicitly declared JSON payloads. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The parsed and size-bounded JSON document owned by the caller. + private static JsonDocument ReadBody(IRequest request) + { + if (request is not Request concrete || concrete.Content is null || concrete.Content.Length > 16384 || + !string.Equals(request.Header.ContentType?.Split(';')[0].Trim(), "application/json", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("An application/json body is required."); + } + return JsonDocument.Parse(concrete.Content); + } + + /// + /// Rejects browser origins that could otherwise mutate another site's authentication cookies. + /// + /// The HTTP request whose authentication context is being evaluated. + /// True when no browser origin is supplied or the origin matches the request; otherwise, false. + private static bool SameOrigin(IRequest request) + { + if (string.IsNullOrEmpty(request.Header.Origin)) { return true; } + return Uri.TryCreate(request.Header.Origin, UriKind.Absolute, out var origin) && + Uri.TryCreate(request.Uri.ToString(), UriKind.Absolute, out var target) && + origin.GetLeftPart(UriPartial.Authority) == target.GetLeftPart(UriPartial.Authority); + } + + /// + /// Exposes authentication status while keeping both browser credentials in protected cookies. + /// + /// The issued credentials whose public expiration metadata is returned. + /// The JSON response containing status and expiration metadata only. + private static IResponse SignedIn(IdentityTokenPair pair) => Json(new ResponseOK(), new { authenticated = true, expiresAt = pair.AccessTokenExpiresAt }); + /// + /// Uses a uniform failure response that does not disclose account or provider details. + /// + /// The uniform unauthorized response without a Basic authentication challenge. + private static IResponse Unauthorized() + { + var response = new ResponseUnauthorized(); + response.Header.WWWAuthenticate = false; + return Error(response, "authentication_failed"); + } + /// + /// Keeps authentication failures machine-readable without exposing internal exceptions. + /// + /// The outgoing response that must obey the authentication transport contract. + /// The stable error identifier exposed to the client. + /// The response carrying the stable JSON error contract. + private static IResponse Error(IResponse response, string error) => Json(response, new { error }); + /// + /// Applies a consistent JSON content type and serialization contract to authentication responses. + /// + /// The outgoing response that must obey the authentication transport contract. + /// The payload serialized or supplied for the authentication request. + /// The response containing the serialized JSON payload. + private static IResponse Json(IResponse response, object body) + { + response.Header.ContentType = "application/json; charset=utf-8"; + response.Content = JsonSerializer.Serialize(body); + return response; + } + + /// + /// Releases limiter timers when the hosting server shuts down. + /// + public void Dispose() => _loginLimiter.Dispose(); + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/FileIdentityTokenStore.cs b/src/WebExpress.WebCore/WebIdentity/FileIdentityTokenStore.cs new file mode 100644 index 00000000..6bb5265d --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/FileIdentityTokenStore.cs @@ -0,0 +1,83 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Stores only replay and revocation markers; no identity or session is kept on the server. + /// Multiple instances must share a filesystem that implements atomic exclusive file creation. + /// + public sealed class FileIdentityTokenStore : IIdentityTokenStore + { + private readonly string _directory; + + /// + /// Requires an explicit durable location to avoid silently losing revocations on restart. + /// + /// The durable shared directory used for replay and revocation markers. + public FileIdentityTokenStore(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + _directory = Path.GetFullPath(directory); + Directory.CreateDirectory(_directory); + } + + /// + /// Uses exclusive file creation to let only one instance redeem a credential. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// The deadline until which the replay or revocation marker must be retained. + /// True when this operation created the first durable marker; otherwise, false. + public bool TryConsume(string tokenId, DateTimeOffset expiresAt) + { + var path = MarkerPath(tokenId); + try + { + using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read); + using var writer = new StreamWriter(stream); + writer.Write(expiresAt.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); + return true; + } + catch (IOException) when (File.Exists(path)) + { + return false; + } + } + + /// + /// Retains a durable denial marker after a credential or grant is revoked. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// The deadline until which the replay or revocation marker must be retained. + public void Revoke(string tokenId, DateTimeOffset expiresAt) => TryConsume(tokenId, expiresAt); + + /// + /// Checks durable markers before refresh or personal credentials can be used. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// True when a durable denial marker exists; otherwise, false. + public bool IsRevoked(string tokenId) + { + try + { + File.GetAttributes(MarkerPath(tokenId)); + return true; + } + catch (FileNotFoundException) { return false; } + } + + /// + /// Hashes credential identifiers so storage paths cannot reveal raw identifiers or contain caller-controlled paths. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// The path of the hashed replay or revocation marker. + private string MarkerPath(string tokenId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tokenId); + return Path.Combine(_directory, Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(tokenId))) + ".token"); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentity.cs b/src/WebExpress.WebCore/WebIdentity/IIdentity.cs index 9c7b7026..ec4f9d0f 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentity.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentity.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace WebExpress.WebCore.WebIdentity { @@ -32,5 +33,21 @@ public interface IIdentity /// Gets the groups associated with the identity. /// IEnumerable Groups { get; } + + /// + /// Preserves provider role names without requiring the provider on subsequent requests. + /// + IEnumerable Roles => (Groups ?? []).Select(x => x.Name); + + /// + /// Carries explicit permission identifiers in credential-free authorization snapshots. + /// + IEnumerable Permissions => []; + + /// + /// Preserves policy identifiers without deserializing executable CLR types from a token. + /// + IEnumerable PolicyNames => (Groups ?? []).SelectMany(x => x.Policies ?? []) + .Select(x => x.GetType().FullName); } } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs index 77536f24..ea012cdd 100644 --- a/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityManager.cs @@ -5,7 +5,6 @@ using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; -using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.WebIdentity { @@ -65,28 +64,51 @@ public interface IIdentityManager : IComponentManager IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity); /// - /// Login an identity. + /// Issues a provider-independent token pair and queues its protected response cookies. /// - /// - /// The session keeps its state but gets a new id, so an id the client held before the - /// sign-in never names the signed-in session (session fixation). The client learns the - /// new id from the cookie sent with the response; a caller must not hand it out itself. - /// - /// The identity. - /// The request. - /// The session of the logged-in identity, or null if the login process failed. - Session Login(IIdentity identity, IRequest request); + /// The verified identity whose authorization snapshot is being processed. + /// The HTTP request whose authentication context is being evaluated. + /// The issued token pair, or null when no identity was authenticated. + IdentityTokenPair Login(IIdentity identity, IRequest request); /// - /// Logout an identity. + /// Clears authentication cookies and revokes renewal for the current login grant. /// - /// - /// The mirror image of the sign-in: the identity is dropped and the session gets a new - /// id, so an id captured while it was signed in resolves to nothing afterwards. - /// - /// The request. + /// The HTTP request whose authentication context is being evaluated. void Logout(IRequest request); + /// + /// Rotates the refresh cookie at the dedicated endpoint without creating a session. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The rotated token pair, or null when validation or replay protection rejects renewal. + IdentityTokenPair Refresh(IRequest request); + + /// + /// Issues a bounded bearer credential with a subset of the owner's permissions. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application context that owns the requested operation. + /// The explicit validity period requested for the personal credential. + /// The requested permission identifiers, limited to the owner's grants. + /// The signed personal credential with the requested authorized permissions. + string CreatePersonalAccessToken(IIdentity identity, IApplicationContext applicationContext, TimeSpan lifetime, IEnumerable permissions); + + /// + /// Revokes a personal credential on every instance sharing the durable token store. + /// + /// The serialized credential that must pass the required trust checks. + /// The application context that owns the requested operation. + /// True when a valid personal credential was revoked; otherwise, false. + bool RevokePersonalAccessToken(string token, IApplicationContext applicationContext); + + /// + /// Delivers login, renewal, or logout cookie changes on the actual HTTP response. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The outgoing response governed by the authentication transport contract. + void ApplyAuthenticationCookies(IRequest request, IResponse response); + /// /// Returns the current signed-in identity based on the provided request. /// @@ -176,37 +198,6 @@ bool CheckAccess(IApplicationContext appli /// True if the identity policy has the permission, false otherwise. bool CheckAccess(IApplicationContext applicationContext, Type policy, Type permission); - /// - /// Registers an identity provider for use within the application context. - /// - /// - /// The identity provider to register. Cannot be null. - /// - /// - /// The application context in which the identity provider will be used. - /// - /// - /// Thrown if identityProvider or applicationContext is null. - /// - void RegisterIdentityProvider(IIdentityProvider identityProvider, IApplicationContext applicationContext); - - /// - /// Unregisters a previously registered identity provider from the given application context. - /// - /// - /// The identity provider to unregister. Cannot be null. - /// - /// - /// The application context from which the identity provider will be removed. - /// - /// - /// Thrown if identityProvider or applicationContext is null. - /// - /// - /// True if the provider was successfully removed; false if it was not registered. - /// - bool UnregisterIdentityProvider(IIdentityProvider identityProvider, IApplicationContext applicationContext); - /// /// Retrieves all available identities from the configured identity providers for the specified application /// context. diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityProviderManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityProviderManager.cs new file mode 100644 index 00000000..390407e1 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityProviderManager.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Keeps authentication sources scoped to the application and plugin that own them. + /// + public interface IIdentityProviderManager : IComponentManager + { + /// + /// Returns a snapshot so a request can safely enumerate providers during plugin changes. + /// + /// The application context that owns the requested authentication operation. + /// A stable snapshot of the application's registered authentication sources. + IEnumerable GetProviders(IApplicationContext applicationContext); + /// + /// Allows application-specific provider configuration in addition to plugin discovery. + /// + /// The authentication source being associated with an application. + /// The application context that owns the requested authentication operation. + void Register(IIdentityProvider provider, IApplicationContext applicationContext); + /// + /// Removes an explicitly registered source from subsequent authentication attempts. + /// + /// The authentication source being associated with an application. + /// The application context that owns the requested authentication operation. + /// True when a provider binding was removed; otherwise, false. + bool Unregister(IIdentityProvider provider, IApplicationContext applicationContext); + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStore.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStore.cs new file mode 100644 index 00000000..a7770578 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStore.cs @@ -0,0 +1,31 @@ +using System; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Isolates durable replay protection from stateless access-token verification. + /// Implementations must coordinate atomic consumption across every server instance. + /// + public interface IIdentityTokenStore + { + /// + /// Records a token identifier exactly once so concurrent refreshes cannot both succeed. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// The deadline until which the replay or revocation marker must be retained. + /// True when this operation created the first durable marker; otherwise, false. + bool TryConsume(string tokenId, DateTimeOffset expiresAt); + /// + /// Retains a revocation at least until the credential can no longer be accepted. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// The deadline until which the replay or revocation marker must be retained. + void Revoke(string tokenId, DateTimeOffset expiresAt); + /// + /// Fails a revoked credential before any identity is granted to its caller. + /// + /// The unique credential or grant identifier whose durable marker is accessed. + /// True when a durable denial marker exists; otherwise, false. + bool IsRevoked(string tokenId); + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/Identity.cs b/src/WebExpress.WebCore/WebIdentity/Identity.cs new file mode 100644 index 00000000..7d21760e --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/Identity.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Carries a credential-free authorization snapshot across provider and token boundaries. + /// + public sealed class Identity : IIdentity + { + /// + /// Provides the stable subject used to bind signed claims to one user. + /// + public Guid Id { get; } + + /// + /// Preserves the display name independently of the originating provider. + /// + public string Name { get; } + + /// + /// Preserves the contact claim without requiring a provider lookup. + /// + public string Email { get; } + + /// + /// Keeps password verifiers out of identities reconstructed from tokens. + /// + public string PasswordHash => null; + + /// + /// Avoids rebuilding executable policy objects from serialized claims. + /// + public IEnumerable Groups => []; + + /// + /// Preserves the trusted role labels captured during authentication. + /// + public IEnumerable Roles { get; } + + /// + /// Limits authorization to permission identifiers captured during authentication. + /// + public IEnumerable Permissions { get; } + + /// + /// Preserves local policy identifiers without loading types named by a token. + /// + public IEnumerable PolicyNames { get; } + + /// + /// Copies trusted claims so provider mutations cannot change an issued identity. + /// + /// The stable subject identifier supplied by the authenticated identity source. + /// The display name supplied by the authenticated identity source. + /// The optional contact claim captured from the authenticated source. + /// The trusted role labels captured during authentication. + /// The effective permission identifiers granted by the trusted identity source. + /// The full local policy names granted to the authenticated identity. + public Identity(Guid id, string name, string email = null, IEnumerable roles = null, + IEnumerable permissions = null, IEnumerable policyNames = null) + { + if (id == Guid.Empty) { throw new ArgumentException("An identity needs a stable subject.", nameof(id)); } + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Id = id; + Name = name; + Email = email; + Roles = Array.AsReadOnly((roles ?? []).Distinct(StringComparer.Ordinal).ToArray()); + Permissions = Array.AsReadOnly((permissions ?? []).Distinct(StringComparer.Ordinal).ToArray()); + PolicyNames = Array.AsReadOnly((policyNames ?? []).Distinct(StringComparer.Ordinal).ToArray()); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs new file mode 100644 index 00000000..b5ab6ea1 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs @@ -0,0 +1,246 @@ +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Runtime.CompilerServices; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Connects credential issuance and request verification without retaining an identity in session state. + /// + public partial class IdentityManager + { + /// + /// Prevents insecure origins and sibling domains from planting an authentication cookie. + /// + public const string AccessCookieName = "__Host-wx-access"; + + /// + /// Allows a narrow cookie path while retaining the browser's Secure-prefix protection. + /// + public const string RefreshCookieName = "__Secure-wx-refresh"; + internal const string DevelopmentAccessCookieName = "wx-access"; + internal const string DevelopmentRefreshCookieName = "wx-refresh"; + + /// + /// Keeps secure transport mandatory unless the deployment explicitly opts into development HTTP. + /// + internal bool RequiresHttps => _httpServerContext.Configuration.GetValue("WebExpress:Authentication:RequireHttps", true); + + /// + /// Separates development cookies from the browser-enforced production cookie namespace. + /// + private string AccessCookie => RequiresHttps ? AccessCookieName : DevelopmentAccessCookieName; + + /// + /// Keeps development renewal credentials outside the protected production cookie namespace. + /// + private string RefreshCookie => RequiresHttps ? RefreshCookieName : DevelopmentRefreshCookieName; + /// + /// Keeps refresh credentials out of ordinary application requests. + /// + public const string RefreshPath = "/api/auth/refresh"; + + private readonly ConditionalWeakTable _authenticationStates = new(); + private readonly object _tokenGate = new(); + private IdentityTokenService _tokens; + + /// + /// Keeps pending authentication changes local to one HTTP request. + /// + private sealed class AuthenticationState + { + internal IIdentity Identity; + internal IdentityTokenPair Pair; + internal bool Changed; + } + + /// + /// Gets the token service, creating it if necessary. + /// + internal IdentityTokenService Tokens + { + get + { + lock (_tokenGate) + { + if (_tokens is not null) { return _tokens; } + var settings = _httpServerContext.Configuration.GetSection("WebExpress:Authentication").Get(); + if (settings is null) { return null; } + _tokens = new IdentityTokenService(settings, new FileIdentityTokenStore(settings.TokenStorePath)); + return _tokens; + } + } + } + + /// + /// Converts a verified identity into a credential-free snapshot and queues protected token cookies. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The HTTP request whose authentication context is being evaluated. + /// The issued token pair, or null when no identity was authenticated. + public IdentityTokenPair Login(IIdentity identity, IRequest request) + { + if (identity is null) { return null; } + ArgumentNullException.ThrowIfNull(request); + var snapshot = Snapshot(identity, request.ApplicationContext); + var pair = RequireTokens().Issue(snapshot, request.ApplicationContext.ApplicationId); + var state = _authenticationStates.GetOrCreateValue(request); + state.Identity = snapshot; + state.Pair = pair; + state.Changed = true; + return pair; + } + + /// + /// Rotates the refresh credential without extending the original grant or creating a session. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The rotated token pair, or null when validation or replay protection rejects renewal. + public IdentityTokenPair Refresh(IRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var pair = RequireTokens().Refresh(CookieValue(request, RefreshCookie), request.ApplicationContext?.ApplicationId); + if (pair is null) { return null; } + var state = _authenticationStates.GetOrCreateValue(request); + state.Identity = Tokens.ValidateAccessToken(pair.AccessToken, request.ApplicationContext.ApplicationId); + state.Pair = pair; + state.Changed = true; + return pair; + } + + /// + /// Clears browser credentials and revokes future renewal while issued access tokens expire naturally. + /// + /// The HTTP request whose authentication context is being evaluated. + public void Logout(IRequest request) + { + ArgumentNullException.ThrowIfNull(request); + Tokens?.RevokeGrant(CookieValue(request, AccessCookie), request.ApplicationContext?.ApplicationId); + Tokens?.RevokeRefreshGrant(CookieValue(request, RefreshCookie), request.ApplicationContext?.ApplicationId); + var state = _authenticationStates.GetOrCreateValue(request); + state.Identity = null; + state.Pair = null; + state.Changed = true; + } + + /// + /// Authenticates each request from a signed cookie or an explicitly supplied personal bearer credential. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The verified request identity, or null when no accepted credential authenticates it. + public IIdentity GetCurrentIdentity(IRequest request) + { + if (request?.ApplicationContext is null) { return null; } + if (_authenticationStates.TryGetValue(request, out var state)) { return state.Identity; } + var authorization = request.Header.Authorization; + // an explicit, invalid authorization header must never fall back to browser credentials + if (authorization is not null) + { + return string.Equals(authorization.Type, "Bearer", StringComparison.OrdinalIgnoreCase) + ? Tokens?.ValidatePersonalAccessToken(authorization.Token, request.ApplicationContext.ApplicationId) : null; + } + var cookie = CookieValue(request, AccessCookie); + return cookie is null ? null : Tokens?.ValidateAccessToken(cookie, request.ApplicationContext.ApplicationId); + } + + /// + /// Creates an explicitly bounded credential whose permissions cannot exceed the owning identity. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application context that owns the requested operation. + /// The explicit validity period requested for the personal credential. + /// The requested permission identifiers, limited to the owner's grants. + /// The signed personal credential with the requested authorized permissions. + public string CreatePersonalAccessToken(IIdentity identity, IApplicationContext applicationContext, + TimeSpan lifetime, IEnumerable permissions) + { + return RequireTokens().CreatePersonalAccessToken(Snapshot(identity, applicationContext), + applicationContext.ApplicationId, lifetime, permissions); + } + + /// + /// Persists revocation so the personal credential stops working across all configured instances. + /// + /// The serialized credential that must pass the required trust checks. + /// The application context that owns the requested operation. + /// True when a valid personal credential was revoked; otherwise, false. + public bool RevokePersonalAccessToken(string token, IApplicationContext applicationContext) + { + return RequireTokens().RevokePersonalAccessToken(token, applicationContext.ApplicationId); + } + + /// + /// Applies queued credential changes to the outgoing response while keeping tokens out of its body. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The outgoing response governed by the authentication transport contract. + public void ApplyAuthenticationCookies(IRequest request, IResponse response) + { + if (request is null || response is null || !_authenticationStates.TryGetValue(request, out var state) || !state.Changed) { return; } + response.Header.CacheControl = "no-store"; + // an expired access credential still identifies the grant for logout; validation always enforces its signed expiry + response.Header.Cookies.Add(CreateCookie(AccessCookie, state.Pair?.AccessToken, "/", state.Pair?.RefreshTokenExpiresAt, RequiresHttps)); + response.Header.Cookies.Add(CreateCookie(RefreshCookie, state.Pair?.RefreshToken, RefreshPath, state.Pair?.RefreshTokenExpiresAt, RequiresHttps)); + } + + /// + /// Applies protected cookie attributes and an expired deadline when removing a credential. + /// + /// The exact cookie or claim name to inspect. + /// The value being validated or placed in the protected response. + /// The route or cookie path that constrains credential use. + /// The cookie deadline, or null when the cookie must be deleted. + /// Whether the browser must restrict the cookie to HTTPS transport. + /// The protected authentication cookie or its deletion counterpart. + internal static Cookie CreateCookie(string name, string value, string path, DateTimeOffset? expiration, bool secure = true) + { + return new Cookie(name, value ?? "", path) + { + Secure = secure, + HttpOnly = true, + Expires = expiration?.UtcDateTime ?? DateTime.UnixEpoch + }; + } + + /// + /// Rejects duplicate credentials so browser cookie ordering cannot determine the authenticated identity. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The exact cookie or claim name to inspect. + /// The matching cookie value, or null when absent or ambiguous. + internal static string CookieValue(IRequest request, string name) + { + var cookies = request.Header.Cookies.Where(x => x.Name == name).ToArray(); + return cookies.Length == 1 ? cookies[0].Value : null; + } + + /// + /// Prevents authentication from silently falling back to ephemeral signing configuration. + /// + /// The token service configured for this deployment. + private IdentityTokenService RequireTokens() => Tokens ?? throw new InvalidOperationException("Configure WebExpress:Authentication before signing in."); + + /// + /// Captures effective authorization before mutable provider state crosses the token boundary. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application whose provider bindings or authorization definitions apply. + /// The immutable identity containing the effective authorization claims. + private Identity Snapshot(IIdentity identity, IApplicationContext application) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(application); + var permissions = identity.Permissions.Concat(Permissions.Where(x => x.ApplicationContext == application && + (CheckAccess(application, identity, x.Permission) || + identity.PolicyNames.Any(policy => CheckAccess(application, policy, x.Permission)))) + .Select(x => x.Permission.FullName)); + return new Identity(identity.Id, identity.Name, identity.Email, identity.Roles, permissions, identity.PolicyNames); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs index f6493012..b14d21dc 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.cs @@ -14,20 +14,18 @@ using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebPlugin; -using WebExpress.WebCore.WebSession.Model; namespace WebExpress.WebCore.WebIdentity { /// /// Management of identities (users). /// - public class IdentityManager : IIdentityManager + public partial class IdentityManager : IIdentityManager { private readonly IComponentHub _componentHub; private readonly IHttpServerContext _httpServerContext; private readonly IdentityPermissionDictionary _permissionDictionary = []; private readonly IdentityPolicyDictionary _policyDictionary = []; - private readonly Dictionary> _identityProviders = []; /// /// Gets all permissions. @@ -388,9 +386,9 @@ private void OnAddApplication(object sender, IApplicationContext e) /// public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity = null) { - if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) + if (initiator?.ApplicationContext is not null) { - foreach (var provider in list) + foreach (var provider in GetProviders(initiator.ApplicationContext)) { var response = provider.CreateAuthenticationPrompt(request, initiator, identity); @@ -418,9 +416,9 @@ public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initi /// public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) { - if (_identityProviders.TryGetValue(initiator?.ApplicationContext, out var list)) + if (initiator?.ApplicationContext is not null) { - foreach (var provider in list) + foreach (var provider in GetProviders(initiator.ApplicationContext)) { var response = provider.CreateForbiddenResponse(request, initiator, identity); @@ -435,72 +433,6 @@ public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiato return null; } - /// - /// Login an identity. - /// - /// - /// The session keeps its state but gets a new id, so the id the client signed in under - - /// one an attacker may have planted in the browser or observed on the wire - names - /// nothing once the identity is bound (session fixation). - /// - /// The identity. - /// The request. - /// The session of the logged-in identity, or null if the login process failed. - public Session Login(IIdentity identity, IRequest request) - { - if (identity is null) - { - return null; - } - - var session = _componentHub?.SessionManager.GetSession(request); - - // the id is replaced before the identity is bound, so the identity never lives - // under an id the client chose - _componentHub?.SessionManager.RegenerateId(session); - - var authentification = session.GetOrCreateProperty(identity); - - // verify that the identity was correctly bound to the session - if (authentification.Identity != identity) - { - return null; - } - - return session; - } - - /// - /// Logout an identity. - /// - /// - /// The session keeps its state but gets a new id, the mirror image of the sign-in: a - /// copy of the id taken while the session was signed in - from a log, a leaked header, - /// a shared machine - resolves to nothing afterwards, not even to the anonymous - /// remainder of the session. - /// - /// The request. - public void Logout(IRequest request) - { - var session = _componentHub?.SessionManager.GetSession(request); - session.RemoveProperty(); - - _componentHub?.SessionManager.RegenerateId(session); - } - - /// - /// Returns the current signed-in identity based on the provided request. - /// - /// The request to get the current identity for. - /// The current signed-in identity. - public IIdentity GetCurrentIdentity(IRequest request) - { - var session = _componentHub?.SessionManager.GetSession(request); - var authentification = session.GetProperty(); - - return authentification?.Identity; - } - /// /// Checks whether the specified identity satisfies all policies associated with the given endpoint context. /// @@ -527,8 +459,7 @@ public bool CheckAccess(IIdentity identity, IIdentityPolicy policy) return false; } - // evaluate all associated groups using linq - return identity.Groups?.Any(group => CheckAccess(group, policy)) ?? false; + return policy is WebPolicies.AuthenticatedAccessPolicy || identity.PolicyNames.Contains(policy.GetType().FullName, StringComparer.Ordinal); } /// @@ -573,7 +504,8 @@ public bool CheckAccess(IApplicationContext applicationContext, IIdentity identi { var groups = identity?.Groups ?? []; - return groups.Any(group => CheckAccess(applicationContext, group, permission)); + return identity?.Permissions.Contains(permission.FullName, StringComparer.Ordinal) == true || + groups.Any(group => CheckAccess(applicationContext, group, permission)); } /// @@ -701,46 +633,6 @@ public static string ComputeHash(SecureString input) } } - /// - /// Registers an identity provider for use within the application context. - /// - /// The identity provider to register. Cannot be null. - /// The application context in which the identity provider will be used. - /// Thrown if identityProvider or applicationContext is null. - public void RegisterIdentityProvider(IIdentityProvider identityProvider, IApplicationContext applicationContext) - { - ArgumentNullException.ThrowIfNull(identityProvider); - ArgumentNullException.ThrowIfNull(applicationContext); - - if (!_identityProviders.TryGetValue(applicationContext, out var list)) - { - list = []; - _identityProviders[applicationContext] = list; - } - - list.Add(identityProvider); - } - - /// - /// Unregisters a previously registered identity provider from the given application context. - /// - /// The identity provider to unregister. Cannot be null. - /// The application context from which the identity provider will be removed. - /// Thrown if identityProvider or applicationContext is null. - /// True if the provider was successfully removed; false if it was not registered. - public bool UnregisterIdentityProvider(IIdentityProvider identityProvider, IApplicationContext applicationContext) - { - ArgumentNullException.ThrowIfNull(identityProvider); - ArgumentNullException.ThrowIfNull(applicationContext); - - if (_identityProviders.TryGetValue(applicationContext, out var list)) - { - return list.Remove(identityProvider); - } - - return false; - } - /// /// Retrieves all available identities from the configured identity providers for the specified /// application context. @@ -787,12 +679,7 @@ public IEnumerable GetGroups(IApplicationContext applicationCont /// private IEnumerable GetProviders(IApplicationContext applicationContext) { - if (_identityProviders.TryGetValue(applicationContext, out var list)) - { - return list; - } - - return []; + return _componentHub.IdentityProviderManager.GetProviders(applicationContext); } /// @@ -803,4 +690,4 @@ public void Dispose() GC.SuppressFinalize(this); } } -} \ No newline at end of file +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs new file mode 100644 index 00000000..e1504c03 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityProviderManager.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Binds provider lifetimes to plugin and application events instead of the identity login path. + /// + public sealed class IdentityProviderManager : IIdentityProviderManager + { + private readonly IComponentHub _hub; + private readonly IHttpServerContext _server; + private readonly object _gate = new(); + private readonly List _entries = []; + + /// + /// Associates a provider instance with the application and plugin responsible for its lifetime. + /// + /// The authentication source owned by this registration. + /// The application whose requests may use this source. + /// The plugin whose removal ends this registration. + private sealed record Entry(IIdentityProvider Provider, IApplicationContext Application, IPluginContext Plugin); + + /// + /// Connects authentication source ownership to plugin and application lifecycle events. + /// + /// The hub supplying application-scoped authentication services. + /// The server context supplying deployment configuration and framework services. + private IdentityProviderManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _hub = componentHub; + _server = httpServerContext; + _hub.PluginManager.AddPlugin += OnAddPlugin; + _hub.PluginManager.RemovePlugin += OnRemovePlugin; + _hub.ApplicationManager.AddApplication += OnAddApplication; + _hub.ApplicationManager.RemoveApplication += OnRemoveApplication; + } + + /// + /// Returns an application-scoped snapshot that tolerates concurrent plugin changes. + /// + /// The application context that owns the requested operation. + /// A snapshot of authentication sources registered for the application. + public IEnumerable GetProviders(IApplicationContext applicationContext) + { + lock (_gate) { return _entries.Where(x => x.Application == applicationContext).Select(x => x.Provider).ToArray(); } + } + + /// + /// Registers a configured source with its owning plugin so removal also removes authentication access. + /// + /// The authentication source associated with an application. + /// The application context that owns the requested operation. + public void Register(IIdentityProvider provider, IApplicationContext applicationContext) + { + ArgumentNullException.ThrowIfNull(provider); + ArgumentNullException.ThrowIfNull(applicationContext); + var plugin = _hub.PluginManager.GetPlugins(applicationContext) + .FirstOrDefault(x => x.Assembly == provider.GetType().Assembly) ?? applicationContext.PluginContext; + lock (_gate) + { + if (!_entries.Any(x => x.Application == applicationContext && ReferenceEquals(x.Provider, provider))) + { + _entries.Add(new Entry(provider, applicationContext, plugin)); + } + } + } + + /// + /// Removes an explicitly configured source from subsequent authentication attempts. + /// + /// The authentication source associated with an application. + /// The application context that owns the requested operation. + /// True when a registered provider binding was removed; otherwise, false. + public bool Unregister(IIdentityProvider provider, IApplicationContext applicationContext) + { + lock (_gate) { return _entries.RemoveAll(x => x.Application == applicationContext && ReferenceEquals(x.Provider, provider)) > 0; } + } + + /// + /// Discovers sources for applications associated with a newly available plugin. + /// + /// The manager that raised the lifecycle event. + /// The plugin whose authentication source bindings are affected. + private void OnAddPlugin(object sender, IPluginContext plugin) + { + foreach (var application in _hub.ApplicationManager.GetApplications(plugin)) { Discover(plugin, application); } + } + + /// + /// Discovers sources when an application becomes available after its plugins. + /// + /// The manager that raised the lifecycle event. + /// The application whose provider bindings or authorization definitions apply. + private void OnAddApplication(object sender, IApplicationContext application) + { + foreach (var plugin in _hub.PluginManager.GetPlugins(application)) { Discover(plugin, application); } + } + + /// + /// Constructs each provider once per application using framework-supplied dependencies. + /// + /// The plugin whose authentication source bindings are affected. + /// The application whose provider bindings or authorization definitions apply. + private void Discover(IPluginContext plugin, IApplicationContext application) + { + foreach (var type in plugin.Assembly.GetExportedTypes().Where(x => x.IsClass && !x.IsAbstract && + !x.ContainsGenericParameters && typeof(IIdentityProvider).IsAssignableFrom(x))) + { + lock (_gate) + { + if (_entries.Any(x => x.Plugin == plugin && x.Application == application && x.Provider.GetType() == type)) { continue; } + var dependencies = new object[] { _hub, _server, application, plugin }; + var constructor = type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .OrderByDescending(x => x.GetParameters().Length) + .FirstOrDefault(x => x.GetParameters().All(p => dependencies.Any(p.ParameterType.IsInstanceOfType))); + if (constructor is null) { continue; } + var provider = (IIdentityProvider)constructor.Invoke(constructor.GetParameters() + .Select(p => dependencies.First(p.ParameterType.IsInstanceOfType)).ToArray()); + _entries.Add(new Entry(provider, application, plugin)); + } + } + } + + /// + /// Removes authentication sources whose owning plugin is no longer available. + /// + /// The manager that raised the lifecycle event. + /// The plugin whose authentication source bindings are affected. + private void OnRemovePlugin(object sender, IPluginContext plugin) + { + Remove(x => x.Plugin == plugin); + } + + /// + /// Removes provider bindings that no longer have an owning application. + /// + /// The manager that raised the lifecycle event. + /// The application whose provider bindings or authorization definitions apply. + private void OnRemoveApplication(object sender, IApplicationContext application) + { + Remove(x => x.Application == application); + } + + /// + /// Disposes a removed source only when its final application binding disappears. + /// + /// The ownership condition selecting provider bindings for removal. + private void Remove(Predicate predicate) + { + lock (_gate) + { + var removed = _entries.FindAll(predicate); + _entries.RemoveAll(predicate); + foreach (var provider in removed.Select(x => x.Provider).Distinct().OfType()) + { + if (!_entries.Any(x => ReferenceEquals(x.Provider, provider))) { provider.Dispose(); } + } + } + } + + /// + /// Detaches lifecycle subscriptions and releases providers when the manager is shut down. + /// + public void Dispose() + { + _hub.PluginManager.AddPlugin -= OnAddPlugin; + _hub.PluginManager.RemovePlugin -= OnRemovePlugin; + _hub.ApplicationManager.AddApplication -= OnAddApplication; + _hub.ApplicationManager.RemoveApplication -= OnRemoveApplication; + Remove(_ => true); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityTokenPair.cs b/src/WebExpress.WebCore/WebIdentity/IdentityTokenPair.cs new file mode 100644 index 00000000..da649568 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityTokenPair.cs @@ -0,0 +1,30 @@ +using System; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Transfers newly issued credentials to the HTTP-only cookie writer, never to a JSON response. + /// + public sealed class IdentityTokenPair + { + /// + /// Authorizes requests until the short-lived snapshot expires. + /// + public string AccessToken { get; internal init; } + + /// + /// Permits one renewal at the dedicated refresh endpoint. + /// + public string RefreshToken { get; internal init; } + + /// + /// Tells clients when the signed authorization expires and renewal is required. + /// + public DateTimeOffset AccessTokenExpiresAt { get; internal init; } + + /// + /// Keeps the refresh cookie within the original login grant's deadline. + /// + public DateTimeOffset RefreshTokenExpiresAt { get; internal init; } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs b/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs new file mode 100644 index 00000000..b99b7e4d --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs @@ -0,0 +1,337 @@ +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Enforces disjoint trust rules for access, refresh, and personal credentials. + /// Access verification needs only the shared signing configuration and the signed claims. + /// + public sealed class IdentityTokenService + { + private readonly JsonWebTokenHandler _handler = new() { MaximumTokenSizeInBytes = 16384 }; + private readonly SigningCredentials _signing; + private readonly string _issuer; + private readonly string _audience; + private readonly TimeSpan _accessLifetime; + private readonly TimeSpan _refreshLifetime; + private readonly TimeSpan _patLifetime; + private readonly IIdentityTokenStore _store; + private readonly TimeProvider _time; + + /// + /// Rejects missing or weak deployment secrets instead of falling back to per-process keys. + /// + /// The configured trust boundary and credential lifetime constraints. + /// The durable store shared by instances that consume or revoke credentials. + /// The UTC clock used to enforce signed expiration boundaries. + public IdentityTokenService(AuthenticationSettings settings, IIdentityTokenStore store, TimeProvider timeProvider = null) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.Issuer); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.Audience); + var key = Convert.FromBase64String(settings.SigningKey ?? ""); + if (key.Length < 32) { throw new ArgumentException("Authentication requires at least 256 random signing-key bits."); } + if (settings.AccessTokenLifetime < TimeSpan.FromSeconds(1) || + settings.RefreshTokenLifetime <= settings.AccessTokenLifetime || + settings.MaximumPersonalAccessTokenLifetime < TimeSpan.FromSeconds(1)) + { + throw new ArgumentException("Authentication token lifetimes are invalid."); + } + _signing = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); + _issuer = settings.Issuer; + _audience = settings.Audience; + _accessLifetime = settings.AccessTokenLifetime; + _refreshLifetime = settings.RefreshTokenLifetime; + _patLifetime = settings.MaximumPersonalAccessTokenLifetime; + _store = store; + _time = timeProvider ?? TimeProvider.System; + } + + /// + /// Gives every authentication source the same short-lived authorization snapshot. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application identifier that scopes token audiences and authentication. + /// The signed access and refresh credentials for the new login grant. + public IdentityTokenPair Issue(IIdentity identity, string applicationId) + { + return IssuePair(identity, applicationId, Guid.NewGuid().ToString("N"), _time.GetUtcNow() + _refreshLifetime); + } + + /// + /// Accepts access credentials only, preventing refresh or personal tokens from entering through cookies. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// The verified access identity, or null when validation fails. + public IIdentity ValidateAccessToken(string token, string applicationId) + { + return ReadIdentity(Validate(token, applicationId, "access")); + } + + /// + /// Restricts bearer credentials to explicitly created, unrevoked personal tokens. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// The verified personal identity, or null when validation or revocation checks fail. + public IIdentity ValidatePersonalAccessToken(string token, string applicationId) + { + var jwt = Validate(token, applicationId, "pat"); + return jwt is not null && !_store.IsRevoked("pat:" + jwt.Id) ? ReadIdentity(jwt) : null; + } + + /// + /// Rotates each refresh credential once and revokes the entire grant when it is replayed. + /// The original grant deadline is retained so renewal cannot make a login immortal. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// The rotated token pair, or null when validation or replay protection rejects renewal. + public IdentityTokenPair Refresh(string token, string applicationId) + { + var jwt = Validate(token, applicationId, "refresh"); + var identity = ReadIdentity(jwt); + if (identity is null || !jwt.TryGetPayloadValue("grant", out var grant) || + string.IsNullOrEmpty(grant) || _store.IsRevoked("grant:" + grant)) { return null; } + var expires = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero); + if (!_store.TryConsume("refresh:" + jwt.Id, expires)) + { + _store.Revoke("grant:" + grant, expires); + return null; + } + return IssuePair(identity, applicationId, grant, expires); + } + + /// + /// Creates an explicitly bounded credential and intersects its permissions with its owner's grants. + /// Policies and roles are omitted so they cannot bypass a PAT's permission restriction. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application identifier that scopes token audiences and authentication. + /// The explicit validity period requested for the personal credential. + /// The requested permission identifiers, limited to the owner's grants. + /// The signed personal credential with the requested authorized permissions. + public string CreatePersonalAccessToken(IIdentity identity, string applicationId, TimeSpan lifetime, + IEnumerable permissions) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(permissions); + if (lifetime < TimeSpan.FromSeconds(1) || lifetime > _patLifetime) { throw new ArgumentOutOfRangeException(nameof(lifetime)); } + var requested = permissions.Distinct(StringComparer.Ordinal).ToArray(); + if (requested.Except(identity.Permissions, StringComparer.Ordinal).Any()) + { + throw new ArgumentException("A personal token cannot exceed its owner's permissions.", nameof(permissions)); + } + var restricted = new Identity(identity.Id, identity.Name, identity.Email, permissions: requested); + return Create(restricted, applicationId, "pat", _time.GetUtcNow() + lifetime); + } + + /// + /// Prevents a personal credential from authenticating on any instance sharing the token store. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// True when a valid personal credential was revoked; otherwise, false. + public bool RevokePersonalAccessToken(string token, string applicationId) + { + var jwt = Validate(token, applicationId, "pat"); + if (jwt is null) { return false; } + _store.Revoke("pat:" + jwt.Id, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); + return true; + } + + /// + /// Ends renewal using the grant reference in an access cookie, even when the refresh cookie's + /// narrow path keeps it out of the logout request. Existing access tokens expire naturally. + /// + /// The signed access credential identifying the login grant to revoke. + /// The application identifier that scopes token audiences and authentication. + public void RevokeGrant(string accessToken, string applicationId) + { + var jwt = Validate(accessToken, applicationId, "access", validateLifetime: false); + if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant) && + jwt.TryGetPayloadValue("grant_exp", out var expiration)) + { + _store.Revoke("grant:" + grant, DateTimeOffset.FromUnixTimeSeconds(expiration)); + } + } + + /// + /// Signs correlation data with a purpose that cannot authorize application requests. + /// + /// The trusted claims bound into the signed credential. + /// The application identifier that scopes token audiences and authentication. + /// The signed browser correlation credential. + internal string ProtectChallenge(Dictionary claims, string applicationId) + { + return CreateToken(claims, applicationId, "challenge", _time.GetUtcNow() + TimeSpan.FromMinutes(5)); + } + + /// + /// Accepts only unexpired browser correlation credentials for the selected application. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// The verified correlation token, or null when validation fails. + internal JsonWebToken ValidateChallenge(string token, string applicationId) => Validate(token, applicationId, "challenge"); + + /// + /// Prevents different server instances from exchanging a code with the same challenge. + /// + /// The verified browser correlation token that must be consumed exactly once. + /// True for the first successful consumption; otherwise, false. + internal bool ConsumeChallenge(JsonWebToken challenge) => _store.TryConsume("challenge:" + challenge.Id, + new DateTimeOffset(challenge.ValidTo, TimeSpan.Zero)); + + /// + /// Allows logout at the refresh cookie path to end the original login grant. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + internal void RevokeRefreshGrant(string token, string applicationId) + { + var jwt = Validate(token, applicationId, "refresh"); + if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant)) + { + _store.Revoke("grant:" + grant, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); + } + } + + /// + /// Keeps both credentials inside the absolute grant deadline and browser cookie size limits. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application identifier that scopes token audiences and authentication. + /// The identifier of the original login grant. + /// The absolute deadline beyond which the login grant cannot be renewed. + /// The signed token pair constrained by the absolute grant deadline. + private IdentityTokenPair IssuePair(IIdentity identity, string applicationId, string grant, DateTimeOffset deadline) + { + ArgumentNullException.ThrowIfNull(identity); + var accessDeadline = _time.GetUtcNow() + _accessLifetime; + if (accessDeadline > deadline) { accessDeadline = deadline; } + var pair = new IdentityTokenPair + { + AccessToken = Create(identity, applicationId, "access", accessDeadline, grant, deadline), + RefreshToken = Create(identity, applicationId, "refresh", deadline, grant, deadline), + AccessTokenExpiresAt = accessDeadline, + RefreshTokenExpiresAt = deadline + }; + if (pair.AccessToken.Length > 3800 || pair.RefreshToken.Length > 3800) + { + throw new InvalidOperationException("Identity claims exceed the authentication cookie limit."); + } + return pair; + } + + /// + /// Copies only credential-free identity claims into a purpose-specific payload. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application identifier that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// The signed expiration deadline of the credential. + /// The identifier of the original login grant. + /// The expiration deadline shared by credentials from one login grant. + /// The signed credential containing the trusted identity snapshot. + private string Create(IIdentity identity, string applicationId, string purpose, DateTimeOffset expires, + string grant = null, DateTimeOffset? grantExpires = null) + { + var claims = new Dictionary + { + ["sub"] = identity.Id.ToString(), ["name"] = identity.Name, + ["roles"] = identity.Roles.ToArray(), ["permissions"] = identity.Permissions.ToArray(), + ["policies"] = identity.PolicyNames.ToArray() + }; + if (identity.Email is not null) { claims["email"] = identity.Email; } + if (grant is not null) { claims["grant"] = grant; claims["grant_exp"] = grantExpires.Value.ToUnixTimeSeconds(); } + return CreateToken(claims, applicationId, purpose, expires); + } + + /// + /// Binds claims to their signing authority, application, purpose, and expiration. + /// + /// The trusted claims bound into the signed credential. + /// The application identifier that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// The signed expiration deadline of the credential. + /// The signed JWT bound to the deployment trust boundary. + private string CreateToken(Dictionary claims, string applicationId, string purpose, DateTimeOffset expires) + { + ArgumentException.ThrowIfNullOrWhiteSpace(applicationId); + claims["jti"] = Guid.NewGuid().ToString("N"); + claims["token_use"] = purpose; + return _handler.CreateToken(new SecurityTokenDescriptor + { + Issuer = _issuer, Audience = _audience + "/" + applicationId, + IssuedAt = _time.GetUtcNow().UtcDateTime, NotBefore = _time.GetUtcNow().UtcDateTime, + Expires = expires.UtcDateTime, Claims = claims, SigningCredentials = _signing, + TokenType = "wx-" + purpose + "+jwt" + }); + } + + /// + /// Enforces the complete token trust boundary before any claims are consumed. + /// + /// The serialized credential that must pass the required trust checks. + /// The application identifier that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// A value indicating whether this operation must enforce expiration. + /// The verified JWT, or null when any required trust check fails. + private JsonWebToken Validate(string token, string applicationId, string purpose, bool validateLifetime = true) + { + if (string.IsNullOrEmpty(token) || token.Length > 16384 || string.IsNullOrEmpty(applicationId)) { return null; } + var result = _handler.ValidateTokenAsync(token, new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = _issuer, + ValidateAudience = true, ValidAudience = _audience + "/" + applicationId, + ValidateIssuerSigningKey = true, IssuerSigningKey = _signing.Key, + RequireSignedTokens = true, RequireExpirationTime = true, + ValidAlgorithms = [SecurityAlgorithms.HmacSha256], ValidTypes = ["wx-" + purpose + "+jwt"], + ValidateLifetime = validateLifetime, ClockSkew = TimeSpan.Zero, + LifetimeValidator = validateLifetime ? (notBefore, expires, _, _) => + notBefore.HasValue && expires.HasValue && notBefore <= _time.GetUtcNow().UtcDateTime && + expires > _time.GetUtcNow().UtcDateTime && notBefore < expires : null + }).GetAwaiter().GetResult(); + if (!result.IsValid || result.SecurityToken is not JsonWebToken jwt || string.IsNullOrEmpty(jwt.Id) || + !jwt.TryGetPayloadValue("token_use", out var use) || use != purpose) { return null; } + return jwt; + } + + /// + /// Rejects incomplete subjects before constructing a credential-free identity. + /// + /// The verified JWT from which credential-free claims are read. + /// The immutable identity snapshot, or null when required claims are absent. + private static IIdentity ReadIdentity(JsonWebToken jwt) + { + if (jwt is null || !Guid.TryParse(jwt.Subject, out var subject) || subject == Guid.Empty || + !jwt.TryGetPayloadValue("name", out var name) || string.IsNullOrWhiteSpace(name)) { return null; } + try + { + jwt.TryGetPayloadValue("email", out var email); + return new Identity(subject, name, email, ReadArray(jwt, "roles"), ReadArray(jwt, "permissions"), ReadArray(jwt, "policies")); + } + catch (JsonException) { return null; } + } + + /// + /// Reads authorization labels without constructing executable types from serialized claims. + /// + /// The verified JWT from which credential-free claims are read. + /// The exact cookie or claim name to inspect. + /// The claim values, or an empty array when the claim is absent. + private static string[] ReadArray(JsonWebToken jwt, string name) + { + return jwt.TryGetPayloadValue(name, out var values) ? values : []; + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/LocalIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/LocalIdentityProvider.cs new file mode 100644 index 00000000..3fcc20bb --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/LocalIdentityProvider.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Identity; +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Adapts a local user directory to the common login pipeline using salted password hashes. + /// Applications override the directory queries and may override authentication for their password store. + /// + public abstract class LocalIdentityProvider : IIdentityProvider + { + private readonly PasswordHasher _hasher = new(); + private readonly string _dummyHash = new PasswordHasher().HashPassword(null, Guid.NewGuid().ToString()); + + /// + /// Verifies credentials before any identity is allowed into central token issuance. + /// Missing users still incur password hashing work to reduce username timing disclosure. + /// + /// The submitted account name to resolve through the local directory. + /// The password to verify before returning an authenticated identity. + /// The verified local identity, or null when the credentials are rejected. + public virtual IIdentity Authenticate(string username, string password) + { + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password) || username.Length > 256 || password.Length > 4096) { return null; } + var identity = GetIdentities().FirstOrDefault(x => string.Equals(x.Name, username, StringComparison.OrdinalIgnoreCase)); + try + { + var result = _hasher.VerifyHashedPassword(identity, identity?.PasswordHash ?? _dummyHash, password); + return result != PasswordVerificationResult.Failed ? identity : null; + } + catch (FormatException) { return null; } + } + + /// + /// Supplies the local user directory whose password verifiers this provider is trusted to check. + /// + /// The local directory identities whose credentials this provider can verify. + public abstract IEnumerable GetIdentities(); + /// + /// Allows applications to expose local role groups alongside their user directory. + /// + /// The local directory groups, or an empty collection when no directory is exposed. + public virtual IEnumerable GetGroups() => []; + /// + /// Leaves interactive login presentation to the application unless a provider overrides it. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The protected page that initiated authentication or denied access. + /// The verified identity whose authorization snapshot is being processed. + /// The provider response, or null when the application should choose the login presentation. + public virtual IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity) => null; + /// + /// Leaves permission-denied presentation to the application unless a provider overrides it. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The protected page that initiated authentication or denied access. + /// The verified identity whose authorization snapshot is being processed. + /// The provider response, or null when the application should choose the denied-access presentation. + public virtual IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) => null; + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs new file mode 100644 index 00000000..19b0de13 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs @@ -0,0 +1,292 @@ +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Completes authorization-code authentication before translating verified external claims into + /// WebExpress identities. State, nonce, and PKCE are bound to a signed HTTP-only browser cookie. + /// + public abstract class OpenIdConnectIdentityProvider : IIdentityProvider, IDisposable + { + internal const string ChallengeCookieName = "__Secure-wx-oidc"; + private readonly OpenIdConnectSettings _settings; + private readonly HttpClient _client; + private readonly bool _ownsClient; + private readonly ConfigurationManager _configuration; + private readonly JsonWebTokenHandler _handler = new() { MaximumTokenSizeInBytes = 16384 }; + + /// + /// Exposes the configured selection key without exposing client secrets to callers. + /// + public string ProviderId => _settings.ProviderId; + + /// + /// Pins HTTPS endpoints and uses cached discovery with signing-key refresh for key rotation. + /// An injected client permits controlled backchannel transport and deterministic integration tests. + /// + /// The authority, client credentials, redirect URI, and local authorization mappings. + /// The optional HTTP transport used for discovery and code exchange. + protected OpenIdConnectIdentityProvider(OpenIdConnectSettings settings, HttpClient client = null) + { + ArgumentNullException.ThrowIfNull(settings); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.ProviderId); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.ClientId); + RequireHttps(settings.Authority); + RequireHttps(settings.RedirectUri); + var redirect = new Uri(settings.RedirectUri); + if (redirect.AbsolutePath != "/api/auth/callback" || !string.IsNullOrEmpty(redirect.Fragment)) + { + throw new ArgumentException("The OIDC redirect must use /api/auth/callback without a fragment."); + } + _settings = new OpenIdConnectSettings + { + ProviderId = settings.ProviderId, Authority = settings.Authority, ClientId = settings.ClientId, + ClientSecret = settings.ClientSecret, RedirectUri = settings.RedirectUri, + RolePermissions = settings.RolePermissions.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.Ordinal), + RolePolicies = settings.RolePolicies.ToDictionary(x => x.Key, x => x.Value.ToArray(), StringComparer.Ordinal) + }; + _ownsClient = client is null; + _client = client ?? new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(15) }; + _configuration = new ConfigurationManager( + settings.Authority.TrimEnd('/') + "/.well-known/openid-configuration", + new OpenIdConnectConfigurationRetriever(), new HttpDocumentRetriever(_client) { RequireHttps = true }); + } + + /// + /// Starts a code flow whose verifier remains in the initiating browser's protected cookie. + /// + /// The shared token service used to bind browser correlation to the local signing authority. + /// The application identifier that scopes token audiences and authentication. + /// A redirect response with a protected browser correlation cookie. + public async Task CreateChallengeAsync(IdentityTokenService tokens, string applicationId) + { + var redirectQuery = QueryHelpers.ParseQuery(new Uri(_settings.RedirectUri).Query); + if (redirectQuery["application"].Count != 1 || redirectQuery["application"] != applicationId || + redirectQuery["provider"].Count != 1 || redirectQuery["provider"] != ProviderId) + { + throw new InvalidOperationException("The registered callback must identify its application and provider."); + } + var configuration = await GetConfigurationAsync(); + var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); + var nonce = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); + var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); + var challenge = tokens.ProtectChallenge(new Dictionary + { + ["state"] = state, ["nonce"] = nonce, ["verifier"] = verifier, ["provider"] = ProviderId + }, applicationId); + var response = new ResponseMovedTemporarily(); + response.Header.Location = QueryHelpers.AddQueryString(configuration.AuthorizationEndpoint, new Dictionary + { + ["client_id"] = _settings.ClientId, ["redirect_uri"] = _settings.RedirectUri, + ["response_type"] = "code", ["scope"] = "openid profile email", ["response_mode"] = "query", + ["state"] = state, ["nonce"] = nonce, ["code_challenge_method"] = "S256", + ["code_challenge"] = Base64UrlEncoder.Encode(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))) + }); + response.Header.Cookies.Add(IdentityManager.CreateCookie(ChallengeCookieName, challenge, + "/api/auth/callback", DateTimeOffset.UtcNow.AddMinutes(5))); + response.Header.CacheControl = "no-store"; + return response; + } + + /// + /// Accepts only a browser-correlated authorization code and validates the resulting ID token's + /// signature, issuer, audience, lifetime, authorized party, and nonce before mapping claims. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The shared token service used to bind browser correlation to the local signing authority. + /// The normalized external identity, or null when correlation or token validation fails. + public async Task AuthenticateCallbackAsync(IRequest request, IdentityTokenService tokens) + { + var challenge = tokens.ValidateChallenge(IdentityManager.CookieValue(request, ChallengeCookieName), request.ApplicationContext?.ApplicationId); + var state = Query(request, "state"); + var code = Query(request, "code"); + if (challenge is null || string.IsNullOrEmpty(code) || code.Length > 4096 || Query(request, "error") is not null || + !challenge.TryGetPayloadValue("state", out var expectedState) || !FixedEquals(state, expectedState) || + !challenge.TryGetPayloadValue("provider", out var provider) || provider != ProviderId || + !challenge.TryGetPayloadValue("nonce", out var nonce) || + !challenge.TryGetPayloadValue("verifier", out var verifier)) { return null; } + var issuer = Query(request, "iss"); + if (issuer is not null && issuer != _settings.Authority) { return null; } + if (!tokens.ConsumeChallenge(challenge)) { return null; } + var configuration = await GetConfigurationAsync(); + var form = new Dictionary + { + ["grant_type"] = "authorization_code", ["code"] = code, ["redirect_uri"] = _settings.RedirectUri, + ["client_id"] = _settings.ClientId, ["code_verifier"] = verifier + }; + if (!string.IsNullOrEmpty(_settings.ClientSecret)) { form["client_secret"] = _settings.ClientSecret; } + using var content = new FormUrlEncodedContent(form); + using var response = await _client.PostAsync(configuration.TokenEndpoint, content); + if (!response.IsSuccessStatusCode) { return null; } + var body = await response.Content.ReadAsStringAsync(); + if (body.Length > 65536) { return null; } + using var json = JsonDocument.Parse(body); + if (!json.RootElement.TryGetProperty("id_token", out var token) || token.ValueKind != JsonValueKind.String) { return null; } + var result = await ValidateIdTokenAsync(token.GetString(), configuration); + if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) + { + _configuration.RequestRefresh(); + configuration = await GetConfigurationAsync(); + result = await ValidateIdTokenAsync(token.GetString(), configuration); + } + if (!result.IsValid || result.SecurityToken is not JsonWebToken jwt || string.IsNullOrWhiteSpace(jwt.Subject) || + !jwt.TryGetPayloadValue("nonce", out var actualNonce) || !FixedEquals(nonce, actualNonce) || + jwt.IssuedAt == DateTime.MinValue || jwt.IssuedAt > DateTime.UtcNow.AddSeconds(30)) { return null; } + jwt.TryGetPayloadValue("azp", out var authorizedParty); + if ((jwt.Audiences.Count() > 1 && authorizedParty is null) || + (authorizedParty is not null && authorizedParty != _settings.ClientId)) { return null; } + return MapIdentity(jwt); + } + + /// + /// Preserves role labels for application display while granting only explicitly configured local rights. + /// The issuer participates in the subject identifier so different authorities cannot collide by subject. + /// + /// The verified external ID token whose claims are mapped to the common identity. + /// The common identity containing only locally mapped authorization claims. + protected virtual IIdentity MapIdentity(JsonWebToken token) + { + var roles = ReadRoles(token).Distinct(StringComparer.Ordinal).ToArray(); + token.TryGetPayloadValue("preferred_username", out var name); + token.TryGetPayloadValue("email", out var email); + var id = new Guid(SHA256.HashData(Encoding.UTF8.GetBytes(_settings.Authority + "\0" + token.Subject)).AsSpan(0, 16)); + return new Identity(id, string.IsNullOrWhiteSpace(name) ? token.Subject : name, email, roles, + roles.Where(_settings.RolePermissions.ContainsKey).SelectMany(x => _settings.RolePermissions[x]), + roles.Where(_settings.RolePolicies.ContainsKey).SelectMany(x => _settings.RolePolicies[x])); + } + + /// + /// Allows provider-specific role claim layouts without changing token validation or local authorization rules. + /// + /// The verified external ID token containing the provider's role claims. + /// The role labels supplied by the verified provider-specific claim mapping. + protected virtual IEnumerable ReadRoles(JsonWebToken token) + { + return token.TryGetPayloadValue("roles", out var roles) ? roles : []; + } + + /// + /// Exposes the audience used to restrict provider-specific client role claims. + /// + protected string ClientId => _settings.ClientId; + + /// + /// Validates external identity evidence against pinned authority metadata and asymmetric algorithms. + /// + /// The serialized credential that must pass the required trust checks. + /// The trusted authority metadata containing signing keys and protocol endpoints. + /// The cryptographic and protocol claim validation result. + private Task ValidateIdTokenAsync(string token, OpenIdConnectConfiguration configuration) + { + return _handler.ValidateTokenAsync(token, new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = _settings.Authority, + ValidateAudience = true, ValidAudience = _settings.ClientId, + ValidateIssuerSigningKey = true, IssuerSigningKeys = configuration.SigningKeys, + RequireSignedTokens = true, RequireExpirationTime = true, ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30), + ValidAlgorithms = [SecurityAlgorithms.RsaSha256, SecurityAlgorithms.RsaSha384, SecurityAlgorithms.RsaSha512, + SecurityAlgorithms.RsaSsaPssSha256, SecurityAlgorithms.EcdsaSha256] + }); + } + + /// + /// Checks discovered endpoints against the configured HTTPS authority before code exchange. + /// + /// The verified discovery metadata for the configured authority. + private async Task GetConfigurationAsync() + { + var configuration = await _configuration.GetConfigurationAsync(CancellationToken.None); + if (configuration.Issuer != _settings.Authority) { throw new InvalidOperationException("OIDC discovery issuer mismatch."); } + RequireHttps(configuration.AuthorizationEndpoint); + RequireHttps(configuration.TokenEndpoint); + RequireHttps(configuration.JwksUri); + return configuration; + } + + /// + /// Excludes ambiguous, session, and form values from authorization-code correlation. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The exact cookie or claim name to inspect. + /// The single decoded query value, or null when absent or ambiguous. + internal static string Query(IRequest request, string name) + { + if (request is not RequestBase concrete) { return null; } + var query = QueryHelpers.ParseQuery(concrete.QueryString); + return query.TryGetValue(name, out var values) && values.Count == 1 ? values[0] : null; + } + + /// + /// Avoids content-dependent comparisons of browser correlation secrets. + /// + /// The correlation value received for comparison. + /// The expected correlation value. + /// True when both correlation values match; otherwise, false. + private static bool FixedEquals(string left, string right) => left is not null && right is not null && + CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(left), Encoding.UTF8.GetBytes(right)); + + /// + /// Rejects external endpoints that could disclose credentials through insecure transport. + /// + /// The value being validated or placed in the protected response. + private static void RequireHttps(string value) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || uri.Scheme != "https" || !string.IsNullOrEmpty(uri.UserInfo)) + { + throw new ArgumentException("OIDC endpoints must use absolute HTTPS URLs without credentials."); + } + } + + /// + /// External directories are not enumerated to authenticate an already verified subject. + /// + /// The directory identities, or an empty collection for an external source. + public IEnumerable GetIdentities() => []; + /// + /// External roles are mapped at login without importing a remote group directory. + /// + /// The local directory groups, or an empty collection when no directory is exposed. + public IEnumerable GetGroups() => []; + /// + /// Leaves the choice of interactive provider to the application's login UI. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The protected page that initiated authentication or denied access. + /// The verified identity whose authorization snapshot is being processed. + /// The provider response, or null when the application should choose the login presentation. + public IResponse CreateAuthenticationPrompt(IRequest request, IPageContext initiator, IIdentity identity) => null; + /// + /// Lets the application retain control of its permission-denied presentation. + /// + /// The HTTP request whose authentication context is being evaluated. + /// The protected page that initiated authentication or denied access. + /// The verified identity whose authorization snapshot is being processed. + /// The provider response, or null when the application should choose the denied-access presentation. + public IResponse CreateForbiddenResponse(IRequest request, IPageContext initiator, IIdentity identity) => null; + /// + /// Releases the provider-owned backchannel when its plugin is removed. + /// + public void Dispose() + { + if (_ownsClient) { _client.Dispose(); } + GC.SuppressFinalize(this); + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index d626dcb1..27bfb6b2 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -22,6 +22,10 @@ namespace WebExpress.WebCore.WebMessage public abstract class RequestBase : IRequest { private readonly ParameterDictionary _param = []; + private Session _session; + + internal Session ExistingSession { get => _session; set => _session = value; } + internal string QueryString { get; private set; } /// /// Gets the context of the web server. @@ -49,9 +53,9 @@ public abstract class RequestBase : IRequest public UriEndpoint Uri { get; set; } /// - /// Gets the session. + /// Creates optional application state only when a caller needs it; authentication never depends on it. /// - public Session Session { get; private set; } + public Session Session => _session ??= WebEx.ComponentHub?.SessionManager?.GetSession(this); /// /// Gets the http version. @@ -170,8 +174,9 @@ internal RequestBase(IFeatureCollection contextFeatures, RequestHeaderFields hea requestFeature.RawTarget ); - ParseQueryParams(requestFeature.QueryString); - ParseSessionParams(); + QueryString = requestFeature.QueryString; + ParseQueryParams(QueryString); + if (Header.Cookies.Any(x => x.Name == "session")) { ParseSessionParams(); } } /// @@ -209,8 +214,6 @@ private void ParseQueryParams(string query) /// private void ParseSessionParams() { - Session = WebEx.ComponentHub?.SessionManager?.GetSession(this); - var property = Session?.GetProperty(); if (property is not null && property.Params is not null) { diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs index f80cb9b6..43fcc19c 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs @@ -79,6 +79,16 @@ public class RequestHeaderFields /// public string Referer { get; private set; } + /// + /// Allows cookie-authenticated mutations to reject cross-origin browser requests. + /// + public string Origin { get; private set; } + + /// + /// Requires a browser preflight before another origin can invoke authentication mutations. + /// + public string AuthenticationRequest { get; private set; } + /// /// Gets the If-None-Match header value. It carries the entity tag (ETag) the client already /// holds and is used for conditional requests so unchanged resources can be answered with 304. @@ -124,6 +134,8 @@ internal RequestHeaderFields(IFeatureCollection contextFeatures) AcceptLanguage = requestFeature.Headers.AcceptLanguage.SelectMany(x => x.Split(';', StringSplitOptions.RemoveEmptyEntries)); UserAgent = requestFeature.Headers.UserAgent; Referer = requestFeature.Headers.Referer; + Origin = requestFeature.Headers.Origin; + AuthenticationRequest = requestFeature.Headers["X-WebExpress-Auth"]; IfNoneMatch = requestFeature.Headers.IfNoneMatch; Upgrade = requestFeature.Headers.Upgrade; SecWebSocketKey = requestFeature.Headers.SecWebSocketKey; diff --git a/src/WebExpress.WebCore/WebSession/AuthorizationService.cs b/src/WebExpress.WebCore/WebSession/AuthorizationService.cs deleted file mode 100644 index 58182244..00000000 --- a/src/WebExpress.WebCore/WebSession/AuthorizationService.cs +++ /dev/null @@ -1,18 +0,0 @@ -using WebExpress.WebCore.WebSession.Model; - -namespace WebExpress.WebCore.WebSession -{ - - /// - /// Represents an abstract authorization service. - /// - public abstract class AuthorizationService - { - /// - /// Checks if the authenticated user is authorized. - /// - /// The current session. - /// true if authorized, false otherwise. - public abstract bool Authorization(Session session); - } -} diff --git a/src/WebExpress.WebCore/WebSession/Model/Session.cs b/src/WebExpress.WebCore/WebSession/Model/Session.cs index 7168b7c2..ca239118 100644 --- a/src/WebExpress.WebCore/WebSession/Model/Session.cs +++ b/src/WebExpress.WebCore/WebSession/Model/Session.cs @@ -16,9 +16,8 @@ public class Session /// Gets the session id. /// /// - /// The id is the only thing the client holds, so it is what an attacker would plant or - /// steal. The session manager therefore replaces it when the session changes privilege - /// - at sign-in - which is why the id must not be cached across such a change. + /// The id identifies optional application state and never authenticates a user. + /// Applications may regenerate it when replacing sensitive application state. /// public Guid Id { get; internal set; } diff --git a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthentification.cs b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthentification.cs deleted file mode 100644 index f430d68e..00000000 --- a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthentification.cs +++ /dev/null @@ -1,27 +0,0 @@ - -using WebExpress.WebCore.WebIdentity; - -namespace WebExpress.WebCore.WebSession.Model -{ - /// - /// Represents the authentication session property. - /// Authentication is the process of verifying the identity of a person or system to ensure that someone is who they claim to be. - /// - public class SessionPropertyAuthentification : SessionProperty - { - /// - /// Gets the identity. - /// - public IIdentity Identity { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The identity to be set. - public SessionPropertyAuthentification(IIdentity identity) - { - Identity = identity; - } - - } -} diff --git a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthorization.cs b/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthorization.cs deleted file mode 100644 index d5f93840..00000000 --- a/src/WebExpress.WebCore/WebSession/Model/SessionPropertyAuthorization.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace WebExpress.WebCore.WebSession.Model -{ - /// - /// Represents a session property authorization. - /// Authorization is the process of determining which resources and actions are allowed for an authenticated person or system. - /// - public class SessionPropertyAuthorization : SessionProperty - { - } -} diff --git a/src/WebExpress.WebCore/WebSession/SessionManager.cs b/src/WebExpress.WebCore/WebSession/SessionManager.cs index 58551817..c7bb291f 100644 --- a/src/WebExpress.WebCore/WebSession/SessionManager.cs +++ b/src/WebExpress.WebCore/WebSession/SessionManager.cs @@ -76,9 +76,10 @@ public Session GetSession(IRequest request) // the session resolved for the request comes first: a session created for this // request has an id the client only learns with the response, so the cookie // cannot name it yet and a lookup by cookie would create a second one - if (request?.Session is not null) + var existing = request is RequestBase concrete ? concrete.ExistingSession : request?.Session; + if (existing is not null) { - return request.Session; + return existing; } var sessionCookie = request?.Header @@ -95,6 +96,7 @@ public Session GetSession(IRequest request) { // sliding window: an active session keeps renewing its idle deadline known.Updated = now; + if (request is RequestBase knownRequest) { knownRequest.ExistingSession = known; } return known; } @@ -107,6 +109,7 @@ public Session GetSession(IRequest request) // no, invalid, unknown or expired session id => a fresh, server-generated one var session = new Session(); _dictionary[session.Id] = session; + if (request is RequestBase newRequest) { newRequest.ExistingSession = session; } return session; } diff --git a/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs new file mode 100644 index 00000000..e8aaa8b7 --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs @@ -0,0 +1,57 @@ +using System; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Defines the trust boundary shared by every instance serving the same applications. + /// Secrets must come from deployment configuration and must never be generated per process. + /// + public sealed class AuthenticationSettings + { + /// + /// Identifies the authority that issues WebExpress tokens. + /// + public string Issuer { get; set; } + + /// + /// Separates this deployment's tokens from those of other services. + /// + public string Audience { get; set; } + + /// + /// Supplies at least 256 random bits, encoded as Base64, from a secret store. + /// + public string SigningKey { get; set; } + + /// + /// Requires HTTPS and protected cookie prefixes unless explicitly disabled for local development. + /// Production deployments must retain the default value of true. + /// + public bool RequireHttps { get; set; } = true; + + /// + /// Bounds the time a signed authorization snapshot remains usable without a database lookup. + /// + public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Bounds the entire login grant; rotation never extends this deadline. + /// + public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(7); + + /// + /// Limits explicitly created credentials for unattended clients. + /// + public TimeSpan MaximumPersonalAccessTokenLifetime { get; set; } = TimeSpan.FromDays(90); + + /// + /// Places refresh replay markers and PAT revocations on durable shared storage. + /// + public string TokenStorePath { get; set; } + + /// + /// Chooses an application for the root authentication endpoints when none is supplied. + /// + public string ApplicationId { get; set; } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs index a72ba4b3..162ee4e4 100644 --- a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs @@ -46,6 +46,11 @@ public sealed class HttpServerSettings /// public SessionSettings Session { get; set; } + /// + /// Defines the shared signing authority and lifetimes for session-independent authentication. + /// + public AuthenticationSettings Authentication { get; set; } + /// /// The log settings. A missing block keeps logging switched off. /// diff --git a/src/WebExpress.WebCore/WebSetting/OpenIdConnectSettings.cs b/src/WebExpress.WebCore/WebSetting/OpenIdConnectSettings.cs new file mode 100644 index 00000000..6804da2d --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/OpenIdConnectSettings.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Pins the external authority and maps its trusted roles into local permissions explicitly. + /// + public sealed class OpenIdConnectSettings + { + /// + /// Distinguishes this provider when multiple external authorities serve one application. + /// + public string ProviderId { get; set; } + + /// + /// Pins the HTTPS issuer advertised by discovery and by every accepted ID token. + /// + public string Authority { get; set; } + + /// + /// Binds token audiences and authorization requests to the registered relying party. + /// + public string ClientId { get; set; } + + /// + /// Authenticates a confidential client at the token endpoint when required by the provider. + /// + public string ClientSecret { get; set; } + + /// + /// Pins the registered HTTPS callback including the application and provider query parameters. + /// + public string RedirectUri { get; set; } + + /// + /// Prevents unrecognized external roles from granting local application permissions. + /// + public Dictionary RolePermissions { get; set; } = []; + + /// + /// Explicitly maps external roles to the full names of locally defined policy classes. + /// + public Dictionary RolePolicies { get; set; } = []; + } +} From 89a647dee8982558b329020590490dbe8812d635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 21 Sep 2026 22:31:56 +0200 Subject: [PATCH 39/69] feat: introduce certificate management infrastructure --- .../Manager/UnitTestAuthenticationEndpoint.cs | 60 ++ .../Manager/UnitTestCertificateManager.cs | 698 ++++++++++++++++++ .../Server/UnitTestHttpServer.cs | 2 +- src/WebExpress.WebCore/HttpServer.cs | 143 ++-- src/WebExpress.WebCore/HttpServerContext.cs | 11 +- src/WebExpress.WebCore/IHttpServerContext.cs | 6 + .../Internationalization/de | 3 + .../Internationalization/en | 4 +- .../WebCertificate/CertificateInfo.cs | 61 ++ .../WebCertificate/CertificateManager.cs | 445 +++++++++++ .../WebCertificate/CertificateMaterial.cs | 50 ++ .../WebCertificate/CertificateStatus.cs | 51 ++ .../WebCertificate/FileCertificateStore.cs | 65 ++ .../WebCertificate/ICertificateManager.cs | 45 ++ .../WebCertificate/ICertificateStore.cs | 21 + .../WebComponent/ComponentHub.cs | 6 + .../WebComponent/IComponentHub.cs | 6 + .../WebMessage/RequestBase.cs | 8 +- .../WebSetting/CertificateManagerSettings.cs | 26 + .../WebSetting/CertificateSettings.cs | 33 + .../WebSetting/EndpointSettings.cs | 35 +- .../WebSetting/HttpServerSettings.cs | 5 + 22 files changed, 1724 insertions(+), 60 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/CertificateInfo.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/CertificateManager.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/CertificateMaterial.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/CertificateStatus.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/FileCertificateStore.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/ICertificateManager.cs create mode 100644 src/WebExpress.WebCore/WebCertificate/ICertificateStore.cs create mode 100644 src/WebExpress.WebCore/WebSetting/CertificateManagerSettings.cs create mode 100644 src/WebExpress.WebCore/WebSetting/CertificateSettings.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs index c2ced942..8c3f1b5d 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestAuthenticationEndpoint.cs @@ -12,6 +12,66 @@ namespace WebExpress.WebCore.Test.Manager [Collection("NonParallelTests")] public class UnitTestAuthenticationEndpoint { + /// + /// Preserves explicit HTTP and HTTPS ports so same-origin checks accept browser requests without accepting other origins. + /// + /// The authority sent in the Host header, including the public port. + /// The browser origin used to validate the authentication request. + /// The HTTP transport scheme used by the host. + /// The local listener port. + /// The expected success or forbidden status. + /// A task that completes after URL construction and authentication have been checked. + [Theory] + [InlineData("localhost:8080", "http://localhost:8080", "http", 8080, 200)] + [InlineData("[::1]:8080", "http://[::1]:8080", "http", 8080, 200)] + [InlineData("localhost:8443", "https://localhost:8443", "https", 8443, 200)] + [InlineData("localhost:443", "https://localhost", "https", 443, 200)] + [InlineData("localhost:8080", "http://localhost:8081", "http", 8080, 403)] + [InlineData("localhost:8080", "http://other.example:8080", "http", 8080, 403)] + public async Task LoginWithExplicitPortEnforcesTheBrowserOrigin(string host, string origin, string scheme, int port, int expectedStatus) + { + // arrange + using var fixture = new AuthenticationFixture(requireHttps: scheme == "https"); + using var endpoint = new AuthenticationEndpoint(fixture.Hub, fixture.Server); + fixture.Hub.IdentityProviderManager.Register(new PasswordProvider(), fixture.Application); + var context = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + context.TraceIdentifier = Guid.NewGuid().ToString("N"); + context.Request.Method = "POST"; + context.Request.Scheme = scheme; + context.Request.Host = new Microsoft.AspNetCore.Http.HostString(host); + context.Request.Path = "/api/auth/login"; + context.Request.Protocol = "HTTP/1.1"; + context.Request.ContentType = "application/json"; + context.Request.Headers.Origin = origin; + context.Request.Headers["X-WebExpress-Auth"] = "1"; + context.Features.Get().RawTarget = "/api/auth/login"; + context.Connection.LocalIpAddress = System.Net.IPAddress.Loopback; + context.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + context.Connection.LocalPort = port; + using var body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("{\"username\":\"alice\",\"password\":\"correct\"}")); + context.Request.Body = body; + context.Request.ContentLength = body.Length; + + // act + var request = new WebMessage.HttpContext(context.Features, fixture.Server).Request; + var response = await endpoint.HandleAsync(request); + + // validation + Assert.True(Uri.TryCreate(request.Uri.ToString(), UriKind.Absolute, out var uri)); + Assert.Equal(port, uri.Port); + Assert.Equal(expectedStatus, response.Status); + if (expectedStatus == 200) + { + using var result = JsonDocument.Parse((string)response.Content); + Assert.True(result.RootElement.GetProperty("authenticated").GetBoolean()); + } + else + { + Assert.Contains("invalid_origin", (string)response.Content); + Assert.Empty(response.Header.Cookies.Cast()); + } + } + /// /// Keeps HTTPS mandatory when a deployment has not explicitly enabled development HTTP. /// diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs new file mode 100644 index 00000000..99d93ff9 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestCertificateManager.cs @@ -0,0 +1,698 @@ +using Microsoft.Extensions.Configuration; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebCertificate; +using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Exercises real PFX loading, provider substitution, validity boundaries and hosting integration. + /// + [Collection("NonParallelTests")] + public sealed class UnitTestCertificateManager : IDisposable + { + private readonly string _directory = Path.Combine(Path.GetTempPath(), "webexpress-certificates-" + Guid.NewGuid().ToString("N")); + private readonly TestClock _clock = new(); + + /// + /// Isolates generated certificates from the repository and the operating system certificate store. + /// + public UnitTestCertificateManager() + { + Directory.CreateDirectory(_directory); + } + + /// + /// Provides deterministic UTC validity boundaries without sleeping or changing the machine clock. + /// + private sealed class TestClock : TimeProvider + { + /// + /// Gets or sets the instant observed by certificate validation. + /// + public DateTimeOffset Now { get; set; } = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + + /// + /// Supplies the test instant to the manager. + /// + /// The configured UTC instant. + public override DateTimeOffset GetUtcNow() => Now; + } + + /// + /// Models a module that supplies certificates without any file access. + /// + /// The private material owned by the test provider. + private sealed class MemoryStore(byte[] pfx) : ICertificateStore + { + /// + /// Gets the provider name used to select the in-memory test material. + /// + public string Name => "memory"; + + /// + /// Gets the number of loads so resolution can prove it does not revisit the provider. + /// + public int LoadCount { get; private set; } + + /// + /// Transfers fresh material so cached resolution can be distinguished from repeated provider access. + /// + /// The test reference accepted without filesystem access. + /// The password protecting the in-memory PFX. + /// Newly owned certificate material for the manager. + public CertificateMaterial Load(string reference, string password) + { + LoadCount++; + return new CertificateMaterial(X509CertificateLoader.LoadPkcs12(pfx, password, X509KeyStorageFlags.EphemeralKeySet)); + } + } + + /// + /// Models a provider error that would expose secrets if its exception were logged verbatim. + /// + private sealed class FailingStore : ICertificateStore + { + /// + /// Gets the provider name used to select the failure scenario. + /// + public string Name => "failure"; + + /// + /// Simulates an unsafe provider exception to verify that diagnostics never expose credentials. + /// + /// The unused test reference. + /// The secret deliberately included in the simulated exception. + /// No material because the simulated provider always fails. + public CertificateMaterial Load(string reference, string password) => throw new IOException(password); + } + + /// + /// Creates a short-lived test leaf with controlled identity, key usage and validity. + /// + /// The subject alternative DNS name. + /// The beginning of the validity interval. + /// The end of the validity interval. + /// Whether the EKU permits server authentication. + /// Whether the certificate is a CA certificate. + /// Whether the key usage permits digital signatures. + /// A certificate with its private key owned by the caller. + private static X509Certificate2 CreateCertificate(string host, DateTimeOffset notBefore, DateTimeOffset notAfter, + bool serverUsage = true, bool certificateAuthority = false, bool signingUsage = true) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=" + host, key, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName(host); + names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(certificateAuthority, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension( + signingUsage ? X509KeyUsageFlags.DigitalSignature : X509KeyUsageFlags.KeyEncipherment, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid(serverUsage ? "1.3.6.1.5.5.7.3.1" : "1.3.6.1.5.5.7.3.2") }, false)); + return request.CreateSelfSigned(notBefore, notAfter); + } + + /// + /// Writes real password-protected material so tests exercise the shipped file store. + /// + /// The file name within the isolated test directory. + /// The subject alternative DNS name. + /// The remaining validity in days. + /// The PFX password used by the loader. + /// The absolute file path. + private string WriteCertificate(string fileName = "server.pfx", string host = "localhost", int days = 90, string password = "test-secret") + { + using var certificate = CreateCertificate(host, _clock.Now.AddDays(-2), _clock.Now.AddDays(days)); + var path = Path.Combine(_directory, fileName); + File.WriteAllBytes(path, certificate.Export(X509ContentType.Pfx, password)); + return path; + } + + /// + /// Builds the smallest inventory that refers to the generated PFX file. + /// + /// The independent settings object for one test. + private HttpServerSettings Settings() => new() + { + Certificates = new CertificateManagerSettings + { + Directory = _directory, + Items = [new CertificateSettings { Alias = "primary", Reference = "server.pfx", Password = "test-secret", HostNames = ["localhost"] }] + } + }; + + /// + /// Proves aliases and normalized hostnames share cached material with inspectable metadata. + /// + [Fact] + public void FileInventoryResolvesAliasesHostsAndMetadata() + { + // arrange + var path = WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(Settings()); + File.Delete(path); + var material = manager.Resolve("PRIMARY"); + + // validation + Assert.Same(material, manager.Resolve("LOCALHOST.")); + var info = Assert.Single(manager.GetCertificates()); + Assert.True(info.IsUsable); + Assert.Equal(CertificateStatus.Valid, info.Status); + Assert.Equal(material.Certificate.Issuer, info.Issuer); + Assert.Equal(_clock.Now.AddDays(90), info.NotAfter); + Assert.Equal(material.Certificate.Thumbprint, info.Thumbprint); + } + + /// + /// Keeps two configured names independent while preserving legacy absolute PFX references. + /// + [Fact] + public void MultipleInlineEndpointsResolveDifferentCertificates() + { + // arrange + var first = WriteCertificate("first.pfx", "first.example"); + var second = WriteCertificate("second.pfx", "second.example"); + var settings = new HttpServerSettings + { + Endpoints = + [ + new() { Uri = "https://first.example:443", PfxFile = first, Password = "test-secret", CertificateAlias = "first" }, + new() { Uri = "HTTPS://second.example:8443", PfxFile = second, Password = "test-secret" } + ] + }; + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(settings); + + // validation + Assert.Same(manager.Resolve("first"), manager.Resolve("FIRST.EXAMPLE")); + Assert.Same(manager.Resolve(settings.Endpoints[1]), manager.Resolve("second.example")); + Assert.NotEqual(manager.Resolve("first").Certificate.Thumbprint, manager.Resolve("second.example").Certificate.Thumbprint); + } + + /// + /// Separates TLS suitability failures from provider loading failures. + /// + /// The suitability rule the generated material violates. + /// The status flag callers must be able to inspect. + [Theory] + [InlineData("expired", CertificateStatus.Expired)] + [InlineData("future", CertificateStatus.NotYetValid)] + [InlineData("keyless", CertificateStatus.MissingPrivateKey)] + [InlineData("client", CertificateStatus.InvalidUsage)] + [InlineData("ca", CertificateStatus.InvalidUsage)] + [InlineData("usage", CertificateStatus.InvalidUsage)] + [InlineData("hostname", CertificateStatus.HostNameMismatch)] + public void InvalidCertificatesRemainInspectableButCannotResolve(string failure, CertificateStatus expected) + { + // arrange + using var certificate = CreateCertificate(failure == "hostname" ? "other.example" : "localhost", + _clock.Now.AddDays(failure == "future" ? 1 : -3), _clock.Now.AddDays(failure == "expired" ? -1 : 90), + serverUsage: failure != "client", certificateAuthority: failure == "ca", signingUsage: failure != "usage"); + using var publicOnly = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + File.WriteAllBytes(Path.Combine(_directory, "server.pfx"), + (failure == "keyless" ? publicOnly : certificate).Export(X509ContentType.Pfx, "test-secret")); + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(Settings()); + + // validation + var info = Assert.Single(manager.GetCertificates()); + Assert.True(info.Status.HasFlag(expected)); + Assert.False(info.IsUsable); + Assert.Throws(() => manager.Resolve("primary")); + } + + /// + /// Reports unreadable PFX files without returning unusable material or leaking credentials. + /// + /// The file or credential boundary to violate. + [Theory] + [InlineData("missing")] + [InlineData("password")] + [InlineData("corrupt")] + public void LoadFailuresAreSafeAndInspectable(string failure) + { + // arrange + if (failure != "missing") { WriteCertificate(); } + if (failure == "corrupt") { File.WriteAllText(Path.Combine(_directory, "server.pfx"), "invalid pfx"); } + var settings = Settings(); + settings.Certificates.Items[0].Password = "never-log-this-password"; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.LoadFailed, Assert.Single(manager.GetCertificates()).Status); + Assert.Throws(() => manager.Resolve("primary")); + Assert.DoesNotContain(log.GetRecentEntries(), x => x.Message.Contains("never-log-this-password")); + Assert.True(log.ErrorCount > 0); + } + + /// + /// Makes expiry warnings configurable and validates the exact validity endpoint on later resolutions. + /// + [Fact] + public void ThresholdsAndClockChangesControlStatus() + { + // arrange + WriteCertificate(days: 10); + var settings = Settings(); + settings.Certificates.WarningThresholdDays = [14, 7]; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.ExpiringSoon, Assert.Single(manager.GetCertificates()).Status); + Assert.Equal(1, log.WarningCount); + Assert.Contains(log.GetRecentEntries(), x => x.Message.Contains("primary") && x.Message.Contains("14")); + Assert.NotNull(manager.Resolve("primary")); + + // act + _clock.Now = _clock.Now.AddDays(10); + + // validation + Assert.Equal(CertificateStatus.Expired, Assert.Single(manager.GetCertificates()).Status); + Assert.Throws(() => manager.Resolve("primary")); + } + + /// + /// Allows deployments to suppress expiry warnings without disabling certificate validity checks. + /// + [Fact] + public void EmptyThresholdsDisableWarnings() + { + // arrange + WriteCertificate(days: 1); + var settings = Settings(); + settings.Certificates.WarningThresholdDays = []; + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + + // act + manager.Load(settings); + + // validation + Assert.Equal(CertificateStatus.Valid, Assert.Single(manager.GetCertificates()).Status); + Assert.Equal(0, log.WarningCount); + } + + /// + /// Prevents endpoint aliases from bypassing hostname validation or choosing an arbitrary fallback. + /// + [Fact] + public void EndpointResolutionValidatesHostAndMissingMappings() + { + // arrange + WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + manager.Load(Settings()); + + // act + var concrete = manager.Resolve(new EndpointSettings { Uri = "https://localhost:443" }); + var wildcard = manager.Resolve(new EndpointSettings { Uri = "https://*:443", CertificateAlias = "primary" }); + + // validation + Assert.NotNull(concrete); + Assert.Same(concrete, wildcard); + Assert.Throws(() => manager.Resolve(new EndpointSettings { Uri = "https://other.example", CertificateAlias = "primary" })); + Assert.Throws(() => manager.Resolve("unknown")); + Assert.Throws(() => manager.Resolve(new EndpointSettings { Uri = "https://*:443" })); + } + + /// + /// Preserves an unambiguous inventory even when configuration is supplied by several files. + /// + /// The invalid configuration to construct. + [Theory] + [InlineData("alias")] + [InlineData("host")] + [InlineData("alias-host")] + [InlineData("store")] + [InlineData("threshold")] + [InlineData("wildcard")] + public void ConflictingOrInvalidConfigurationIsRejected(string failure) + { + // arrange + WriteCertificate(); + var settings = Settings(); + switch (failure) + { + case "alias": settings.Certificates.Items.Add(new() { Alias = "PRIMARY", Reference = "server.pfx" }); break; + case "host": settings.Certificates.Items.Add(new() { Alias = "other", Reference = "server.pfx", HostNames = ["LOCALHOST."] }); break; + case "alias-host": settings.Certificates.Items.Add(new() { Alias = "localhost", Reference = "server.pfx" }); break; + case "store": settings.Certificates.Items[0].Store = "unknown"; break; + case "threshold": settings.Certificates.WarningThresholdDays = [-1]; break; + case "wildcard": settings.Certificates.Items[0].HostNames = ["*.example.com"]; break; + } + using var manager = new CertificateManager(timeProvider: _clock); + + // act + var exception = Record.Exception(() => manager.Load(settings)); + + // validation + Assert.NotNull(exception); + Assert.Empty(manager.GetCertificates()); + } + + /// + /// Demonstrates that providers can change without introducing file access in applications. + /// + [Fact] + public void ExternalStoreSupportsCachedResolutionAndSafeReload() + { + // arrange + using var certificate = CreateCertificate("localhost", _clock.Now.AddDays(-1), _clock.Now.AddDays(90)); + var store = new MemoryStore(certificate.Export(X509ContentType.Pfx, "test-secret")); + using var manager = new CertificateManager(timeProvider: _clock); + manager.RegisterStore(store); + var settings = Settings(); + settings.Certificates.Items[0].Store = "memory"; + settings.Certificates.Items[0].Reference = "provider-reference"; + + // act + manager.Load(settings); + var borrowed = manager.Resolve("primary"); + + // validation + Assert.Same(borrowed, manager.Resolve("localhost")); + Assert.Equal(1, store.LoadCount); + + // act + manager.Load(settings); + + // validation + Assert.NotSame(borrowed, manager.Resolve("primary")); + Assert.True(borrowed.Certificate.HasPrivateKey); + + // act + manager.Dispose(); + + // validation + Assert.Throws(() => manager.Resolve("primary")); + Assert.Equal(IntPtr.Zero, borrowed.Certificate.Handle); + } + + /// + /// Keeps provider exceptions containing credentials out of the shared log. + /// + [Fact] + public void ProviderExceptionDoesNotLeakPassword() + { + // arrange + var log = new Log { LogMode = LogMode.Off }; + using var manager = new CertificateManager(log, _clock); + manager.RegisterStore(new FailingStore()); + var settings = Settings(); + settings.Certificates.Items[0].Store = "failure"; + + // act + manager.Load(settings); + + // validation + Assert.DoesNotContain(log.GetRecentEntries(), x => x.Message.Contains("test-secret")); + Assert.Equal(CertificateStatus.LoadFailed, Assert.Single(manager.GetCertificates()).Status); + } + + /// + /// Verifies that existing configuration binding exposes the complete certificate configuration. + /// + [Fact] + public void SettingsBindCertificatesAndEndpointAliases() + { + // arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(""" + { "WebExpress": { + "Certificates": { "Directory": "./ssl", "WarningThresholdDays": [21, 3], + "Items": [{ "Alias": "site", "Reference": "site.pfx", "Password": "configured", "HostNames": ["site.example"] }] }, + "Endpoints": [{ "Uri": "https://*:443", "CertificateAlias": "site" }] + } } + """)); + var configuration = new ConfigurationBuilder().AddJsonStream(stream).Build(); + + // act + var settings = configuration.GetServerSettings(); + + // validation + Assert.Equal("./ssl", settings.Certificates.Directory); + Assert.Equal([21, 3], settings.Certificates.WarningThresholdDays); + Assert.Equal("configured", settings.Certificates.Items[0].Password); + Assert.Equal("file", settings.Certificates.Items[0].Store); + Assert.Equal("site", settings.Endpoints[0].CertificateAlias); + } + + /// + /// Preserves configured empty arrays so operators can disable expiry warnings through JSON. + /// + [Fact] + public void EmptyWarningArraySurvivesConfigurationBinding() + { + // arrange + using var stream = new MemoryStream(Encoding.UTF8.GetBytes( + """{ "WebExpress": { "Certificates": { "WarningThresholdDays": [] } } }""")); + + // act + var settings = new ConfigurationBuilder().AddJsonStream(stream).Build().GetServerSettings(); + + // validation + Assert.NotNull(settings.Certificates.WarningThresholdDays); + Assert.Empty(settings.Certificates.WarningThresholdDays); + } + + /// + /// Verifies failed replacement configuration leaves the currently served inventory intact. + /// + [Fact] + public void FailedReloadKeepsExistingInventory() + { + // arrange + WriteCertificate(); + using var manager = new CertificateManager(timeProvider: _clock); + var settings = Settings(); + manager.Load(settings); + var current = manager.Resolve("primary"); + settings.Certificates.Items.Add(new() { Alias = "other", Store = "missing", Reference = "anything" }); + + // act + var exception = Record.Exception(() => manager.Load(settings)); + + // validation + Assert.IsType(exception); + Assert.Same(current, manager.Resolve("primary")); + Assert.True(current.Certificate.HasPrivateKey); + } + + /// + /// Keeps one alias usable for several endpoints without reloading its PFX for each listener. + /// + [Fact] + public void InlineAliasCanBeSharedAcrossPorts() + { + // arrange + var path = WriteCertificate(); + var settings = new HttpServerSettings + { + Endpoints = + [ + new() { Uri = "https://localhost:443", CertificateAlias = "site" }, + new() { Uri = "https://127.0.0.1:8443", CertificateAlias = "site", PfxFile = path, Password = "test-secret" } + ] + }; + using var manager = new CertificateManager(timeProvider: _clock); + + // act + manager.Load(settings); + + // validation + Assert.Single(manager.GetCertificates()); + Assert.Same(manager.Resolve(settings.Endpoints[0]), manager.Resolve(settings.Endpoints[1])); + Assert.Same(manager.Resolve("localhost"), manager.Resolve("127.0.0.1")); + } + + /// + /// Preserves supplied issuer certificates and presents the intermediate during the TLS handshake. + /// + /// A task that completes after the client has observed the supplied intermediate. + [Fact] + public async Task HttpsListenerPresentsSuppliedIntermediateChain() + { + // arrange + using var rootKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var rootRequest = new CertificateRequest("CN=WebExpress Test Root", rootKey, HashAlgorithmName.SHA256); + rootRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + rootRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + using var root = rootRequest.CreateSelfSigned(_clock.Now.AddDays(-2), _clock.Now.AddDays(100)); + + using var issuerKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var issuerRequest = new CertificateRequest("CN=WebExpress Test Intermediate", issuerKey, HashAlgorithmName.SHA256); + issuerRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); + issuerRequest.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign, true)); + using var issuerPublic = issuerRequest.Create(root, _clock.Now.AddDays(-1), _clock.Now.AddDays(95), RandomNumberGenerator.GetBytes(16)); + using var issuer = issuerPublic.CopyWithPrivateKey(issuerKey); + + using var leafKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=localhost", leafKey, HashAlgorithmName.SHA256); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection { new("1.3.6.1.5.5.7.3.1") }, false)); + using var leafPublic = request.Create(issuer, _clock.Now.AddHours(-1), _clock.Now.AddDays(90), RandomNumberGenerator.GetBytes(16)); + using var leaf = leafPublic.CopyWithPrivateKey(leafKey); + using var rootPublic = X509CertificateLoader.LoadCertificate(root.Export(X509ContentType.Cert)); + var bundle = new X509Certificate2Collection { leaf, issuerPublic, rootPublic }; + File.WriteAllBytes(Path.Combine(_directory, "server.pfx"), bundle.Export(X509ContentType.Pfx, "test-secret")); + + var settings = Settings(); + var port = GetAvailablePort(); + settings.Endpoints = [new() { Uri = $"https://*:{port}", CertificateAlias = "primary" }]; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) { Settings = settings }; + try + { + server.Start(); + var material = server.HttpServerContext.CertificateManager.Resolve("primary"); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken); + var receivedIntermediate = false; + using var tls = new SslStream(client.GetStream(), false, (_, _, chain, _) => + { + receivedIntermediate = chain.ChainElements.Cast() + .Any(x => x.Certificate.Thumbprint == issuer.Thumbprint); + return true; + }); + // act + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "localhost" }, + TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.Equal(2, material.Chain.Count); + Assert.Contains(material.Chain, x => x.Thumbprint == issuer.Thumbprint); + Assert.True(receivedIntermediate); + } + finally { server.Stop(); } + } + + /// + /// Proves the real Kestrel listener receives manager-owned private material and completes TLS. + /// + /// A task that completes after the TLS peer certificate has been inspected. + [Fact] + public async Task HttpsListenerCompletesHandshakeWithManagedCertificate() + { + // arrange + WriteCertificate(); + var settings = Settings(); + var port = GetAvailablePort(); + settings.Endpoints = [new() { Uri = $"https://127.0.0.1:{port}", CertificateAlias = "primary" }]; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) { Settings = settings }; + try + { + server.Start(); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + using var tls = new SslStream(client.GetStream(), false, (_, _, _, _) => true); + + // act + await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = "localhost" }, TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.True(tls.IsAuthenticated); + Assert.Equal(server.HttpServerContext.CertificateManager.Resolve("primary").Certificate.Thumbprint, + tls.RemoteCertificate.GetCertHashString()); + } + finally { server.Stop(); } + } + + /// + /// Ensures development HTTP never requires a certificate directory or a configured certificate. + /// + /// A task that completes after connecting to the HTTP listener. + [Fact] + public async Task HttpDevelopmentStartsWithoutCertificates() + { + // arrange + var port = GetAvailablePort(); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings { Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }] } + }; + + // act + try + { + server.Start(); + using var client = new TcpClient(); + await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken).AsTask().WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken); + + // validation + Assert.Empty(server.HttpServerContext.CertificateManager.GetCertificates()); + } + finally { server.Stop(); } + } + + /// + /// Prevents a failed HTTPS configuration from leaving any HTTP listener running. + /// + /// A task that completes after confirming the port remains closed. + [Fact] + public async Task InvalidHttpsAbortsStartupBeforeHttpBinds() + { + // arrange + var port = GetAvailablePort(); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings + { + Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }, new() { Uri = "https://127.0.0.1:443", CertificateAlias = "missing" }] + } + }; + // act + var exception = Record.Exception(() => server.Start()); + + // validation + Assert.IsType(exception); + using var client = new TcpClient(); + await Assert.ThrowsAsync(async () => await client.ConnectAsync(IPAddress.Loopback, port, TestContext.Current.CancellationToken)); + server.Stop(); + } + + /// + /// Finds an ephemeral loopback port without relying on a machine-specific fixed test port. + /// + /// The port just released for the test listener. + private static int GetAvailablePort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + /// + /// Removes only the isolated directory created by this test instance. + /// + public void Dispose() + { + Directory.Delete(_directory, true); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index a4a3d4ba..ceeaccd9 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -38,7 +38,7 @@ public void HttpsWithoutCertificateNeverRegistersPlainHttpListener(string scheme var addEndpoint = typeof(HttpServer).GetMethod("AddEndpoint", flags, null, [wrapper.GetType(), typeof(EndpointSettings), typeof(Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols?)], null); - addEndpoint.Invoke(server, [wrapper, endpoint, null]); + Assert.Throws(() => addEndpoint.Invoke(server, [wrapper, endpoint, null])); var listeners = typeof(Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions) .GetProperty("CodeBackedListenOptions", flags).GetValue(options); diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index eb9fca85..f8843ca2 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -2,8 +2,9 @@ using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; +using Microsoft.AspNetCore.Server.Kestrel.Https; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; @@ -14,10 +15,10 @@ using System.Net; using System.Net.Sockets; using System.Reflection; -using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebCertificate; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebIdentity; using WebExpress.WebCore.WebLog; @@ -38,6 +39,7 @@ namespace WebExpress.WebCore public class HttpServer : IHost, IHttpApplication { private readonly Lazy _authenticationEndpoint; + private Microsoft.Extensions.Hosting.IHost _webHost; /// /// Event is triggered after the web server is started. @@ -47,7 +49,7 @@ public class HttpServer : IHost, IHttpApplication /// /// Provides the KestrelServer, which responds to the requests. /// - private KestrelServer Kestrel { get; set; } + private IServer Kestrel { get; set; } /// /// Gets the server thread termination. @@ -112,7 +114,8 @@ public HttpServer(IHttpServerContext context) context.Configuration, context.Culture, context.Log, - this + this, + context.CertificateManager ); Culture = HttpServerContext.Culture; @@ -126,6 +129,32 @@ public HttpServer(IHttpServerContext context) /// public void Start() { + try + { + StartCore(); + } + catch + { + _webHost?.Dispose(); + _webHost = null; + Kestrel = null; + HttpServerContext.CertificateManager.Dispose(); + throw; + } + } + + /// + /// Validates every HTTPS certificate before opening any listener and waits for startup failures. + /// + private void StartCore() + { + var settings = Settings ?? new HttpServerSettings { Endpoints = HttpServerContext.Endpoints?.ToList() ?? [] }; + HttpServerContext.CertificateManager.Load(settings); + foreach (var endpoint in (settings.Endpoints ?? []).Where(x => x.GetBindingAddress().Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + HttpServerContext.CertificateManager.Resolve(endpoint); + } + if (HttpServerContext is not null && HttpServerContext.Log != null) { HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.run")); @@ -137,37 +166,33 @@ public void Start() } var logger = new LogFactory(); - var transportOptions = new OptionsWrapper - ( - new SocketTransportOptions() - ); - var transport = new SocketTransportFactory(transportOptions, logger); - var serviceCollection = new ServiceCollection(); - - serviceCollection.AddMemoryCache(); - serviceCollection.AddLogging(x => - { - x.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); - x.AddProvider(logger); - }); - serviceCollection.AddHttpLogging - ( - x => - { - x.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All; - } - ); + _webHost = new HostBuilder() + .ConfigureWebHost(webHost => webHost + .UseKestrel() + .ConfigureServices(services => + { + services.AddMemoryCache(); + services.AddLogging(logging => + { + logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); + logging.AddProvider(logger); + }); + services.AddHttpLogging(logging => + logging.LoggingFields = Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All); + }) + .Configure(_ => { })) + .Build(); // the kestrel settings block is optional; a missing block or property keeps the built-in defaults var kestrel = Settings?.Kestrel; - var serverOptions = new OptionsWrapper(new KestrelServerOptions() - { - AllowSynchronousIO = kestrel?.AllowSynchronousIO ?? true, - AllowResponseHeaderCompression = kestrel?.AllowResponseHeaderCompression ?? true, - AddServerHeader = kestrel?.AddServerHeader ?? true, - ApplicationServices = serviceCollection.BuildServiceProvider() - }); + var serverOptions = new OptionsWrapper + ( + _webHost.Services.GetRequiredService>().Value + ); + serverOptions.Value.AllowSynchronousIO = kestrel?.AllowSynchronousIO ?? true; + serverOptions.Value.AllowResponseHeaderCompression = kestrel?.AllowResponseHeaderCompression ?? true; + serverOptions.Value.AddServerHeader = kestrel?.AddServerHeader ?? true; var limits = serverOptions.Value.Limits; @@ -210,13 +235,13 @@ public void Start() var protocols = kestrel?.ResolveProtocols(); - foreach (var endpoint in Settings?.Endpoints ?? []) + foreach (var endpoint in settings.Endpoints ?? []) { AddEndpoint(serverOptions, endpoint, protocols); } - Kestrel = new KestrelServer(serverOptions, transport, logger); - Kestrel.StartAsync(this, ServerTokenSource.Token); + Kestrel = _webHost.Services.GetRequiredService(); + Kestrel.StartAsync(this, ServerTokenSource.Token).GetAwaiter().GetResult(); HttpServerContext.Log?.Info(message: I18N.Translate ( @@ -237,13 +262,17 @@ private void AddEndpoint(OptionsWrapper serverOptions, End { try { - var uri = new UriBuilder(endPoint.Uri); + var uri = endPoint.GetBindingAddress(); var asterisk = uri.Host.Equals("*"); + var certificate = uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + ? HttpServerContext.CertificateManager.Resolve(endPoint) : null; var port = uri.Port; - var host = asterisk ? Dns.GetHostEntry(Dns.GetHostName()) : Dns.GetHostEntry(uri.Host); - var addressList = host.AddressList - .Union(asterisk ? Dns.GetHostEntry("localhost").AddressList : []) + var addresses = asterisk + ? new[] { Socket.OSSupportsIPv6 ? IPAddress.IPv6Any : IPAddress.Any } + : IPAddress.TryParse(uri.Host.Trim('[', ']'), out var address) + ? [address] : Dns.GetHostAddresses(uri.Host); + var addressList = addresses.Distinct() .Where(x => x.AddressFamily == AddressFamily.InterNetwork || x.AddressFamily == AddressFamily.InterNetworkV6); HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.endpoint"), args: endPoint.Uri); @@ -252,11 +281,11 @@ private void AddEndpoint(OptionsWrapper serverOptions, End { var ep = new IPEndPoint(ipAddress, port); - switch (uri.Scheme) + switch (uri.Scheme.ToLowerInvariant()) { case "https": { - AddEndpoint(serverOptions, ep, endPoint.PfxFile, endPoint.Password, protocols); + AddEndpoint(serverOptions, ep, certificate, protocols); break; } default: @@ -271,6 +300,7 @@ private void AddEndpoint(OptionsWrapper serverOptions, End { HttpServerContext.Log?.Error(message: I18N.Translate("webexpress.webcore:httpserver.listen.exeption"), args: endPoint); HttpServerContext.Log?.Exception(ex); + throw; } } @@ -297,15 +327,20 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE /// /// The server options. /// The endpoint. - /// The path to the PFX file containing the certificate. - /// The password for the PFX file. + /// The validated material borrowed from the central certificate manager. /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. - private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, string pfxFile, string password, HttpProtocols? protocols) + private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, CertificateMaterial certificate, HttpProtocols? protocols) { serverOptions.Value.Listen(endPoint, configure => { - var cert = X509CertificateLoader.LoadPkcs12FromFile(pfxFile, password, X509KeyStorageFlags.DefaultKeySet); - configure.UseHttps(cert); + configure.UseHttps(new HttpsConnectionAdapterOptions + { + ServerCertificate = certificate.Certificate, + ServerCertificateChain = new System.Security.Cryptography.X509Certificates.X509Certificate2Collection + ( + certificate.Chain.ToArray() + ) + }); if (protocols is not null) { @@ -321,10 +356,20 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE /// public void Stop() { - // signal cancellation and stop server - ServerTokenSource.Cancel(); - Kestrel.StopAsync(ServerTokenSource.Token); - if (_authenticationEndpoint.IsValueCreated) { _authenticationEndpoint.Value.Dispose(); } + try + { + // certificate handles must outlive all active tls connections + Kestrel?.StopAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + finally + { + ServerTokenSource.Cancel(); + _webHost?.Dispose(); + _webHost = null; + Kestrel = null; + HttpServerContext.CertificateManager.Dispose(); + if (_authenticationEndpoint.IsValueCreated) { _authenticationEndpoint.Value.Dispose(); } + } } /// diff --git a/src/WebExpress.WebCore/HttpServerContext.cs b/src/WebExpress.WebCore/HttpServerContext.cs index 1e2d07e3..6d3ce77d 100644 --- a/src/WebExpress.WebCore/HttpServerContext.cs +++ b/src/WebExpress.WebCore/HttpServerContext.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Reflection; using Microsoft.Extensions.Configuration; +using WebExpress.WebCore.WebCertificate; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebSetting; @@ -16,6 +17,11 @@ namespace WebExpress.WebCore /// public class HttpServerContext : IHttpServerContext { + /// + /// Gets the certificate service shared with the host and application components. + /// + public ICertificateManager CertificateManager { get; } + /// /// Gets the route of the web server. /// @@ -84,6 +90,7 @@ public class HttpServerContext : IHttpServerContext /// The culture. /// The log. /// The host. + /// The optional shared certificate service owned by the host. public HttpServerContext ( IRoute route, @@ -95,7 +102,8 @@ public HttpServerContext IConfigurationRoot configuration, CultureInfo culture, ILog log, - IHost host + IHost host, + ICertificateManager certificateManager = null ) { var assembly = typeof(HttpServer).Assembly; @@ -111,6 +119,7 @@ IHost host Culture = culture; Log = log; Host = host; + CertificateManager = certificateManager ?? new CertificateManager(log); } } } diff --git a/src/WebExpress.WebCore/IHttpServerContext.cs b/src/WebExpress.WebCore/IHttpServerContext.cs index f4627d38..ccd87d97 100644 --- a/src/WebExpress.WebCore/IHttpServerContext.cs +++ b/src/WebExpress.WebCore/IHttpServerContext.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Globalization; using Microsoft.Extensions.Configuration; +using WebExpress.WebCore.WebCertificate; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebLog; using WebExpress.WebCore.WebSetting; @@ -15,6 +16,11 @@ namespace WebExpress.WebCore /// public interface IHttpServerContext { + /// + /// Gets the shared certificate service so applications never need direct storage access. + /// + ICertificateManager CertificateManager { get; } + /// /// Gets the route of the web server. /// diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index b3c3f652..7c92885c 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -209,3 +209,6 @@ validation.missing_fragment={0} muss '{1}' enthalten. validation.suffix_mismatch={0} muss mit '{1}' enden. validation.invalid_enum={0} muss ein gültiger {1}-Wert sein. validation.invalid_date={0} muss ein gültiges Datum sein. +certificate.invalid=Das Zertifikat {0} kann nicht für HTTPS verwendet werden: {1}. +certificate.expiring=Das Zertifikat {0} läuft am {1} ab; seine verbleibende Gültigkeit hat die Warnschwelle von {2} Tagen erreicht. +certificate.load_failed=Das Zertifikat {0} konnte nicht aus dem Speicher {1} geladen werden. Referenz, Zugriffsrechte, PFX-Inhalt und Zugangsdaten prüfen. diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 536d2790..8147376e 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -209,4 +209,6 @@ validation.missing_fragment={0} must contain '{1}'. validation.suffix_mismatch={0} must end with '{1}'. validation.invalid_enum={0} must be a valid {1} value. validation.invalid_date={0} must be a valid date. - +certificate.invalid=Certificate {0} cannot be used for HTTPS: {1}. +certificate.expiring=Certificate {0} expires at {1}; its remaining lifetime reached the warning threshold of {2} days. +certificate.load_failed=Certificate {0} could not be loaded from store {1}. Check the reference, access permissions, PFX content and credential. diff --git a/src/WebExpress.WebCore/WebCertificate/CertificateInfo.cs b/src/WebExpress.WebCore/WebCertificate/CertificateInfo.cs new file mode 100644 index 00000000..3d1669eb --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/CertificateInfo.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Exposes a metadata snapshot without revealing credentials or private key material. + /// + public sealed class CertificateInfo + { + /// + /// Gets the stable name applications can resolve. + /// + public string Alias { get; internal init; } + + /// + /// Gets the configured concrete host mappings. + /// + public IReadOnlyList HostNames { get; internal init; } + + /// + /// Gets the store name for operational diagnostics. + /// + public string Store { get; internal init; } + + /// + /// Gets the certificate subject, or null if loading failed. + /// + public string Subject { get; internal init; } + + /// + /// Gets the certificate issuer, or null if loading failed. + /// + public string Issuer { get; internal init; } + + /// + /// Gets the certificate thumbprint for identifying a deployed version. + /// + public string Thumbprint { get; internal init; } + + /// + /// Gets the UTC validity start, or null if loading failed. + /// + public DateTimeOffset? NotBefore { get; internal init; } + + /// + /// Gets the UTC expiry time, or null if loading failed. + /// + public DateTimeOffset? NotAfter { get; internal init; } + + /// + /// Gets the suitability status evaluated when this snapshot was requested. + /// + public CertificateStatus Status { get; internal init; } + + /// + /// Gets whether the material can be handed to a TLS listener. + /// + public bool IsUsable => (Status & ~CertificateStatus.ExpiringSoon) == CertificateStatus.Valid; + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/CertificateManager.cs b/src/WebExpress.WebCore/WebCertificate/CertificateManager.cs new file mode 100644 index 00000000..3cf67fd8 --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/CertificateManager.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Security.Cryptography.X509Certificates; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Owns certificate material and validates it before hosts or applications can use it. + /// Store registration and inventory replacement are serialized with resolution. + /// + public sealed class CertificateManager : ICertificateManager + { + private readonly object _sync = new(); + private readonly ILog _log; + private readonly TimeProvider _timeProvider; + private readonly Dictionary _stores = new(StringComparer.OrdinalIgnoreCase); + private readonly List _materials = []; + private Dictionary _lookup = new(StringComparer.OrdinalIgnoreCase); + private Entry[] _entries = []; + private int[] _thresholds = []; + private bool _disposed; + + /// + /// Keeps validated mappings separate from credentials that are needed only while loading. + /// + /// The canonical configured alias. + /// The registered store name. + /// The validated concrete hostname mappings. + /// The owned material, or null after a load failure. + /// The suitability checks that do not change with time. + private sealed class Entry(string alias, string store, string[] hostNames, CertificateMaterial material, CertificateStatus status) + { + internal readonly string Alias = alias; + internal readonly string Store = store; + internal readonly string[] HostNames = hostNames; + internal readonly CertificateMaterial Material = material; + internal readonly CertificateStatus Status = status; + } + + /// + /// Connects validation to the server log and an injectable UTC clock. + /// + /// The shared log, or null when diagnostics are not required. + /// The clock used for validity checks, defaulting to the system clock. + public CertificateManager(ILog log = null, TimeProvider timeProvider = null) + { + _log = log; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + /// Registers a store for use by future inventory loads. The "file" store is reserved for the built-in file system provider. + /// + /// The certificate store to register. + /// Thrown when the store name is already registered or reserved. + public void RegisterStore(ICertificateStore store) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrWhiteSpace(store.Name); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (store.Name.Equals("file", StringComparison.OrdinalIgnoreCase) || !_stores.TryAdd(store.Name, store)) + { + throw new ArgumentException("The certificate store name is already registered or reserved.", nameof(store)); + } + } + } + + /// + /// Replaces the inventory with a new set of registrations and validates them for immediate use. + /// + /// The HTTP server settings containing certificate configurations. + /// Thrown when certificate warning thresholds are negative. + public void Load(HttpServerSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var options = settings.Certificates ?? new CertificateManagerSettings(); + var thresholds = (options.WarningThresholdDays ?? [30, 14, 7]).Distinct().Order().ToArray(); + if (thresholds.Any(x => x < 0)) + { + throw new ArgumentException("Certificate warning thresholds must not be negative.", nameof(settings)); + } + + var registrations = GetRegistrations(settings); + var fileStore = new FileCertificateStore(options.Directory); + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var entries = new List(); + try + { + foreach (var registration in registrations) + { + var store = registration.Store.Equals("file", StringComparison.OrdinalIgnoreCase) + ? fileStore : _stores.GetValueOrDefault(registration.Store); + if (store is null) + { + throw new InvalidOperationException($"Certificate store '{registration.Store}' is not registered."); + } + + var entry = LoadEntry(registration, store); + entries.Add(entry); + foreach (var key in entry.HostNames.Prepend(entry.Alias).Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (!lookup.TryAdd(key, entry)) + { + throw new InvalidOperationException($"Certificate alias or hostname '{key}' is ambiguous."); + } + } + } + } + catch + { + foreach (var entry in entries) { entry.Material?.Dispose(); } + throw; + } + + // borrowed material may still be used by a listener after an explicit inventory reload + _materials.AddRange(entries.Where(x => x.Material is not null).Select(x => x.Material)); + _entries = entries.ToArray(); + _lookup = lookup; + _thresholds = thresholds; + + foreach (var entry in _entries) + { + var info = GetInfo(entry); + if (!info.IsUsable) + { + _log?.Warning(Translate("webexpress.webcore:certificate.invalid", + "Certificate {0} cannot be used for HTTPS: {1}.", info.Alias, info.Status)); + } + else if (info.Status.HasFlag(CertificateStatus.ExpiringSoon)) + { + var remainingDays = (info.NotAfter.Value - _timeProvider.GetUtcNow()).TotalDays; + var threshold = thresholds.First(x => remainingDays <= x); + _log?.Warning(Translate("webexpress.webcore:certificate.expiring", + "Certificate {0} expires at {1}; its remaining lifetime reached the warning threshold of {2} days.", info.Alias, + info.NotAfter.Value.ToString("O", CultureInfo.InvariantCulture), threshold)); + } + } + } + } + + /// + /// Resolves a certificate by its alias or a concrete hostname that is covered by the certificate's subject alternative names. + /// + /// The alias or hostname of the certificate to resolve. + /// The resolved certificate material. + /// Thrown when the certificate is not configured. + /// Thrown when the certificate cannot be used for HTTPS. + public CertificateMaterial Resolve(string aliasOrHostName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(aliasOrHostName); + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_lookup.TryGetValue(NormalizeKey(aliasOrHostName), out var entry)) + { + throw new KeyNotFoundException($"Certificate '{aliasOrHostName}' is not configured."); + } + + var info = GetInfo(entry); + if (!info.IsUsable) + { + throw new InvalidOperationException($"Certificate '{info.Alias}' cannot be used for HTTPS: {info.Status}."); + } + + return entry.Material; + } + } + + /// + /// Resolves a certificate for an HTTPS endpoint, ensuring that the certificate covers the endpoint's hostname. + /// + /// The HTTPS endpoint for which to resolve a certificate. + /// The resolved certificate material. + /// Thrown when the endpoint is not an HTTPS endpoint. + /// Thrown when the certificate does not cover the endpoint's hostname. + public CertificateMaterial Resolve(EndpointSettings endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + var uri = endpoint.GetBindingAddress(); + if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Certificate resolution requires an HTTPS endpoint.", nameof(endpoint)); + } + + var material = Resolve(GetEndpointKey(endpoint)); + if (uri.Host != "*" && !material.Certificate.MatchesHostname(NormalizeHostName(uri.Host), allowCommonName: false)) + { + throw new InvalidOperationException($"The certificate does not cover HTTPS endpoint '{endpoint.Uri}'."); + } + + return material; + } + + /// + /// Returns a snapshot of the current inventory with validity and expiry warnings evaluated at the current time. + /// + /// A read-only list of certificate information. + public IReadOnlyList GetCertificates() + { + lock (_sync) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return Array.AsReadOnly(_entries.Select(GetInfo).ToArray()); + } + } + + /// + /// Combines shared definitions with inline endpoint definitions without modifying bound settings. + /// + /// The configuration to convert into unique registrations. + /// The registrations with normalized aliases and concrete hostname mappings. + private static IEnumerable GetRegistrations(HttpServerSettings settings) + { + var registrations = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in settings.Certificates?.Items ?? []) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentException.ThrowIfNullOrWhiteSpace(item.Alias); + ArgumentException.ThrowIfNullOrWhiteSpace(item.Store); + ArgumentException.ThrowIfNullOrWhiteSpace(item.Reference); + var registration = new CertificateSettings + { + Alias = NormalizeKey(item.Alias), + Store = item.Store, + Reference = item.Reference, + Password = item.Password, + HostNames = (item.HostNames ?? []).Select(NormalizeHostName).Distinct(StringComparer.OrdinalIgnoreCase).ToArray() + }; + if (!registrations.TryAdd(registration.Alias, registration)) + { + throw new InvalidOperationException($"Certificate alias '{registration.Alias}' is duplicated."); + } + } + + foreach (var endpoint in settings.Endpoints ?? []) + { + var uri = endpoint.GetBindingAddress(); + if (!uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) { continue; } + var key = NormalizeKey(GetEndpointKey(endpoint)); + registrations.TryGetValue(key, out var registration); + if (!string.IsNullOrWhiteSpace(endpoint.PfxFile)) + { + if (registration is not null && (!registration.Store.Equals("file", StringComparison.OrdinalIgnoreCase) || + registration.Reference != endpoint.PfxFile || registration.Password != endpoint.Password)) + { + throw new InvalidOperationException($"Certificate alias '{key}' has conflicting definitions."); + } + if (registration is null) + { + registration = new CertificateSettings + { + Alias = key, + Reference = endpoint.PfxFile, + Password = endpoint.Password + }; + registrations.Add(key, registration); + } + } + } + + // resolve mappings after all inline declarations so configuration order cannot hide a hostname + foreach (var endpoint in settings.Endpoints ?? []) + { + var uri = endpoint.GetBindingAddress(); + if (uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && uri.Host != "*" && + registrations.TryGetValue(NormalizeKey(GetEndpointKey(endpoint)), out var registration)) + { + registration.HostNames = registration.HostNames.Append(NormalizeHostName(uri.Host)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + } + + return registrations.Values; + } + + /// + /// Preserves inline endpoint identity when no explicit alias was supplied. + /// + /// The endpoint whose lookup identity is needed. + /// The alias, inline endpoint identity, or concrete hostname. + private static string GetEndpointKey(EndpointSettings endpoint) + { + if (!string.IsNullOrWhiteSpace(endpoint.CertificateAlias)) { return endpoint.CertificateAlias; } + return !string.IsNullOrWhiteSpace(endpoint.PfxFile) ? endpoint.Uri : endpoint.GetBindingAddress().Host; + } + + /// + /// Converts a store failure into inspectable status while excluding store exception text from logs. + /// + /// The credential-bearing registration used only for this load. + /// The provider selected by the registration. + /// The loaded entry or a metadata-only failure entry. + private Entry LoadEntry(CertificateSettings registration, ICertificateStore store) + { + CertificateMaterial material = null; + try + { + material = store.Load(registration.Reference, registration.Password) + ?? throw new InvalidOperationException("The certificate store returned no material."); + var status = Validate(material.Certificate, registration.HostNames); + return new Entry(registration.Alias, store.Name, registration.HostNames, material, status); + } + catch (Exception) + { + // provider exception messages can contain credentials or secret store request details + material?.Dispose(); + _log?.Error(Translate("webexpress.webcore:certificate.load_failed", + "Certificate {0} could not be loaded from store {1}. Check the reference, access permissions, PFX content and credential.", + registration.Alias, store.Name)); + return new Entry(registration.Alias, store.Name, registration.HostNames, null, CertificateStatus.LoadFailed); + } + } + + /// + /// Enforces local TLS suitability without depending on network trust or revocation services. + /// + /// The leaf certificate to validate. + /// The concrete hostnames that must be covered by subject alternative names. + /// The combined suitability failures independent of the current time. + private static CertificateStatus Validate(X509Certificate2 certificate, IEnumerable hostNames) + { + var status = certificate.HasPrivateKey ? CertificateStatus.Valid : CertificateStatus.MissingPrivateKey; + if (certificate.Extensions.OfType().Any(x => x.CertificateAuthority) || + certificate.Extensions.OfType().Any(x => + !x.EnhancedKeyUsages.Cast().Any(oid => oid.Value == "1.3.6.1.5.5.7.3.1")) || + certificate.Extensions.OfType().Any(x => + (x.KeyUsages & X509KeyUsageFlags.DigitalSignature) == 0)) + { + status |= CertificateStatus.InvalidUsage; + } + + if (hostNames.Any(x => !certificate.MatchesHostname(x, allowCommonName: false))) + { + status |= CertificateStatus.HostNameMismatch; + } + + return status; + } + + /// + /// Rechecks time on every read so a certificate cannot remain usable merely because startup succeeded. + /// + /// The immutable inventory entry. + /// The current metadata snapshot. + private CertificateInfo GetInfo(Entry entry) + { + var certificate = entry.Material?.Certificate; + var status = entry.Status; + DateTimeOffset? notBefore = certificate?.NotBefore.ToUniversalTime(); + DateTimeOffset? notAfter = certificate?.NotAfter.ToUniversalTime(); + var now = _timeProvider.GetUtcNow(); + if (notBefore.HasValue && now < notBefore.Value) { status |= CertificateStatus.NotYetValid; } + if (notAfter.HasValue) + { + if (now >= notAfter.Value) { status |= CertificateStatus.Expired; } + else if (_thresholds.Any(x => (notAfter.Value - now).TotalDays <= x)) { status |= CertificateStatus.ExpiringSoon; } + } + + return new CertificateInfo + { + Alias = entry.Alias, + Store = entry.Store, + HostNames = Array.AsReadOnly(entry.HostNames), + Subject = certificate?.Subject, + Issuer = certificate?.Issuer, + Thumbprint = certificate?.Thumbprint, + NotBefore = notBefore, + NotAfter = notAfter, + Status = status + }; + } + + /// + /// Canonicalizes concrete hostnames so lookup and certificate coverage use identical identities. + /// + /// The configured DNS name or IP address without a port. + /// The ASCII DNS name or canonical IP address. + private static string NormalizeHostName(string hostName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(hostName); + var value = hostName.Trim().TrimEnd('.'); + if (IPAddress.TryParse(value.Trim('[', ']'), out var address)) { return address.ToString(); } + value = new IdnMapping().GetAscii(value); + if (Uri.CheckHostName(value) != UriHostNameType.Dns) + { + throw new ArgumentException("Certificate host mappings require concrete DNS names or IP addresses.", nameof(hostName)); + } + return value.ToLowerInvariant(); + } + + /// + /// Makes DNS aliases and hostname lookups agree while retaining non-DNS aliases such as endpoint URIs. + /// + /// The alias or hostname to use as a dictionary key. + /// The canonical lookup key. + private static string NormalizeKey(string value) + { + value = value.Trim().TrimEnd('.'); + ArgumentException.ThrowIfNullOrWhiteSpace(value); + return Uri.CheckHostName(value.Trim('[', ']')) != UriHostNameType.Unknown ? NormalizeHostName(value) : value; + } + + /// + /// Keeps startup diagnostics actionable when the manager is used before translation resources are registered. + /// + /// The translation key in the Core resources. + /// The English format used when resources are unavailable. + /// The non-secret values included in the diagnostic. + /// The formatted diagnostic in the available language. + private static string Translate(string key, string fallback, params object[] args) + { + var format = I18N.Translate(key); + return string.Format(CultureInfo.CurrentCulture, format == key ? fallback : format, args); + } + + /// + /// Releases active and retired material after hosting has stopped using borrowed certificates. + /// Registered stores remain owned by their supplying modules. + /// + public void Dispose() + { + lock (_sync) + { + if (_disposed) { return; } + _disposed = true; + foreach (var material in _materials) { material.Dispose(); } + _materials.Clear(); + _entries = []; + _lookup.Clear(); + _stores.Clear(); + } + } + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/CertificateMaterial.cs b/src/WebExpress.WebCore/WebCertificate/CertificateMaterial.cs new file mode 100644 index 00000000..c53e27ff --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/CertificateMaterial.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Keeps a server certificate and its supplied chain alive for the same hosting lifetime. + /// Consumers borrow this material and must not dispose or modify its certificates. + /// + public sealed class CertificateMaterial : IDisposable + { + private bool _disposed; + + /// + /// Gets the leaf certificate that authenticates the server. + /// + public X509Certificate2 Certificate { get; } + + /// + /// Gets the additional certificates that allow the host to send the supplied chain. + /// + public IReadOnlyList Chain { get; } + + /// + /// Takes ownership of the supplied certificates so a store can transfer them as one unit. + /// + /// The leaf certificate, normally including its private key. + /// The optional issuer certificates owned by this material. + public CertificateMaterial(X509Certificate2 certificate, IEnumerable chain = null) + { + ArgumentNullException.ThrowIfNull(certificate); + Certificate = certificate; + Chain = Array.AsReadOnly((chain ?? []).Where(x => !ReferenceEquals(x, certificate)) + .Distinct(ReferenceEqualityComparer.Instance).ToArray()); + } + + /// + /// Releases the native certificate handles after all hosting consumers have stopped. + /// + public void Dispose() + { + if (_disposed) { return; } + _disposed = true; + Certificate.Dispose(); + foreach (var certificate in Chain) { certificate.Dispose(); } + } + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/CertificateStatus.cs b/src/WebExpress.WebCore/WebCertificate/CertificateStatus.cs new file mode 100644 index 00000000..caf3f387 --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/CertificateStatus.cs @@ -0,0 +1,51 @@ +using System; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Describes local TLS suitability independently of client trust and revocation services. + /// + [Flags] + public enum CertificateStatus + { + /// + /// Indicates that all local suitability checks passed. + /// + Valid = 0, + + /// + /// Indicates that a warning threshold was reached without preventing use. + /// + ExpiringSoon = 1, + + /// + /// Prevents use after the certificate validity period. + /// + Expired = 2, + + /// + /// Prevents use before the certificate validity period. + /// + NotYetValid = 4, + + /// + /// Prevents use when the server cannot prove possession of the private key. + /// + MissingPrivateKey = 8, + + /// + /// Prevents use of CA certificates or certificates whose usages exclude TLS server authentication. + /// + InvalidUsage = 16, + + /// + /// Prevents use when a configured hostname is not covered by a subject alternative name. + /// + HostNameMismatch = 32, + + /// + /// Indicates that the store could not provide readable certificate material. + /// + LoadFailed = 64 + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/FileCertificateStore.cs b/src/WebExpress.WebCore/WebCertificate/FileCertificateStore.cs new file mode 100644 index 00000000..e35526d0 --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/FileCertificateStore.cs @@ -0,0 +1,65 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Isolates PFX file access and keeps platform-specific key storage out of certificate consumers. + /// + public sealed class FileCertificateStore : ICertificateStore + { + private readonly string _directory; + + /// + /// Gets the store name for operational diagnostics. + /// + public string Name => "file"; + + /// + /// Anchors relative references once so loading does not depend on later working directory changes. + /// + /// The base directory for relative PFX paths. + public FileCertificateStore(string directory) + { + _directory = Path.GetFullPath(string.IsNullOrWhiteSpace(directory) ? "." : directory); + } + + /// + /// Loads a certificate from a PFX file. + /// + /// The path to the PFX file. + /// The password for the PFX file. + /// The loaded certificate material. + public CertificateMaterial Load(string reference, string password) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reference); + // schannel needs a temporary key container on windows; disposal removes it + var keyStorage = OperatingSystem.IsWindows() ? X509KeyStorageFlags.DefaultKeySet : X509KeyStorageFlags.EphemeralKeySet; + var certificates = X509CertificateLoader.LoadPkcs12CollectionFromFile + ( + Path.GetFullPath(reference, _directory), password, keyStorage + ); + + try + { + var leaves = certificates.Cast().Where(x => x.HasPrivateKey).ToArray(); + if (leaves.Length > 1 || certificates.Count == 0) + { + throw new CryptographicException("A PFX must identify exactly one server certificate."); + } + + // keyless material remains inspectable through the manager's validation status + var leaf = leaves.SingleOrDefault() ?? certificates[0]; + return new CertificateMaterial(leaf, certificates.Cast()); + } + catch + { + foreach (var certificate in certificates) { certificate.Dispose(); } + throw; + } + } + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/ICertificateManager.cs b/src/WebExpress.WebCore/WebCertificate/ICertificateManager.cs new file mode 100644 index 00000000..6528ed46 --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/ICertificateManager.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Provides the only certificate loading and resolution boundary used by applications and hosts. + /// + public interface ICertificateManager : IDisposable + { + /// + /// Registers a store supplied by a module before its certificates are loaded. + /// + /// The externally owned store whose name must be unique. + void RegisterStore(ICertificateStore store); + + /// + /// Loads the configured inventory and endpoint certificates before listeners are started. + /// Previously returned material remains alive until this manager is disposed. + /// + /// The server configuration containing certificates and endpoint references. + void Load(HttpServerSettings settings); + + /// + /// Resolves usable material by a case-insensitive alias or configured hostname. + /// + /// The configured lookup key. + /// Borrowed material that the caller must not modify or dispose. + CertificateMaterial Resolve(string aliasOrHostName); + + /// + /// Resolves usable material and verifies coverage of a concrete HTTPS endpoint hostname. + /// + /// The endpoint whose alias takes precedence over its hostname. + /// Borrowed material that the caller must not modify or dispose. + CertificateMaterial Resolve(EndpointSettings endpoint); + + /// + /// Supplies current metadata and status without disclosing private material. + /// + /// A snapshot of every configured certificate, including failed entries. + IReadOnlyList GetCertificates(); + } +} diff --git a/src/WebExpress.WebCore/WebCertificate/ICertificateStore.cs b/src/WebExpress.WebCore/WebCertificate/ICertificateStore.cs new file mode 100644 index 00000000..e4fc96a9 --- /dev/null +++ b/src/WebExpress.WebCore/WebCertificate/ICertificateStore.cs @@ -0,0 +1,21 @@ +namespace WebExpress.WebCore.WebCertificate +{ + /// + /// Allows modules to supply certificate material without exposing their storage to applications. + /// + public interface ICertificateStore + { + /// + /// Gets the case-insensitive name selected by certificate configuration. + /// + string Name { get; } + + /// + /// Transfers newly owned certificate material to the manager for validation and lifetime management. + /// + /// The identifier interpreted by this store. + /// The optional credential needed to read the material. + /// The material whose certificates must not be disposed by the store after return. + CertificateMaterial Load(string reference, string password); + } +} diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index 70ca3c66..cc5d9fa4 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -4,6 +4,7 @@ using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAsset; +using WebExpress.WebCore.WebCertificate; using WebExpress.WebCore.WebComponent.Model; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebEvent; @@ -59,6 +60,11 @@ public class ComponentHub : IComponentHub private readonly ThemeManager _themeManager; private int _lastCounter = 0; + /// + /// Gets the host-owned certificate service without creating a second certificate inventory. + /// + public ICertificateManager CertificateManager => _httpServerContext.CertificateManager; + /// /// An event that fires when an component is added. /// diff --git a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs index a5b450b6..c6e0e6ca 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs @@ -3,6 +3,7 @@ using WebExpress.WebCore.Internationalization; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebAsset; +using WebExpress.WebCore.WebCertificate; using WebExpress.WebCore.WebEndpoint; using WebExpress.WebCore.WebEvent; using WebExpress.WebCore.WebFragment; @@ -30,6 +31,11 @@ namespace WebExpress.WebCore.WebComponent /// public interface IComponentHub : IComponentManager { + /// + /// Gets the shared certificate service used by hosting and application components. + /// + ICertificateManager CertificateManager { get; } + /// /// An event that fires when an component is added. /// diff --git a/src/WebExpress.WebCore/WebMessage/RequestBase.cs b/src/WebExpress.WebCore/WebMessage/RequestBase.cs index 27bfb6b2..02cb7ca9 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestBase.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestBase.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using System; using System.Collections.Generic; using System.Globalization; @@ -162,14 +163,15 @@ internal RequestBase(IFeatureCollection contextFeatures, RequestHeaderFields hea LocalEndPoint = new IPEndPoint(connectionFeature.LocalIpAddress, connectionFeature.LocalPort); RemoteEndPoint = new IPEndPoint(connectionFeature.RemoteIpAddress, connectionFeature.RemotePort); + var requestHost = new HostString(Header.Host); Uri = new UriEndpoint ( Scheme, new UriAuthority() { - Host = Header.Host, - Port = connectionFeature.LocalPort + Host = requestHost.Host, + Port = requestHost.Port ?? connectionFeature.LocalPort }, requestFeature.RawTarget ); diff --git a/src/WebExpress.WebCore/WebSetting/CertificateManagerSettings.cs b/src/WebExpress.WebCore/WebSetting/CertificateManagerSettings.cs new file mode 100644 index 00000000..135e3634 --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/CertificateManagerSettings.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Defines the certificate inventory independently of the listeners that consume it. + /// + public sealed class CertificateManagerSettings + { + /// + /// Gets or sets the base directory for relative PFX references, relative to the working directory. + /// + public string Directory { get; set; } = "."; + + /// + /// Gets or sets the remaining lifetime thresholds in days that trigger expiry warnings. + /// A missing value uses 30, 14 and 7 days; an empty array disables expiry warnings. + /// + public int[] WarningThresholdDays { get; set; } + + /// + /// Gets or sets the certificates that can be shared by listeners and applications. + /// + public List Items { get; set; } = []; + } +} diff --git a/src/WebExpress.WebCore/WebSetting/CertificateSettings.cs b/src/WebExpress.WebCore/WebSetting/CertificateSettings.cs new file mode 100644 index 00000000..1ad27f9f --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/CertificateSettings.cs @@ -0,0 +1,33 @@ +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Identifies certificate material without coupling consumers to its storage technology. + /// + public sealed class CertificateSettings + { + /// + /// Gets or sets the unique, case-insensitive name used by consumers. + /// + public string Alias { get; set; } + + /// + /// Gets or sets the concrete DNS names or IP addresses that must be covered by the certificate. + /// + public string[] HostNames { get; set; } = []; + + /// + /// Gets or sets the registered store responsible for interpreting the reference. + /// + public string Store { get; set; } = "file"; + + /// + /// Gets or sets the store-specific reference, which is a PFX path for the file store. + /// + public string Reference { get; set; } + + /// + /// Gets or sets the optional credential passed only to the store that loads the material. + /// + public string Password { get; set; } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs b/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs index 2662a455..ffd6fbba 100644 --- a/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/EndpointSettings.cs @@ -1,9 +1,11 @@ -namespace WebExpress.WebCore.WebSetting +using Microsoft.AspNetCore.Http; +using System; + +namespace WebExpress.WebCore.WebSetting { /// - /// One address the web server listens on. An https endpoint additionally names the pfx file - /// that holds its certificate, because Kestrel needs the certificate before the first - /// connection can be accepted. + /// Defines a listener whose production HTTPS certificate is resolved by the central certificate manager. + /// Development endpoints continue to use HTTP without certificate configuration. /// public sealed class EndpointSettings { @@ -14,7 +16,8 @@ public sealed class EndpointSettings public string Uri { get; set; } /// - /// The certificate as a pfx file. Only needed for https. + /// Gets or sets an inline PFX reference relative to the configured certificate directory, or an absolute path. + /// HTTPS endpoints can omit this when an alias or hostname resolves an inventory entry. /// public string PfxFile { get; set; } @@ -23,6 +26,28 @@ public sealed class EndpointSettings /// public string Password { get; set; } + /// + /// Gets or sets the certificate alias, optionally naming an inline PFX definition for reuse. + /// When omitted, the manager uses the inline endpoint identity or a configured hostname mapping. + /// + public string CertificateAlias { get; set; } + + /// + /// Parses wildcard bindings consistently for certificate resolution and listener registration. + /// + /// The validated HTTP or HTTPS binding address. + internal BindingAddress GetBindingAddress() + { + var address = BindingAddress.Parse(Uri); + if ((!address.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && + !address.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) || + address.Port < 0 || address.Port > 65535) + { + throw new ArgumentException("An endpoint requires an HTTP or HTTPS address with a valid port.", nameof(Uri)); + } + return address; + } + /// /// Conversion into its string representation. /// diff --git a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs index 162ee4e4..817c5206 100644 --- a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs @@ -33,6 +33,11 @@ public sealed class HttpServerSettings /// public List Endpoints { get; set; } = []; + /// + /// Gets or sets the shared certificate inventory and expiry warning policy used for production HTTPS. + /// + public CertificateManagerSettings Certificates { get; set; } + /// /// Optional fine-tuning of the underlying Kestrel server, including all request limits. When /// the block is omitted the web server keeps its built-in defaults, so this block only ever From 4c0542597d030f2d28c50a414eb5520c5bb950da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Tue, 22 Sep 2026 20:58:35 +0200 Subject: [PATCH 40/69] refactor: introduce IdentityTokenStoreManager and merge IdentityTokenService into IdentityManager --- .../Fixture/AuthenticationFixture.cs | 44 +- .../Manager/UnitTestIdentityAuthentication.cs | 82 ++-- .../UnitTestIdentityTokenStoreManager.cs | 141 ++++++ .../UnitTestOpenIdConnectIdentityProvider.cs | 14 +- .../WebComponent/ComponentHub.cs | 9 + .../WebComponent/IComponentHub.cs | 5 + .../WebIdentity/AuthenticationEndpoint.cs | 8 +- .../WebIdentity/IIdentityTokenStoreManager.cs | 42 ++ .../IdentityManager.Authentication.cs | 55 +-- .../WebIdentity/IdentityManager.Token.cs | 406 ++++++++++++++++++ .../WebIdentity/IdentityTokenService.cs | 337 --------------- .../WebIdentity/IdentityTokenStoreManager.cs | 229 ++++++++++ .../OpenIdConnectIdentityProvider.cs | 20 +- .../WebSetting/AuthenticationSettings.cs | 1 + 14 files changed, 938 insertions(+), 455 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IIdentityTokenStoreManager.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs delete mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs create mode 100644 src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs index a0f461cb..b4dc6956 100644 --- a/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs @@ -24,30 +24,53 @@ internal sealed class AuthenticationFixture : IDisposable internal IHttpServerContext Server { get; } internal IApplicationContext Application { get; } internal IdentityManager Manager => (IdentityManager)Hub.IdentityManager; - internal IdentityTokenService Tokens { get; } internal TestClock Clock { get; } = new(); /// /// Creates an isolated signing authority and durable token directory for each test. /// /// Whether the test retains the production transport requirement. - internal AuthenticationFixture(bool requireHttps = true) + /// Whether the deployment configures a location for the default file store. + internal AuthenticationFixture(bool requireHttps = true, bool withTokenStorePath = true) { Settings.RequireHttps = requireHttps; - var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + if (!withTokenStorePath) { Settings.TokenStorePath = null; } + var configuration = Configuration(); + Server = UnitTestFixture.CreateHttpServerContextMock(configuration: configuration); + Hub = UnitTestFixture.CreateComponentHubMock(Server); + ((PluginManager)Hub.PluginManager).Register(); + Application = Hub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + configuration["WebExpress:Authentication:ApplicationId"] = Application.ApplicationId; + Manager.Clock = Clock; + } + + /// + /// Simulates another server instance that shares the hub's token store but reads the current settings, + /// so a changed issuer or signing key yields an independent signing authority. + /// + /// A separate identity manager driven by the fixture clock. + internal IdentityManager Instance() + { + var server = UnitTestFixture.CreateHttpServerContextMock(configuration: Configuration()); + var manager = ComponentActivator.CreateInstance(typeof(IdentityManager), server, Hub); + manager.Clock = Clock; + return manager; + } + + /// + /// Projects the current settings into the configuration section read by the identity manager. + /// + /// The configuration containing the authentication section. + private IConfigurationRoot Configuration() + { + return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["WebExpress:Authentication:Issuer"] = Settings.Issuer, ["WebExpress:Authentication:Audience"] = Settings.Audience, ["WebExpress:Authentication:SigningKey"] = Settings.SigningKey, - ["WebExpress:Authentication:RequireHttps"] = requireHttps.ToString(), + ["WebExpress:Authentication:RequireHttps"] = Settings.RequireHttps.ToString(), ["WebExpress:Authentication:TokenStorePath"] = Settings.TokenStorePath }).Build(); - Server = UnitTestFixture.CreateHttpServerContextMock(configuration: configuration); - Hub = UnitTestFixture.CreateComponentHubMock(Server); - ((PluginManager)Hub.PluginManager).Register(); - Application = Hub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); - configuration["WebExpress:Authentication:ApplicationId"] = Application.ApplicationId; - Tokens = new IdentityTokenService(Settings, new FileIdentityTokenStore(Settings.TokenStorePath), Clock); } /// @@ -77,6 +100,7 @@ internal RequestBase Request(string headers = "", string method = "GET", string public void Dispose() { Hub.IdentityProviderManager.Dispose(); + Hub.IdentityTokenStoreManager.Dispose(); if (Directory.Exists(Settings.TokenStorePath)) { Directory.Delete(Settings.TokenStorePath, true); } } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs index e415068f..b127a866 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityAuthentication.cs @@ -26,8 +26,8 @@ public void LoginSurvivesInstanceChangeWithoutSession() var pair = fixture.Manager.Login(source, request); Assert.Null(request.ExistingSession); Assert.Null(fixture.Manager.Login(null, request)); - var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath)); - var identity = other.ValidateAccessToken(pair.AccessToken, fixture.Application.ApplicationId); + var other = fixture.Instance(); + var identity = other.ValidateAccessToken(pair.AccessToken, fixture.Application); Assert.Equal(source.Id, identity.Id); Assert.Equal(source.Roles, identity.Roles); Assert.Null(identity.PasswordHash); @@ -75,25 +75,25 @@ public void TokenValidationEnforcesEveryTrustBoundary() { using var fixture = new AuthenticationFixture(); var identity = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); - var app = fixture.Application.ApplicationId; - var pair = fixture.Tokens.Issue(identity, app); + var app = fixture.Application; + var pair = fixture.Manager.Issue(identity, app); var parts = pair.AccessToken.Split('.'); parts[1] = Base64UrlEncoder.Encode(Base64UrlEncoder.Decode(parts[1]).Replace("alice", "admin")); - Assert.Null(fixture.Tokens.ValidateAccessToken(string.Join('.', parts), app)); - Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, "another-application")); - Assert.Null(fixture.Tokens.ValidateAccessToken(pair.RefreshToken, app)); - Assert.Null(fixture.Tokens.Refresh(pair.AccessToken, app)); - Assert.Null(fixture.Tokens.ValidateAccessToken("not.a.jwt", app)); + Assert.Null(fixture.Manager.ValidateAccessToken(string.Join('.', parts), app)); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First())); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.Refresh(pair.AccessToken, app)); + Assert.Null(fixture.Manager.ValidateAccessToken("not.a.jwt", app)); fixture.Settings.Issuer = "https://another-authority.test"; - var wrongIssuer = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + var wrongIssuer = fixture.Instance(); Assert.Null(wrongIssuer.ValidateAccessToken(pair.AccessToken, app)); fixture.Settings.Issuer = "https://webexpress.test"; fixture.Settings.SigningKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - var wrongKey = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + var wrongKey = fixture.Instance(); Assert.Null(wrongKey.ValidateAccessToken(pair.AccessToken, app)); fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); - Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, app)); - Assert.NotNull(fixture.Tokens.Refresh(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + Assert.NotNull(fixture.Manager.Refresh(pair.RefreshToken, app)); } /// @@ -103,18 +103,18 @@ public void TokenValidationEnforcesEveryTrustBoundary() public void RefreshRotatesOnceAndRetainsAbsoluteExpiry() { using var fixture = new AuthenticationFixture(); - var app = fixture.Application.ApplicationId; - var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); fixture.Clock.Now += TimeSpan.FromMinutes(10); - var next = fixture.Tokens.Refresh(pair.RefreshToken, app); + var next = fixture.Manager.Refresh(pair.RefreshToken, app); Assert.NotEqual(pair.RefreshToken, next.RefreshToken); Assert.Equal(pair.RefreshTokenExpiresAt.ToUnixTimeSeconds(), next.RefreshTokenExpiresAt.ToUnixTimeSeconds()); - var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + var other = fixture.Instance(); Assert.Null(other.Refresh(pair.RefreshToken, app)); - Assert.Null(fixture.Tokens.Refresh(next.RefreshToken, app)); - var fresh = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "bob"), app); + Assert.Null(fixture.Manager.Refresh(next.RefreshToken, app)); + var fresh = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "bob"), app); fixture.Clock.Now = fresh.RefreshTokenExpiresAt.AddSeconds(1); - Assert.Null(fixture.Tokens.Refresh(fresh.RefreshToken, app)); + Assert.Null(fixture.Manager.Refresh(fresh.RefreshToken, app)); } /// @@ -125,10 +125,10 @@ public void RefreshRotatesOnceAndRetainsAbsoluteExpiry() public async Task ConcurrentRefreshHasOnlyOneWinner() { using var fixture = new AuthenticationFixture(); - var app = fixture.Application.ApplicationId; - var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => - new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock) + fixture.Instance() .Refresh(pair.RefreshToken, app)))); Assert.Single(results, x => x is not null); } @@ -140,23 +140,23 @@ public async Task ConcurrentRefreshHasOnlyOneWinner() public void PersonalTokensAreScopedExpiringAndRevocable() { using var fixture = new AuthenticationFixture(); - var app = fixture.Application.ApplicationId; + var app = fixture.Application; var owner = new Identity(Guid.NewGuid(), "alice", roles: ["admin"], permissions: ["read", "write"], policyNames: ["admin-policy"]); - var token = fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); - var restricted = fixture.Tokens.ValidatePersonalAccessToken(token, app); + var token = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); + var restricted = fixture.Manager.ValidatePersonalAccessToken(token, app); Assert.Equal(["read"], restricted.Permissions); Assert.Empty(restricted.Roles); Assert.Empty(restricted.PolicyNames); - Assert.Null(fixture.Tokens.ValidateAccessToken(token, app)); - Assert.Null(fixture.Tokens.Refresh(token, app)); - Assert.Throws(() => fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["delete"])); - Assert.Throws(() => fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.Zero, ["read"])); - Assert.True(fixture.Tokens.RevokePersonalAccessToken(token, app)); - var other = new IdentityTokenService(fixture.Settings, new FileIdentityTokenStore(fixture.Settings.TokenStorePath), fixture.Clock); + Assert.Null(fixture.Manager.ValidateAccessToken(token, app)); + Assert.Null(fixture.Manager.Refresh(token, app)); + Assert.Throws(() => fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["delete"])); + Assert.Throws(() => fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.Zero, ["read"])); + Assert.True(fixture.Manager.RevokePersonalAccessToken(token, app)); + var other = fixture.Instance(); Assert.Null(other.ValidatePersonalAccessToken(token, app)); - var expiring = fixture.Tokens.CreatePersonalAccessToken(owner, app, TimeSpan.FromSeconds(1), ["read"]); + var expiring = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromSeconds(1), ["read"]); fixture.Clock.Now += TimeSpan.FromSeconds(2); - Assert.Null(fixture.Tokens.ValidatePersonalAccessToken(expiring, app)); + Assert.Null(fixture.Manager.ValidatePersonalAccessToken(expiring, app)); } /// @@ -170,7 +170,7 @@ public void LogoutRevokesRenewalAndExpiresBothCookiePaths() var request = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); fixture.Manager.Logout(request); Assert.Null(fixture.Manager.GetCurrentIdentity(request)); - Assert.Null(fixture.Tokens.Refresh(pair.RefreshToken, fixture.Application.ApplicationId)); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); var response = new ResponseOK(); fixture.Manager.ApplyAuthenticationCookies(request, response); Assert.All(response.Header.Cookies.Cast(), x => Assert.True(x.Expires < DateTime.UtcNow)); @@ -185,12 +185,12 @@ public void LogoutRevokesRenewalAndExpiresBothCookiePaths() public void ExpiredAccessCanOnlyRevokeRenewal() { using var fixture = new AuthenticationFixture(); - var app = fixture.Application.ApplicationId; - var pair = fixture.Tokens.Issue(new Identity(Guid.NewGuid(), "alice"), app); + var app = fixture.Application; + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), app); fixture.Clock.Now = pair.AccessTokenExpiresAt.AddSeconds(1); - Assert.Null(fixture.Tokens.ValidateAccessToken(pair.AccessToken, app)); - fixture.Tokens.RevokeGrant(pair.AccessToken, app); - Assert.Null(fixture.Tokens.Refresh(pair.RefreshToken, app)); + Assert.Null(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + fixture.Manager.RevokeGrant(pair.AccessToken, app); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, app)); } /// @@ -202,7 +202,7 @@ public void MappedExternalPoliciesCaptureEffectivePermissions() using var fixture = new AuthenticationFixture(); var identity = new Identity(Guid.NewGuid(), "external", policyNames: [typeof(TestIdentityPolicyA).FullName]); var pair = fixture.Manager.Login(identity, fixture.Request()); - var verified = fixture.Tokens.ValidateAccessToken(pair.AccessToken, fixture.Application.ApplicationId); + var verified = fixture.Manager.ValidateAccessToken(pair.AccessToken, fixture.Application); Assert.Contains(typeof(TestIdentityPermissionC).FullName, verified.Permissions); } diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs new file mode 100644 index 00000000..84e69305 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestIdentityTokenStoreManager.cs @@ -0,0 +1,141 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Verifies that replay and revocation markers always reach exactly one store per application, + /// and that the credential logic in the identity manager follows the store bound at call time. + /// + [Collection("NonParallelTests")] + public class UnitTestIdentityTokenStoreManager + { + /// + /// Without a plugin store, every application shares the file store configured by the deployment. + /// + [Fact] + public void DefaultStoreIsTheSharedFileStore() + { + using var fixture = new AuthenticationFixture(); + var other = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + var store = fixture.Hub.IdentityTokenStoreManager.GetStore(fixture.Application); + Assert.IsType(store); + Assert.Same(store, fixture.Hub.IdentityTokenStoreManager.GetStore(other)); + Assert.Equal(new[] { store }, fixture.Hub.IdentityTokenStoreManager.Stores); + Assert.Null(fixture.Hub.IdentityTokenStoreManager.GetStore(null)); + } + + /// + /// A registered store receives the application's refresh markers, replaces an earlier binding instead of + /// joining it, and leaves other applications on the default store until it is unregistered. + /// + [Fact] + public void RegisteredStoreReplacesTheDefaultForItsApplicationOnly() + { + using var fixture = new AuthenticationFixture(); + var manager = fixture.Hub.IdentityTokenStoreManager; + var other = fixture.Hub.ApplicationManager.GetApplications(typeof(TestApplicationB)).First(); + var first = new MemoryTokenStore(); + var second = new MemoryTokenStore(); + manager.Register(first, fixture.Application); + manager.Register(second, fixture.Application); + Assert.Same(second, manager.GetStore(fixture.Application)); + Assert.DoesNotContain(first, manager.Stores); + Assert.False(first.Disposed, "an explicitly registered store stays owned by its caller"); + Assert.IsType(manager.GetStore(other)); + + var pair = fixture.Manager.Issue(new Identity(Guid.NewGuid(), "alice"), fixture.Application); + Assert.NotNull(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); + Assert.Contains(second.Markers, x => x.StartsWith("refresh:")); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, fixture.Application)); + Assert.Contains(second.Markers, x => x.StartsWith("grant:")); + Assert.Empty(first.Markers); + + Assert.False(manager.Unregister(first, fixture.Application)); + Assert.True(manager.Unregister(second, fixture.Application)); + Assert.IsType(manager.GetStore(fixture.Application)); + } + + /// + /// Without durable storage, stateless access still works, but every operation that depends on a replay + /// or revocation marker fails instead of accepting a credential that might have been revoked. + /// + [Fact] + public void MissingStoreFailsMarkerDependentOperations() + { + using var fixture = new AuthenticationFixture(withTokenStorePath: false); + var app = fixture.Application; + Assert.Null(fixture.Hub.IdentityTokenStoreManager.GetStore(app)); + Assert.False(fixture.Manager.IsAuthenticationConfigured(app)); + var owner = new Identity(Guid.NewGuid(), "alice", permissions: ["read"]); + var pair = fixture.Manager.Issue(owner, app); + Assert.NotNull(fixture.Manager.ValidateAccessToken(pair.AccessToken, app)); + Assert.Null(fixture.Manager.Refresh(pair.RefreshToken, app)); + var token = fixture.Manager.CreatePersonalAccessToken(owner, app, TimeSpan.FromHours(1), ["read"]); + Assert.Null(fixture.Manager.ValidatePersonalAccessToken(token, app)); + Assert.Throws(() => fixture.Manager.RevokePersonalAccessToken(token, app)); + + fixture.Hub.IdentityTokenStoreManager.Register(new MemoryTokenStore(), app); + Assert.True(fixture.Manager.IsAuthenticationConfigured(app)); + Assert.NotNull(fixture.Manager.ValidatePersonalAccessToken(token, app)); + } + + /// + /// Removing the owning plugin unbinds and disposes the store, so no request keeps writing into it. + /// + [Fact] + public void PluginRemovalReleasesTheBoundStore() + { + using var fixture = new AuthenticationFixture(); + var store = new MemoryTokenStore(); + fixture.Hub.IdentityTokenStoreManager.Register(store, fixture.Application); + ((WebPlugin.PluginManager)fixture.Hub.PluginManager).Remove(fixture.Application.PluginContext); + Assert.True(store.Disposed); + Assert.DoesNotContain(store, fixture.Hub.IdentityTokenStoreManager.Stores); + Assert.NotSame(store, fixture.Hub.IdentityTokenStoreManager.GetStore(fixture.Application)); + } + + /// + /// Stands in for a plugin-supplied store; kept private so plugin discovery does not bind it to the test applications. + /// + private sealed class MemoryTokenStore : IIdentityTokenStore, IDisposable + { + private readonly HashSet _markers = []; + internal IReadOnlyCollection Markers { get { lock (_markers) { return _markers.ToArray(); } } } + internal bool Disposed { get; private set; } + + /// + /// Mirrors the atomic first-writer semantics required of every store. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// The deadline until which the marker must be retained. + /// True when this call created the marker; otherwise, false. + public bool TryConsume(string tokenId, DateTimeOffset expiresAt) + { + lock (_markers) { return _markers.Add(tokenId); } + } + + /// + /// Records a denial marker. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// The deadline until which the marker must be retained. + public void Revoke(string tokenId, DateTimeOffset expiresAt) => TryConsume(tokenId, expiresAt); + + /// + /// Reports whether a denial marker exists. + /// + /// The unique credential or grant identifier whose marker is accessed. + /// True when a marker exists; otherwise, false. + public bool IsRevoked(string tokenId) + { + lock (_markers) { return _markers.Contains(tokenId); } + } + + /// + /// Records that the manager released the store. + /// + public void Dispose() => Disposed = true; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs index a30f2ac2..a6d1524a 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestOpenIdConnectIdentityProvider.cs @@ -28,7 +28,7 @@ public async Task CodeFlowUsesPkceAndProducesCommonIdentity() using var backchannel = new Backchannel(); using var client = new HttpClient(backchannel); using var provider = new PluginIdentityProvider(Settings(fixture), client); - var redirect = await provider.CreateChallengeAsync(fixture.Tokens, fixture.Application.ApplicationId); + var redirect = await provider.CreateChallengeAsync(fixture.Manager, fixture.Application); var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); Assert.Equal("code", parameters["response_type"]); Assert.Equal("S256", parameters["code_challenge_method"]); @@ -39,7 +39,7 @@ public async Task CodeFlowUsesPkceAndProducesCommonIdentity() backchannel.Nonce = parameters["nonce"]; var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", $"/api/auth/callback?state={parameters["state"]}&code=single-use-code"); - var identity = await provider.AuthenticateCallbackAsync(callback, fixture.Tokens); + var identity = await provider.AuthenticateCallbackAsync(callback, fixture.Manager); Assert.NotNull(identity); Assert.Equal("alice", identity.Name); Assert.Equal(["read", "write"], identity.Permissions.Order()); @@ -49,7 +49,7 @@ public async Task CodeFlowUsesPkceAndProducesCommonIdentity() var pair = fixture.Manager.Login(identity, callback); var authenticatedRequest = fixture.Request($"Cookie: {IdentityManager.AccessCookieName}={pair.AccessToken}\r\n"); Assert.Equal(identity.Id, fixture.Manager.GetCurrentIdentity(authenticatedRequest)?.Id); - Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Tokens)); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Manager)); Assert.Equal(1, backchannel.Exchanges); } @@ -73,13 +73,13 @@ public async Task InvalidExternalAuthenticationIsRejected(string failure) using var backchannel = new Backchannel { Failure = failure }; using var client = new HttpClient(backchannel); using var provider = new PluginIdentityProvider(Settings(fixture), client); - var redirect = await provider.CreateChallengeAsync(fixture.Tokens, fixture.Application.ApplicationId); + var redirect = await provider.CreateChallengeAsync(fixture.Manager, fixture.Application); var parameters = QueryHelpers.ParseQuery(new Uri(redirect.Header.Location).Query); backchannel.Nonce = parameters["nonce"]; var cookie = redirect.Header.Cookies[OpenIdConnectIdentityProvider.ChallengeCookieName]; var state = failure == "state" ? "attacker-state" : parameters["state"].ToString(); var callback = fixture.Request($"Cookie: {cookie.Name}={cookie.Value}\r\n", "GET", $"/api/auth/callback?state={state}&code=code"); - Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Tokens)); + Assert.Null(await provider.AuthenticateCallbackAsync(callback, fixture.Manager)); Assert.Equal(failure == "state" ? 0 : 1, backchannel.Exchanges); } @@ -94,8 +94,8 @@ public async Task MissingBrowserCorrelationNeverCallsTokenEndpoint() using var backchannel = new Backchannel(); using var client = new HttpClient(backchannel); using var provider = new PluginIdentityProvider(Settings(fixture), client); - Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?code=stolen&state=stolen"), fixture.Tokens)); - Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?id_token=jwt"), fixture.Tokens)); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?code=stolen&state=stolen"), fixture.Manager)); + Assert.Null(await provider.AuthenticateCallbackAsync(fixture.Request(path: "/api/auth/callback?id_token=jwt"), fixture.Manager)); Assert.Equal(0, backchannel.Exchanges); } diff --git a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs index cc5d9fa4..63e34450 100644 --- a/src/WebExpress.WebCore/WebComponent/ComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/ComponentHub.cs @@ -56,6 +56,7 @@ public class ComponentHub : IComponentHub private readonly TaskManager _taskManager; private readonly IdentityManager _identityManager; private readonly IdentityProviderManager _identityProviderManager; + private readonly IdentityTokenStoreManager _identityTokenStoreManager; private readonly SocketManager _socketManager; private readonly ThemeManager _themeManager; private int _lastCounter = 0; @@ -99,6 +100,7 @@ public class ComponentHub : IComponentHub _internationalizationManager, _identityManager, _identityProviderManager, + _identityTokenStoreManager, _sessionManager, _taskManager, _socketManager, @@ -224,6 +226,11 @@ public class ComponentHub : IComponentHub /// public IIdentityProviderManager IdentityProviderManager => _identityProviderManager; + /// + /// Keeps replay and revocation storage replaceable by plugins without changing credential issuance. + /// + public IIdentityTokenStoreManager IdentityTokenStoreManager => _identityTokenStoreManager; + /// /// Gets the session manager. /// @@ -291,6 +298,8 @@ protected ComponentHub(IHttpServerContext httpServerContext) ?? throw new InvalidOperationException("Failed to create TaskManager."); _identityProviderManager = CreateInstance(typeof(IdentityProviderManager)) as IdentityProviderManager ?? throw new InvalidOperationException("Failed to create IdentityProviderManager."); + _identityTokenStoreManager = CreateInstance(typeof(IdentityTokenStoreManager)) as IdentityTokenStoreManager + ?? throw new InvalidOperationException("Failed to create IdentityTokenStoreManager."); _identityManager = CreateInstance(typeof(IdentityManager)) as IdentityManager ?? throw new InvalidOperationException("Failed to create IdentityManager."); _socketManager = CreateInstance(typeof(SocketManager)) as SocketManager diff --git a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs index c6e0e6ca..9f153d4a 100644 --- a/src/WebExpress.WebCore/WebComponent/IComponentHub.cs +++ b/src/WebExpress.WebCore/WebComponent/IComponentHub.cs @@ -170,6 +170,11 @@ public interface IComponentHub : IComponentManager /// IIdentityProviderManager IdentityProviderManager { get; } + /// + /// Resolves the durable replay and revocation store bound to each application. + /// + IIdentityTokenStoreManager IdentityTokenStoreManager { get; } + /// /// Gets the session manager. /// diff --git a/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs b/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs index 4abe83f2..c79db28e 100644 --- a/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs +++ b/src/WebExpress.WebCore/WebIdentity/AuthenticationEndpoint.cs @@ -83,7 +83,7 @@ private async Task HandleCoreAsync(IRequest request, string path) var application = _hub.ApplicationManager.Applications.SingleOrDefault(x => x.ApplicationId == applicationId); if (application is null || request is not RequestBase concrete) { return Error(new ResponseBadRequest(), "unknown_application"); } concrete.ApplicationContext = application; - if (manager.Tokens is null) { return Error(new ResponseServiceUnavailable(), "authentication_not_configured"); } + if (!manager.IsAuthenticationConfigured(application)) { return Error(new ResponseServiceUnavailable(), "authentication_not_configured"); } try { @@ -114,8 +114,8 @@ private async Task HandleCoreAsync(IRequest request, string path) var providers = _hub.IdentityProviderManager.GetProviders(application).OfType() .Where(x => x.ProviderId == providerId).ToArray(); if (providers.Length != 1) { return Error(new ResponseBadRequest(), "unknown_provider"); } - if (path == "/api/auth/authorize") { return await providers[0].CreateChallengeAsync(manager.Tokens, applicationId); } - var external = await providers[0].AuthenticateCallbackAsync(request, manager.Tokens); + if (path == "/api/auth/authorize") { return await providers[0].CreateChallengeAsync(manager, application); } + var external = await providers[0].AuthenticateCallbackAsync(request, manager); var callback = external is null ? Unauthorized() : SignedIn(manager.Login(external, request)); callback.Header.Cookies.Add(IdentityManager.CreateCookie(OpenIdConnectIdentityProvider.ChallengeCookieName, null, "/api/auth/callback", null)); @@ -128,7 +128,7 @@ private async Task HandleCoreAsync(IRequest request, string path) if (isDelete) { var token = body.RootElement.GetProperty("token").GetString(); - if (manager.Tokens.ValidatePersonalAccessToken(token, applicationId)?.Id != owner.Id) { return Unauthorized(); } + if (manager.ValidatePersonalAccessToken(token, application)?.Id != owner.Id) { return Unauthorized(); } manager.RevokePersonalAccessToken(token, application); return new ResponseNoContent(); } diff --git a/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStoreManager.cs b/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStoreManager.cs new file mode 100644 index 00000000..aa954bfd --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IIdentityTokenStoreManager.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Resolves the single durable store that holds replay and revocation markers for an application. + /// Unlike identity providers, an application never uses two stores at once, because a revocation + /// recorded in one store would be invisible to a lookup in the other. + /// + public interface IIdentityTokenStoreManager : IComponentManager + { + /// + /// Returns every store currently in use, for diagnostics and administration only. + /// Request handling must resolve its store through instead. + /// + IEnumerable Stores { get; } + + /// + /// Returns the store bound to the application, falling back to the shared file store of the deployment. + /// + /// The application whose credentials are consumed or revoked. + /// The store responsible for the application, or null when no durable storage is configured. + IIdentityTokenStore GetStore(IApplicationContext applicationContext); + + /// + /// Replaces the application's current binding so plugins can supply distributed or database-backed storage. + /// + /// The store that receives the application's replay and revocation markers. + /// The application whose credentials are consumed or revoked. + void Register(IIdentityTokenStore store, IApplicationContext applicationContext); + + /// + /// Removes an explicit binding so the application returns to the default file store. + /// + /// The store that receives the application's replay and revocation markers. + /// The application whose credentials are consumed or revoked. + /// True when the binding existed and was removed; otherwise, false. + bool Unregister(IIdentityTokenStore store, IApplicationContext applicationContext); + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs index b5ab6ea1..54114c10 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs @@ -6,7 +6,6 @@ using System.Runtime.CompilerServices; using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebMessage; -using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.WebIdentity { @@ -47,8 +46,6 @@ public partial class IdentityManager public const string RefreshPath = "/api/auth/refresh"; private readonly ConditionalWeakTable _authenticationStates = new(); - private readonly object _tokenGate = new(); - private IdentityTokenService _tokens; /// /// Keeps pending authentication changes local to one HTTP request. @@ -60,24 +57,6 @@ private sealed class AuthenticationState internal bool Changed; } - /// - /// Gets the token service, creating it if necessary. - /// - internal IdentityTokenService Tokens - { - get - { - lock (_tokenGate) - { - if (_tokens is not null) { return _tokens; } - var settings = _httpServerContext.Configuration.GetSection("WebExpress:Authentication").Get(); - if (settings is null) { return null; } - _tokens = new IdentityTokenService(settings, new FileIdentityTokenStore(settings.TokenStorePath)); - return _tokens; - } - } - } - /// /// Converts a verified identity into a credential-free snapshot and queues protected token cookies. /// @@ -89,7 +68,7 @@ public IdentityTokenPair Login(IIdentity identity, IRequest request) if (identity is null) { return null; } ArgumentNullException.ThrowIfNull(request); var snapshot = Snapshot(identity, request.ApplicationContext); - var pair = RequireTokens().Issue(snapshot, request.ApplicationContext.ApplicationId); + var pair = Issue(snapshot, request.ApplicationContext); var state = _authenticationStates.GetOrCreateValue(request); state.Identity = snapshot; state.Pair = pair; @@ -105,10 +84,10 @@ public IdentityTokenPair Login(IIdentity identity, IRequest request) public IdentityTokenPair Refresh(IRequest request) { ArgumentNullException.ThrowIfNull(request); - var pair = RequireTokens().Refresh(CookieValue(request, RefreshCookie), request.ApplicationContext?.ApplicationId); + var pair = Refresh(CookieValue(request, RefreshCookie), request.ApplicationContext); if (pair is null) { return null; } var state = _authenticationStates.GetOrCreateValue(request); - state.Identity = Tokens.ValidateAccessToken(pair.AccessToken, request.ApplicationContext.ApplicationId); + state.Identity = ValidateAccessToken(pair.AccessToken, request.ApplicationContext); state.Pair = pair; state.Changed = true; return pair; @@ -121,8 +100,8 @@ public IdentityTokenPair Refresh(IRequest request) public void Logout(IRequest request) { ArgumentNullException.ThrowIfNull(request); - Tokens?.RevokeGrant(CookieValue(request, AccessCookie), request.ApplicationContext?.ApplicationId); - Tokens?.RevokeRefreshGrant(CookieValue(request, RefreshCookie), request.ApplicationContext?.ApplicationId); + RevokeGrant(CookieValue(request, AccessCookie), request.ApplicationContext); + RevokeRefreshGrant(CookieValue(request, RefreshCookie), request.ApplicationContext); var state = _authenticationStates.GetOrCreateValue(request); state.Identity = null; state.Pair = null; @@ -143,10 +122,10 @@ public IIdentity GetCurrentIdentity(IRequest request) if (authorization is not null) { return string.Equals(authorization.Type, "Bearer", StringComparison.OrdinalIgnoreCase) - ? Tokens?.ValidatePersonalAccessToken(authorization.Token, request.ApplicationContext.ApplicationId) : null; + ? ValidatePersonalAccessToken(authorization.Token, request.ApplicationContext) : null; } var cookie = CookieValue(request, AccessCookie); - return cookie is null ? null : Tokens?.ValidateAccessToken(cookie, request.ApplicationContext.ApplicationId); + return cookie is null ? null : ValidateAccessToken(cookie, request.ApplicationContext); } /// @@ -160,19 +139,7 @@ public IIdentity GetCurrentIdentity(IRequest request) public string CreatePersonalAccessToken(IIdentity identity, IApplicationContext applicationContext, TimeSpan lifetime, IEnumerable permissions) { - return RequireTokens().CreatePersonalAccessToken(Snapshot(identity, applicationContext), - applicationContext.ApplicationId, lifetime, permissions); - } - - /// - /// Persists revocation so the personal credential stops working across all configured instances. - /// - /// The serialized credential that must pass the required trust checks. - /// The application context that owns the requested operation. - /// True when a valid personal credential was revoked; otherwise, false. - public bool RevokePersonalAccessToken(string token, IApplicationContext applicationContext) - { - return RequireTokens().RevokePersonalAccessToken(token, applicationContext.ApplicationId); + return SignPersonalAccessToken(Snapshot(identity, applicationContext), applicationContext, lifetime, permissions); } /// @@ -220,12 +187,6 @@ internal static string CookieValue(IRequest request, string name) return cookies.Length == 1 ? cookies[0].Value : null; } - /// - /// Prevents authentication from silently falling back to ephemeral signing configuration. - /// - /// The token service configured for this deployment. - private IdentityTokenService RequireTokens() => Tokens ?? throw new InvalidOperationException("Configure WebExpress:Authentication before signing in."); - /// /// Captures effective authorization before mutable provider state crosses the token boundary. /// diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs new file mode 100644 index 00000000..0eff81df --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Token.cs @@ -0,0 +1,406 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Enforces disjoint trust rules for access, refresh, and personal credentials. + /// Access verification needs only the shared signing configuration and the signed claims; replay + /// and revocation markers live in the store that binds to the application. + /// + public partial class IdentityManager + { + private readonly JsonWebTokenHandler _tokenHandler = new() { MaximumTokenSizeInBytes = 16384 }; + private readonly object _authorityGate = new(); + private TokenAuthority _authority; + + /// + /// Holds the validated trust boundary so weak secrets are rejected before any credential is signed. + /// + /// The deployment-wide HMAC signing credentials. + /// The authority that issues WebExpress tokens. + /// The audience prefix, completed by the application identifier. + /// The lifetime of a signed authorization snapshot. + /// The absolute lifetime of a login grant. + /// The maximum lifetime of a personal credential. + private sealed record TokenAuthority(SigningCredentials Signing, string Issuer, string Audience, + TimeSpan AccessLifetime, TimeSpan RefreshLifetime, TimeSpan PersonalLifetime); + + /// + /// Gets or sets the UTC clock that enforces signed expiration boundaries, replaceable so tests can cross deadlines without sleeping. + /// + internal TimeProvider Clock { get; set; } = TimeProvider.System; + + /// + /// Loads the signing authority once and rejects missing or weak deployment secrets instead of falling back to per-process keys. + /// + private TokenAuthority Authority + { + get + { + lock (_authorityGate) + { + if (_authority is not null) { return _authority; } + var settings = _httpServerContext.Configuration.GetSection("WebExpress:Authentication").Get(); + if (settings is null) { return null; } + ArgumentException.ThrowIfNullOrWhiteSpace(settings.Issuer); + ArgumentException.ThrowIfNullOrWhiteSpace(settings.Audience); + var key = Convert.FromBase64String(settings.SigningKey ?? ""); + if (key.Length < 32) { throw new ArgumentException("Authentication requires at least 256 random signing-key bits."); } + if (settings.AccessTokenLifetime < TimeSpan.FromSeconds(1) || + settings.RefreshTokenLifetime <= settings.AccessTokenLifetime || + settings.MaximumPersonalAccessTokenLifetime < TimeSpan.FromSeconds(1)) + { + throw new ArgumentException("Authentication token lifetimes are invalid."); + } + _authority = new TokenAuthority(new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256), + settings.Issuer, settings.Audience, settings.AccessTokenLifetime, settings.RefreshTokenLifetime, + settings.MaximumPersonalAccessTokenLifetime); + return _authority; + } + } + } + + /// + /// Reports whether the application can both sign credentials and persist their replay and revocation markers. + /// + /// The application whose authentication endpoints are being served. + /// True when a signing authority and a token store are available; otherwise, false. + internal bool IsAuthenticationConfigured(IApplicationContext applicationContext) + { + return Authority is not null && TokenStore(applicationContext) is not null; + } + + /// + /// Gives every authentication source the same short-lived authorization snapshot. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application that scopes token audiences and authentication. + /// The signed access and refresh credentials for the new login grant. + public IdentityTokenPair Issue(IIdentity identity, IApplicationContext applicationContext) + { + return IssuePair(identity, applicationContext, Guid.NewGuid().ToString("N"), Clock.GetUtcNow() + RequireAuthority().RefreshLifetime); + } + + /// + /// Accepts access credentials only, preventing refresh or personal tokens from entering through cookies. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// The verified access identity, or null when validation fails. + public IIdentity ValidateAccessToken(string token, IApplicationContext applicationContext) + { + return ReadIdentity(ValidateToken(token, applicationContext, "access")); + } + + /// + /// Restricts bearer credentials to explicitly created, unrevoked personal tokens. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// The verified personal identity, or null when validation or revocation checks fail. + public IIdentity ValidatePersonalAccessToken(string token, IApplicationContext applicationContext) + { + var jwt = ValidateToken(token, applicationContext, "pat"); + // without a store a revocation cannot be ruled out, so the credential is refused + var store = TokenStore(applicationContext); + return jwt is not null && store is not null && !store.IsRevoked("pat:" + jwt.Id) ? ReadIdentity(jwt) : null; + } + + /// + /// Rotates each refresh credential once and revokes the entire grant when it is replayed. + /// The original grant deadline is retained so renewal cannot make a login immortal. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// The rotated token pair, or null when validation or replay protection rejects renewal. + public IdentityTokenPair Refresh(string token, IApplicationContext applicationContext) + { + var jwt = ValidateToken(token, applicationContext, "refresh"); + var identity = ReadIdentity(jwt); + var store = TokenStore(applicationContext); + if (identity is null || store is null || !jwt.TryGetPayloadValue("grant", out var grant) || + string.IsNullOrEmpty(grant) || store.IsRevoked("grant:" + grant)) { return null; } + var expires = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero); + if (!store.TryConsume("refresh:" + jwt.Id, expires)) + { + store.Revoke("grant:" + grant, expires); + return null; + } + return IssuePair(identity, applicationContext, grant, expires); + } + + /// + /// Persists revocation so the personal credential stops working on every instance sharing the application's store. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// True when a valid personal credential was revoked; otherwise, false. + public bool RevokePersonalAccessToken(string token, IApplicationContext applicationContext) + { + RequireAuthority(); + var jwt = ValidateToken(token, applicationContext, "pat"); + if (jwt is null) { return false; } + RequireTokenStore(applicationContext).Revoke("pat:" + jwt.Id, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); + return true; + } + + /// + /// Ends renewal using the grant reference in an access cookie, even when the refresh cookie's + /// narrow path keeps it out of the logout request. Existing access tokens expire naturally. + /// + /// The signed access credential identifying the login grant to revoke. + /// The application that scopes token audiences and authentication. + public void RevokeGrant(string accessToken, IApplicationContext applicationContext) + { + var jwt = ValidateToken(accessToken, applicationContext, "access", validateLifetime: false); + if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant) && + jwt.TryGetPayloadValue("grant_exp", out var expiration)) + { + RequireTokenStore(applicationContext).Revoke("grant:" + grant, DateTimeOffset.FromUnixTimeSeconds(expiration)); + } + } + + /// + /// Signs correlation data with a purpose that cannot authorize application requests. + /// + /// The trusted claims bound into the signed credential. + /// The application that scopes token audiences and authentication. + /// The signed browser correlation credential. + internal string ProtectChallenge(Dictionary claims, IApplicationContext applicationContext) + { + return SignToken(claims, applicationContext, "challenge", Clock.GetUtcNow() + TimeSpan.FromMinutes(5)); + } + + /// + /// Accepts only unexpired browser correlation credentials for the selected application. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// The verified correlation token, or null when validation fails. + internal JsonWebToken ValidateChallenge(string token, IApplicationContext applicationContext) + { + return ValidateToken(token, applicationContext, "challenge"); + } + + /// + /// Prevents different server instances from exchanging a code with the same challenge. + /// + /// The verified browser correlation token that must be consumed exactly once. + /// The application whose store records the consumption. + /// True for the first successful consumption; otherwise, false. + internal bool ConsumeChallenge(JsonWebToken challenge, IApplicationContext applicationContext) + { + return TokenStore(applicationContext)?.TryConsume("challenge:" + challenge.Id, + new DateTimeOffset(challenge.ValidTo, TimeSpan.Zero)) ?? false; + } + + /// + /// Allows logout at the refresh cookie path to end the original login grant. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + internal void RevokeRefreshGrant(string token, IApplicationContext applicationContext) + { + var jwt = ValidateToken(token, applicationContext, "refresh"); + if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant)) + { + RequireTokenStore(applicationContext).Revoke("grant:" + grant, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); + } + } + + /// + /// Creates an explicitly bounded credential and intersects its permissions with its owner's grants. + /// Policies and roles are omitted so they cannot bypass a PAT's permission restriction. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application that scopes token audiences and authentication. + /// The explicit validity period requested for the personal credential. + /// The requested permission identifiers, limited to the owner's grants. + /// The signed personal credential with the requested authorized permissions. + private string SignPersonalAccessToken(IIdentity identity, IApplicationContext applicationContext, TimeSpan lifetime, + IEnumerable permissions) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(permissions); + if (lifetime < TimeSpan.FromSeconds(1) || lifetime > RequireAuthority().PersonalLifetime) { throw new ArgumentOutOfRangeException(nameof(lifetime)); } + var requested = permissions.Distinct(StringComparer.Ordinal).ToArray(); + if (requested.Except(identity.Permissions, StringComparer.Ordinal).Any()) + { + throw new ArgumentException("A personal token cannot exceed its owner's permissions.", nameof(permissions)); + } + var restricted = new Identity(identity.Id, identity.Name, identity.Email, permissions: requested); + return SignIdentity(restricted, applicationContext, "pat", Clock.GetUtcNow() + lifetime); + } + + /// + /// Keeps both credentials inside the absolute grant deadline and browser cookie size limits. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application that scopes token audiences and authentication. + /// The identifier of the original login grant. + /// The absolute deadline beyond which the login grant cannot be renewed. + /// The signed token pair constrained by the absolute grant deadline. + private IdentityTokenPair IssuePair(IIdentity identity, IApplicationContext applicationContext, string grant, DateTimeOffset deadline) + { + ArgumentNullException.ThrowIfNull(identity); + var accessDeadline = Clock.GetUtcNow() + RequireAuthority().AccessLifetime; + if (accessDeadline > deadline) { accessDeadline = deadline; } + var pair = new IdentityTokenPair + { + AccessToken = SignIdentity(identity, applicationContext, "access", accessDeadline, grant, deadline), + RefreshToken = SignIdentity(identity, applicationContext, "refresh", deadline, grant, deadline), + AccessTokenExpiresAt = accessDeadline, + RefreshTokenExpiresAt = deadline + }; + if (pair.AccessToken.Length > 3800 || pair.RefreshToken.Length > 3800) + { + throw new InvalidOperationException("Identity claims exceed the authentication cookie limit."); + } + return pair; + } + + /// + /// Copies only credential-free identity claims into a purpose-specific payload. + /// + /// The verified identity whose authorization snapshot is being processed. + /// The application that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// The signed expiration deadline of the credential. + /// The identifier of the original login grant. + /// The expiration deadline shared by credentials from one login grant. + /// The signed credential containing the trusted identity snapshot. + private string SignIdentity(IIdentity identity, IApplicationContext applicationContext, string purpose, DateTimeOffset expires, + string grant = null, DateTimeOffset? grantExpires = null) + { + var claims = new Dictionary + { + ["sub"] = identity.Id.ToString(), ["name"] = identity.Name, + ["roles"] = identity.Roles.ToArray(), ["permissions"] = identity.Permissions.ToArray(), + ["policies"] = identity.PolicyNames.ToArray() + }; + if (identity.Email is not null) { claims["email"] = identity.Email; } + if (grant is not null) { claims["grant"] = grant; claims["grant_exp"] = grantExpires.Value.ToUnixTimeSeconds(); } + return SignToken(claims, applicationContext, purpose, expires); + } + + /// + /// Binds claims to their signing authority, application, purpose, and expiration. + /// + /// The trusted claims bound into the signed credential. + /// The application that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// The signed expiration deadline of the credential. + /// The signed JWT bound to the deployment trust boundary. + private string SignToken(Dictionary claims, IApplicationContext applicationContext, string purpose, DateTimeOffset expires) + { + var authority = RequireAuthority(); + ArgumentNullException.ThrowIfNull(applicationContext); + ArgumentException.ThrowIfNullOrWhiteSpace(applicationContext.ApplicationId); + claims["jti"] = Guid.NewGuid().ToString("N"); + claims["token_use"] = purpose; + return _tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Issuer = authority.Issuer, Audience = authority.Audience + "/" + applicationContext.ApplicationId, + IssuedAt = Clock.GetUtcNow().UtcDateTime, NotBefore = Clock.GetUtcNow().UtcDateTime, + Expires = expires.UtcDateTime, Claims = claims, SigningCredentials = authority.Signing, + TokenType = "wx-" + purpose + "+jwt" + }); + } + + /// + /// Enforces the complete token trust boundary before any claims are consumed. + /// + /// The serialized credential that must pass the required trust checks. + /// The application that scopes token audiences and authentication. + /// The token purpose separating access, renewal, and browser correlation. + /// A value indicating whether this operation must enforce expiration. + /// The verified JWT, or null when any required trust check fails. + private JsonWebToken ValidateToken(string token, IApplicationContext applicationContext, string purpose, bool validateLifetime = true) + { + var authority = Authority; + if (authority is null || string.IsNullOrEmpty(token) || token.Length > 16384 || + string.IsNullOrEmpty(applicationContext?.ApplicationId)) { return null; } + var result = _tokenHandler.ValidateTokenAsync(token, new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = authority.Issuer, + ValidateAudience = true, ValidAudience = authority.Audience + "/" + applicationContext.ApplicationId, + ValidateIssuerSigningKey = true, IssuerSigningKey = authority.Signing.Key, + RequireSignedTokens = true, RequireExpirationTime = true, + ValidAlgorithms = [SecurityAlgorithms.HmacSha256], ValidTypes = ["wx-" + purpose + "+jwt"], + ValidateLifetime = validateLifetime, ClockSkew = TimeSpan.Zero, + LifetimeValidator = validateLifetime ? (notBefore, expires, _, _) => + notBefore.HasValue && expires.HasValue && notBefore <= Clock.GetUtcNow().UtcDateTime && + expires > Clock.GetUtcNow().UtcDateTime && notBefore < expires : null + }).GetAwaiter().GetResult(); + if (!result.IsValid || result.SecurityToken is not JsonWebToken jwt || string.IsNullOrEmpty(jwt.Id) || + !jwt.TryGetPayloadValue("token_use", out var use) || use != purpose) { return null; } + return jwt; + } + + /// + /// Prevents authentication from silently falling back to ephemeral signing configuration. + /// + /// The signing authority configured for this deployment. + private TokenAuthority RequireAuthority() + { + return Authority ?? throw new InvalidOperationException("Configure WebExpress:Authentication before signing in."); + } + + /// + /// Resolves the store per call, so a store replaced or removed together with its plugin is never used afterwards. + /// + /// The application whose credentials are consumed or revoked. + /// The store bound to the application, or null when none is available. + private IIdentityTokenStore TokenStore(IApplicationContext applicationContext) + { + return _componentHub?.IdentityTokenStoreManager?.GetStore(applicationContext); + } + + /// + /// Fails a revocation outright rather than reporting success for a marker that was never persisted. + /// + /// The application whose credentials are consumed or revoked. + /// The store bound to the application. + private IIdentityTokenStore RequireTokenStore(IApplicationContext applicationContext) + { + return TokenStore(applicationContext) ?? + throw new InvalidOperationException("Configure WebExpress:Authentication:TokenStorePath or register an identity token store."); + } + + /// + /// Rejects incomplete subjects before constructing a credential-free identity. + /// + /// The verified JWT from which credential-free claims are read. + /// The immutable identity snapshot, or null when required claims are absent. + private static IIdentity ReadIdentity(JsonWebToken jwt) + { + if (jwt is null || !Guid.TryParse(jwt.Subject, out var subject) || subject == Guid.Empty || + !jwt.TryGetPayloadValue("name", out var name) || string.IsNullOrWhiteSpace(name)) { return null; } + try + { + jwt.TryGetPayloadValue("email", out var email); + return new Identity(subject, name, email, ReadArray(jwt, "roles"), ReadArray(jwt, "permissions"), ReadArray(jwt, "policies")); + } + catch (JsonException) { return null; } + } + + /// + /// Reads authorization labels without constructing executable types from serialized claims. + /// + /// The verified JWT from which credential-free claims are read. + /// The exact claim name to inspect. + /// The claim values, or an empty array when the claim is absent. + private static string[] ReadArray(JsonWebToken jwt, string name) + { + return jwt.TryGetPayloadValue(name, out var values) ? values : []; + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs b/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs deleted file mode 100644 index b99b7e4d..00000000 --- a/src/WebExpress.WebCore/WebIdentity/IdentityTokenService.cs +++ /dev/null @@ -1,337 +0,0 @@ -using Microsoft.IdentityModel.JsonWebTokens; -using Microsoft.IdentityModel.Tokens; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using WebExpress.WebCore.WebSetting; - -namespace WebExpress.WebCore.WebIdentity -{ - /// - /// Enforces disjoint trust rules for access, refresh, and personal credentials. - /// Access verification needs only the shared signing configuration and the signed claims. - /// - public sealed class IdentityTokenService - { - private readonly JsonWebTokenHandler _handler = new() { MaximumTokenSizeInBytes = 16384 }; - private readonly SigningCredentials _signing; - private readonly string _issuer; - private readonly string _audience; - private readonly TimeSpan _accessLifetime; - private readonly TimeSpan _refreshLifetime; - private readonly TimeSpan _patLifetime; - private readonly IIdentityTokenStore _store; - private readonly TimeProvider _time; - - /// - /// Rejects missing or weak deployment secrets instead of falling back to per-process keys. - /// - /// The configured trust boundary and credential lifetime constraints. - /// The durable store shared by instances that consume or revoke credentials. - /// The UTC clock used to enforce signed expiration boundaries. - public IdentityTokenService(AuthenticationSettings settings, IIdentityTokenStore store, TimeProvider timeProvider = null) - { - ArgumentNullException.ThrowIfNull(settings); - ArgumentNullException.ThrowIfNull(store); - ArgumentException.ThrowIfNullOrWhiteSpace(settings.Issuer); - ArgumentException.ThrowIfNullOrWhiteSpace(settings.Audience); - var key = Convert.FromBase64String(settings.SigningKey ?? ""); - if (key.Length < 32) { throw new ArgumentException("Authentication requires at least 256 random signing-key bits."); } - if (settings.AccessTokenLifetime < TimeSpan.FromSeconds(1) || - settings.RefreshTokenLifetime <= settings.AccessTokenLifetime || - settings.MaximumPersonalAccessTokenLifetime < TimeSpan.FromSeconds(1)) - { - throw new ArgumentException("Authentication token lifetimes are invalid."); - } - _signing = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); - _issuer = settings.Issuer; - _audience = settings.Audience; - _accessLifetime = settings.AccessTokenLifetime; - _refreshLifetime = settings.RefreshTokenLifetime; - _patLifetime = settings.MaximumPersonalAccessTokenLifetime; - _store = store; - _time = timeProvider ?? TimeProvider.System; - } - - /// - /// Gives every authentication source the same short-lived authorization snapshot. - /// - /// The verified identity whose authorization snapshot is being processed. - /// The application identifier that scopes token audiences and authentication. - /// The signed access and refresh credentials for the new login grant. - public IdentityTokenPair Issue(IIdentity identity, string applicationId) - { - return IssuePair(identity, applicationId, Guid.NewGuid().ToString("N"), _time.GetUtcNow() + _refreshLifetime); - } - - /// - /// Accepts access credentials only, preventing refresh or personal tokens from entering through cookies. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// The verified access identity, or null when validation fails. - public IIdentity ValidateAccessToken(string token, string applicationId) - { - return ReadIdentity(Validate(token, applicationId, "access")); - } - - /// - /// Restricts bearer credentials to explicitly created, unrevoked personal tokens. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// The verified personal identity, or null when validation or revocation checks fail. - public IIdentity ValidatePersonalAccessToken(string token, string applicationId) - { - var jwt = Validate(token, applicationId, "pat"); - return jwt is not null && !_store.IsRevoked("pat:" + jwt.Id) ? ReadIdentity(jwt) : null; - } - - /// - /// Rotates each refresh credential once and revokes the entire grant when it is replayed. - /// The original grant deadline is retained so renewal cannot make a login immortal. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// The rotated token pair, or null when validation or replay protection rejects renewal. - public IdentityTokenPair Refresh(string token, string applicationId) - { - var jwt = Validate(token, applicationId, "refresh"); - var identity = ReadIdentity(jwt); - if (identity is null || !jwt.TryGetPayloadValue("grant", out var grant) || - string.IsNullOrEmpty(grant) || _store.IsRevoked("grant:" + grant)) { return null; } - var expires = new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero); - if (!_store.TryConsume("refresh:" + jwt.Id, expires)) - { - _store.Revoke("grant:" + grant, expires); - return null; - } - return IssuePair(identity, applicationId, grant, expires); - } - - /// - /// Creates an explicitly bounded credential and intersects its permissions with its owner's grants. - /// Policies and roles are omitted so they cannot bypass a PAT's permission restriction. - /// - /// The verified identity whose authorization snapshot is being processed. - /// The application identifier that scopes token audiences and authentication. - /// The explicit validity period requested for the personal credential. - /// The requested permission identifiers, limited to the owner's grants. - /// The signed personal credential with the requested authorized permissions. - public string CreatePersonalAccessToken(IIdentity identity, string applicationId, TimeSpan lifetime, - IEnumerable permissions) - { - ArgumentNullException.ThrowIfNull(identity); - ArgumentNullException.ThrowIfNull(permissions); - if (lifetime < TimeSpan.FromSeconds(1) || lifetime > _patLifetime) { throw new ArgumentOutOfRangeException(nameof(lifetime)); } - var requested = permissions.Distinct(StringComparer.Ordinal).ToArray(); - if (requested.Except(identity.Permissions, StringComparer.Ordinal).Any()) - { - throw new ArgumentException("A personal token cannot exceed its owner's permissions.", nameof(permissions)); - } - var restricted = new Identity(identity.Id, identity.Name, identity.Email, permissions: requested); - return Create(restricted, applicationId, "pat", _time.GetUtcNow() + lifetime); - } - - /// - /// Prevents a personal credential from authenticating on any instance sharing the token store. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// True when a valid personal credential was revoked; otherwise, false. - public bool RevokePersonalAccessToken(string token, string applicationId) - { - var jwt = Validate(token, applicationId, "pat"); - if (jwt is null) { return false; } - _store.Revoke("pat:" + jwt.Id, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); - return true; - } - - /// - /// Ends renewal using the grant reference in an access cookie, even when the refresh cookie's - /// narrow path keeps it out of the logout request. Existing access tokens expire naturally. - /// - /// The signed access credential identifying the login grant to revoke. - /// The application identifier that scopes token audiences and authentication. - public void RevokeGrant(string accessToken, string applicationId) - { - var jwt = Validate(accessToken, applicationId, "access", validateLifetime: false); - if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant) && - jwt.TryGetPayloadValue("grant_exp", out var expiration)) - { - _store.Revoke("grant:" + grant, DateTimeOffset.FromUnixTimeSeconds(expiration)); - } - } - - /// - /// Signs correlation data with a purpose that cannot authorize application requests. - /// - /// The trusted claims bound into the signed credential. - /// The application identifier that scopes token audiences and authentication. - /// The signed browser correlation credential. - internal string ProtectChallenge(Dictionary claims, string applicationId) - { - return CreateToken(claims, applicationId, "challenge", _time.GetUtcNow() + TimeSpan.FromMinutes(5)); - } - - /// - /// Accepts only unexpired browser correlation credentials for the selected application. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// The verified correlation token, or null when validation fails. - internal JsonWebToken ValidateChallenge(string token, string applicationId) => Validate(token, applicationId, "challenge"); - - /// - /// Prevents different server instances from exchanging a code with the same challenge. - /// - /// The verified browser correlation token that must be consumed exactly once. - /// True for the first successful consumption; otherwise, false. - internal bool ConsumeChallenge(JsonWebToken challenge) => _store.TryConsume("challenge:" + challenge.Id, - new DateTimeOffset(challenge.ValidTo, TimeSpan.Zero)); - - /// - /// Allows logout at the refresh cookie path to end the original login grant. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - internal void RevokeRefreshGrant(string token, string applicationId) - { - var jwt = Validate(token, applicationId, "refresh"); - if (jwt is not null && jwt.TryGetPayloadValue("grant", out var grant)) - { - _store.Revoke("grant:" + grant, new DateTimeOffset(jwt.ValidTo, TimeSpan.Zero)); - } - } - - /// - /// Keeps both credentials inside the absolute grant deadline and browser cookie size limits. - /// - /// The verified identity whose authorization snapshot is being processed. - /// The application identifier that scopes token audiences and authentication. - /// The identifier of the original login grant. - /// The absolute deadline beyond which the login grant cannot be renewed. - /// The signed token pair constrained by the absolute grant deadline. - private IdentityTokenPair IssuePair(IIdentity identity, string applicationId, string grant, DateTimeOffset deadline) - { - ArgumentNullException.ThrowIfNull(identity); - var accessDeadline = _time.GetUtcNow() + _accessLifetime; - if (accessDeadline > deadline) { accessDeadline = deadline; } - var pair = new IdentityTokenPair - { - AccessToken = Create(identity, applicationId, "access", accessDeadline, grant, deadline), - RefreshToken = Create(identity, applicationId, "refresh", deadline, grant, deadline), - AccessTokenExpiresAt = accessDeadline, - RefreshTokenExpiresAt = deadline - }; - if (pair.AccessToken.Length > 3800 || pair.RefreshToken.Length > 3800) - { - throw new InvalidOperationException("Identity claims exceed the authentication cookie limit."); - } - return pair; - } - - /// - /// Copies only credential-free identity claims into a purpose-specific payload. - /// - /// The verified identity whose authorization snapshot is being processed. - /// The application identifier that scopes token audiences and authentication. - /// The token purpose separating access, renewal, and browser correlation. - /// The signed expiration deadline of the credential. - /// The identifier of the original login grant. - /// The expiration deadline shared by credentials from one login grant. - /// The signed credential containing the trusted identity snapshot. - private string Create(IIdentity identity, string applicationId, string purpose, DateTimeOffset expires, - string grant = null, DateTimeOffset? grantExpires = null) - { - var claims = new Dictionary - { - ["sub"] = identity.Id.ToString(), ["name"] = identity.Name, - ["roles"] = identity.Roles.ToArray(), ["permissions"] = identity.Permissions.ToArray(), - ["policies"] = identity.PolicyNames.ToArray() - }; - if (identity.Email is not null) { claims["email"] = identity.Email; } - if (grant is not null) { claims["grant"] = grant; claims["grant_exp"] = grantExpires.Value.ToUnixTimeSeconds(); } - return CreateToken(claims, applicationId, purpose, expires); - } - - /// - /// Binds claims to their signing authority, application, purpose, and expiration. - /// - /// The trusted claims bound into the signed credential. - /// The application identifier that scopes token audiences and authentication. - /// The token purpose separating access, renewal, and browser correlation. - /// The signed expiration deadline of the credential. - /// The signed JWT bound to the deployment trust boundary. - private string CreateToken(Dictionary claims, string applicationId, string purpose, DateTimeOffset expires) - { - ArgumentException.ThrowIfNullOrWhiteSpace(applicationId); - claims["jti"] = Guid.NewGuid().ToString("N"); - claims["token_use"] = purpose; - return _handler.CreateToken(new SecurityTokenDescriptor - { - Issuer = _issuer, Audience = _audience + "/" + applicationId, - IssuedAt = _time.GetUtcNow().UtcDateTime, NotBefore = _time.GetUtcNow().UtcDateTime, - Expires = expires.UtcDateTime, Claims = claims, SigningCredentials = _signing, - TokenType = "wx-" + purpose + "+jwt" - }); - } - - /// - /// Enforces the complete token trust boundary before any claims are consumed. - /// - /// The serialized credential that must pass the required trust checks. - /// The application identifier that scopes token audiences and authentication. - /// The token purpose separating access, renewal, and browser correlation. - /// A value indicating whether this operation must enforce expiration. - /// The verified JWT, or null when any required trust check fails. - private JsonWebToken Validate(string token, string applicationId, string purpose, bool validateLifetime = true) - { - if (string.IsNullOrEmpty(token) || token.Length > 16384 || string.IsNullOrEmpty(applicationId)) { return null; } - var result = _handler.ValidateTokenAsync(token, new TokenValidationParameters - { - ValidateIssuer = true, ValidIssuer = _issuer, - ValidateAudience = true, ValidAudience = _audience + "/" + applicationId, - ValidateIssuerSigningKey = true, IssuerSigningKey = _signing.Key, - RequireSignedTokens = true, RequireExpirationTime = true, - ValidAlgorithms = [SecurityAlgorithms.HmacSha256], ValidTypes = ["wx-" + purpose + "+jwt"], - ValidateLifetime = validateLifetime, ClockSkew = TimeSpan.Zero, - LifetimeValidator = validateLifetime ? (notBefore, expires, _, _) => - notBefore.HasValue && expires.HasValue && notBefore <= _time.GetUtcNow().UtcDateTime && - expires > _time.GetUtcNow().UtcDateTime && notBefore < expires : null - }).GetAwaiter().GetResult(); - if (!result.IsValid || result.SecurityToken is not JsonWebToken jwt || string.IsNullOrEmpty(jwt.Id) || - !jwt.TryGetPayloadValue("token_use", out var use) || use != purpose) { return null; } - return jwt; - } - - /// - /// Rejects incomplete subjects before constructing a credential-free identity. - /// - /// The verified JWT from which credential-free claims are read. - /// The immutable identity snapshot, or null when required claims are absent. - private static IIdentity ReadIdentity(JsonWebToken jwt) - { - if (jwt is null || !Guid.TryParse(jwt.Subject, out var subject) || subject == Guid.Empty || - !jwt.TryGetPayloadValue("name", out var name) || string.IsNullOrWhiteSpace(name)) { return null; } - try - { - jwt.TryGetPayloadValue("email", out var email); - return new Identity(subject, name, email, ReadArray(jwt, "roles"), ReadArray(jwt, "permissions"), ReadArray(jwt, "policies")); - } - catch (JsonException) { return null; } - } - - /// - /// Reads authorization labels without constructing executable types from serialized claims. - /// - /// The verified JWT from which credential-free claims are read. - /// The exact cookie or claim name to inspect. - /// The claim values, or an empty array when the claim is absent. - private static string[] ReadArray(JsonWebToken jwt, string name) - { - return jwt.TryGetPayloadValue(name, out var values) ? values : []; - } - } -} diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs b/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs new file mode 100644 index 00000000..436895c4 --- /dev/null +++ b/src/WebExpress.WebCore/WebIdentity/IdentityTokenStoreManager.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebPlugin; + +namespace WebExpress.WebCore.WebIdentity +{ + /// + /// Binds at most one token store to each application and ties the binding's lifetime to the owning + /// plugin and application. Applications without a binding share the file store configured through + /// WebExpress:Authentication:TokenStorePath, which is part of WebCore and needs no plugin. + /// + public sealed class IdentityTokenStoreManager : IIdentityTokenStoreManager + { + private readonly IComponentHub _hub; + private readonly IHttpServerContext _server; + private readonly object _gate = new(); + private readonly List _entries = []; + private FileIdentityTokenStore _defaultStore; + + /// + /// Associates a store with the application it serves and the plugin responsible for its lifetime. + /// + /// The store receiving the application's replay and revocation markers. + /// The application whose credentials use this store. + /// The plugin whose removal ends this binding. + /// Whether the manager created the store and therefore owns its disposal on replacement. + private sealed record Entry(IIdentityTokenStore Store, IApplicationContext Application, IPluginContext Plugin, bool Discovered); + + /// + /// Connects store ownership to plugin and application lifecycle events. + /// + /// The hub supplying the plugin and application managers. + /// The server context supplying the deployment's token-store location. + private IdentityTokenStoreManager(IComponentHub componentHub, IHttpServerContext httpServerContext) + { + _hub = componentHub; + _server = httpServerContext; + _hub.PluginManager.AddPlugin += OnAddPlugin; + _hub.PluginManager.RemovePlugin += OnRemovePlugin; + _hub.ApplicationManager.AddApplication += OnAddApplication; + _hub.ApplicationManager.RemoveApplication += OnRemoveApplication; + } + + /// + /// Returns a snapshot of the bound stores and the default store, if it has been opened. + /// + public IEnumerable Stores + { + get + { + lock (_gate) + { + return _entries.Select(x => x.Store).Append(_defaultStore).Where(x => x is not null).Distinct().ToArray(); + } + } + } + + /// + /// Resolves the application's binding, falling back to the shared file store. + /// + /// The application whose credentials are consumed or revoked. + /// The store responsible for the application, or null when no durable storage is configured. + public IIdentityTokenStore GetStore(IApplicationContext applicationContext) + { + if (applicationContext is null) { return null; } + lock (_gate) + { + return _entries.FirstOrDefault(x => x.Application == applicationContext)?.Store ?? DefaultStore(); + } + } + + /// + /// Replaces the application's binding and attributes the store to the plugin that implements it. + /// + /// The store receiving the application's replay and revocation markers. + /// The application whose credentials are consumed or revoked. + public void Register(IIdentityTokenStore store, IApplicationContext applicationContext) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(applicationContext); + var plugin = _hub.PluginManager.GetPlugins(applicationContext) + .FirstOrDefault(x => x.Assembly == store.GetType().Assembly) ?? applicationContext.PluginContext; + lock (_gate) + { + var replaced = _entries.FindAll(x => x.Application == applicationContext && !ReferenceEquals(x.Store, store)); + _entries.RemoveAll(x => x.Application == applicationContext); + _entries.Add(new Entry(store, applicationContext, plugin, false)); + // an explicitly registered store belongs to its caller until plugin or application removal + Release(replaced.Where(x => x.Discovered)); + } + } + + /// + /// Removes an explicit binding without disposing the store, which remains owned by its caller. + /// + /// The store receiving the application's replay and revocation markers. + /// The application whose credentials are consumed or revoked. + /// True when the binding existed and was removed; otherwise, false. + public bool Unregister(IIdentityTokenStore store, IApplicationContext applicationContext) + { + lock (_gate) + { + return _entries.RemoveAll(x => x.Application == applicationContext && ReferenceEquals(x.Store, store)) > 0; + } + } + + /// + /// Opens the shared file store on first use, so a deployment without a configured location stays unauthenticated + /// instead of losing revocations in a temporary directory. + /// + /// The shared file store, or null when no location is configured. + private FileIdentityTokenStore DefaultStore() + { + if (_defaultStore is not null) { return _defaultStore; } + var path = _server?.Configuration?["WebExpress:Authentication:TokenStorePath"]; + if (string.IsNullOrWhiteSpace(path)) { return null; } + _defaultStore = new FileIdentityTokenStore(path); + return _defaultStore; + } + + /// + /// Discovers stores for applications associated with a newly available plugin. + /// + /// The manager that raised the lifecycle event. + /// The plugin whose store bindings are affected. + private void OnAddPlugin(object sender, IPluginContext plugin) + { + foreach (var application in _hub.ApplicationManager.GetApplications(plugin)) { Discover(plugin, application); } + } + + /// + /// Discovers stores when an application becomes available after its plugins. + /// + /// The manager that raised the lifecycle event. + /// The application whose store binding is affected. + private void OnAddApplication(object sender, IApplicationContext application) + { + foreach (var plugin in _hub.PluginManager.GetPlugins(application)) { Discover(plugin, application); } + } + + /// + /// Binds the first injectable store a plugin implements, leaving an existing binding untouched so + /// load order cannot silently move an application's markers to a different store. + /// + /// The plugin whose store bindings are affected. + /// The application whose store binding is affected. + private void Discover(IPluginContext plugin, IApplicationContext application) + { + foreach (var type in plugin.Assembly.GetExportedTypes().Where(x => x.IsClass && !x.IsAbstract && + !x.ContainsGenericParameters && typeof(IIdentityTokenStore).IsAssignableFrom(x))) + { + lock (_gate) + { + if (_entries.Any(x => x.Application == application)) { return; } + var dependencies = new object[] { _hub, _server, application, plugin }; + var constructor = type.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .OrderByDescending(x => x.GetParameters().Length) + .FirstOrDefault(x => x.GetParameters().All(p => dependencies.Any(p.ParameterType.IsInstanceOfType))); + if (constructor is null) { continue; } + var store = (IIdentityTokenStore)constructor.Invoke(constructor.GetParameters() + .Select(p => dependencies.First(p.ParameterType.IsInstanceOfType)).ToArray()); + _entries.Add(new Entry(store, application, plugin, true)); + } + } + } + + /// + /// Removes stores whose owning plugin is no longer available. + /// + /// The manager that raised the lifecycle event. + /// The plugin whose store bindings are affected. + private void OnRemovePlugin(object sender, IPluginContext plugin) + { + Remove(x => x.Plugin == plugin); + } + + /// + /// Removes the binding of an application that no longer exists. + /// + /// The manager that raised the lifecycle event. + /// The application whose store binding is affected. + private void OnRemoveApplication(object sender, IApplicationContext application) + { + Remove(x => x.Application == application); + } + + /// + /// Unbinds and disposes the selected stores. + /// + /// The ownership condition selecting bindings for removal. + private void Remove(Predicate predicate) + { + lock (_gate) + { + var removed = _entries.FindAll(predicate); + _entries.RemoveAll(predicate); + Release(removed); + } + } + + /// + /// Disposes a store only once its last application binding is gone, because one instance may serve several applications. + /// + /// The bindings that were just removed. + private void Release(IEnumerable removed) + { + foreach (var store in removed.Select(x => x.Store).Distinct().OfType()) + { + if (!_entries.Any(x => ReferenceEquals(x.Store, store))) { store.Dispose(); } + } + } + + /// + /// Detaches lifecycle subscriptions and releases the bound stores when the manager is shut down. + /// + public void Dispose() + { + _hub.PluginManager.AddPlugin -= OnAddPlugin; + _hub.PluginManager.RemovePlugin -= OnRemovePlugin; + _hub.ApplicationManager.AddApplication -= OnAddApplication; + _hub.ApplicationManager.RemoveApplication -= OnRemoveApplication; + Remove(_ => true); + } + } +} diff --git a/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs index 19b0de13..db6c579a 100644 --- a/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs +++ b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs @@ -12,6 +12,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebSetting; @@ -71,11 +72,12 @@ protected OpenIdConnectIdentityProvider(OpenIdConnectSettings settings, HttpClie /// /// Starts a code flow whose verifier remains in the initiating browser's protected cookie. /// - /// The shared token service used to bind browser correlation to the local signing authority. - /// The application identifier that scopes token audiences and authentication. + /// The identity manager whose signing authority binds browser correlation to this deployment. + /// The application that scopes token audiences and authentication. /// A redirect response with a protected browser correlation cookie. - public async Task CreateChallengeAsync(IdentityTokenService tokens, string applicationId) + public async Task CreateChallengeAsync(IdentityManager identityManager, IApplicationContext applicationContext) { + var applicationId = applicationContext?.ApplicationId; var redirectQuery = QueryHelpers.ParseQuery(new Uri(_settings.RedirectUri).Query); if (redirectQuery["application"].Count != 1 || redirectQuery["application"] != applicationId || redirectQuery["provider"].Count != 1 || redirectQuery["provider"] != ProviderId) @@ -86,10 +88,10 @@ public async Task CreateChallengeAsync(IdentityTokenService tokens, s var state = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); var nonce = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); var verifier = Base64UrlEncoder.Encode(RandomNumberGenerator.GetBytes(32)); - var challenge = tokens.ProtectChallenge(new Dictionary + var challenge = identityManager.ProtectChallenge(new Dictionary { ["state"] = state, ["nonce"] = nonce, ["verifier"] = verifier, ["provider"] = ProviderId - }, applicationId); + }, applicationContext); var response = new ResponseMovedTemporarily(); response.Header.Location = QueryHelpers.AddQueryString(configuration.AuthorizationEndpoint, new Dictionary { @@ -109,11 +111,11 @@ public async Task CreateChallengeAsync(IdentityTokenService tokens, s /// signature, issuer, audience, lifetime, authorized party, and nonce before mapping claims. /// /// The HTTP request whose authentication context is being evaluated. - /// The shared token service used to bind browser correlation to the local signing authority. + /// The identity manager whose signing authority and token store verify browser correlation. /// The normalized external identity, or null when correlation or token validation fails. - public async Task AuthenticateCallbackAsync(IRequest request, IdentityTokenService tokens) + public async Task AuthenticateCallbackAsync(IRequest request, IdentityManager identityManager) { - var challenge = tokens.ValidateChallenge(IdentityManager.CookieValue(request, ChallengeCookieName), request.ApplicationContext?.ApplicationId); + var challenge = identityManager.ValidateChallenge(IdentityManager.CookieValue(request, ChallengeCookieName), request.ApplicationContext); var state = Query(request, "state"); var code = Query(request, "code"); if (challenge is null || string.IsNullOrEmpty(code) || code.Length > 4096 || Query(request, "error") is not null || @@ -123,7 +125,7 @@ public async Task AuthenticateCallbackAsync(IRequest request, Identit !challenge.TryGetPayloadValue("verifier", out var verifier)) { return null; } var issuer = Query(request, "iss"); if (issuer is not null && issuer != _settings.Authority) { return null; } - if (!tokens.ConsumeChallenge(challenge)) { return null; } + if (!identityManager.ConsumeChallenge(challenge, request.ApplicationContext)) { return null; } var configuration = await GetConfigurationAsync(); var form = new Dictionary { diff --git a/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs index e8aaa8b7..040f5c6d 100644 --- a/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/AuthenticationSettings.cs @@ -46,6 +46,7 @@ public sealed class AuthenticationSettings /// /// Places refresh replay markers and PAT revocations on durable shared storage. + /// Used by the default file store for every application without a plugin-supplied store. /// public string TokenStorePath { get; set; } From 6ce3bd194b1f4193f4f45e724bfb7668626f82fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Tue, 22 Sep 2026 22:23:03 +0200 Subject: [PATCH 41/69] feat: add secure-by-default headers, OWASP protections and HTTP/3 support --- .../Fixture/UnitTestFixture.cs | 6 +- .../Message/UnitTestRequestOriginGuard.cs | 72 ++++++++ .../Message/UnitTestResponseSender.cs | 158 +++++++++++++++++- .../Server/UnitTestHttpServerProtocols.cs | 32 ++++ src/WebExpress.WebCore/HttpEndpointInfo.cs | 13 ++ src/WebExpress.WebCore/HttpServer.cs | 107 ++++++++++-- .../Internationalization/de | 2 + .../Internationalization/en | 2 + .../WebHtml/HtmlElementScriptingScript.cs | 27 +++ .../WebHtml/HtmlScriptNonce.cs | 48 ++++++ .../WebHtml/Parser/HtmlElementFactory.cs | 3 +- .../IdentityManager.Authentication.cs | 2 + .../OpenIdConnectIdentityProvider.cs | 2 + .../WebMessage/RequestHeaderFields.cs | 7 + .../WebMessage/RequestOriginGuard.cs | 115 +++++++++++++ .../WebMessage/ResponseHeaderFields.cs | 12 +- .../WebMessage/ResponseSender.cs | 81 ++++++++- .../WebMessage/SecurityHeaders.cs | 151 +++++++++++++++++ .../WebSetting/HttpServerSettings.cs | 6 + .../WebSetting/KestrelSettings.cs | 7 +- .../WebSetting/SecuritySettings.cs | 66 ++++++++ 21 files changed, 887 insertions(+), 32 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs create mode 100644 src/WebExpress.WebCore.Test/Server/UnitTestHttpServerProtocols.cs create mode 100644 src/WebExpress.WebCore/HttpEndpointInfo.cs create mode 100644 src/WebExpress.WebCore/WebHtml/HtmlScriptNonce.cs create mode 100644 src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs create mode 100644 src/WebExpress.WebCore/WebMessage/SecurityHeaders.cs create mode 100644 src/WebExpress.WebCore/WebSetting/SecuritySettings.cs diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 88ce7000..4682bf47 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -203,8 +203,10 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "") foreach (var line in filteredLines) { - var key = line.Split(':').FirstOrDefault().Trim(); - var value = line.Split(':').Skip(1).FirstOrDefault().Trim(); + // split at the first colon only: values such as an origin contain further ones + var colon = line.IndexOf(':'); + var key = (colon < 0 ? line : line[..colon]).Trim(); + var value = colon < 0 ? "" : line[(colon + 1)..].Trim(); requestFeature.Headers[key] = value; } diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs b/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs new file mode 100644 index 00000000..a9766e8e --- /dev/null +++ b/src/WebExpress.WebCore.Test/Message/UnitTestRequestOriginGuard.cs @@ -0,0 +1,72 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.Test.Message +{ + /// + /// Unit tests for , which rejects requests another site + /// triggers in the browser of a signed-in user. + /// + public class UnitTestRequestOriginGuard + { + /// + /// Builds a request with the given method and extra header lines. + /// + /// The request method. + /// Header lines in wire format. + /// The request. + private static IRequest Request(string method, params string[] headers) + { + return UnitTestFixture.CreateRequestMock($"{method} /app HTTP/1.1\r\n{string.Join("\r\n", headers)}\r\n\r\n"); + } + + /// + /// Tests the decision for typical browser and non-browser requests. + /// + [Theory] + [InlineData("GET", true, "Origin: https://evil.example", "Sec-Fetch-Site: cross-site")] + [InlineData("POST", true, "Origin: http://localhost", "Sec-Fetch-Site: same-origin")] + [InlineData("POST", true, "Origin: http://localhost")] + [InlineData("POST", true, "Sec-Fetch-Site: none")] + [InlineData("POST", true)] + [InlineData("POST", false, "Origin: https://evil.example", "Sec-Fetch-Site: cross-site")] + [InlineData("DELETE", false, "Origin: https://evil.example")] + [InlineData("PUT", false, "Origin: null", "Sec-Fetch-Site: cross-site")] + [InlineData("POST", false, "Origin: https://sub.localhost", "Sec-Fetch-Site: same-site")] + [InlineData("POST", true, "Origin: https://public.example", "Sec-Fetch-Site: same-origin")] + [InlineData("POST", true, "Origin: https://evil.example", "Authorization: Bearer abc")] + public void Decision(string method, bool allowed, params string[] headers) + { + var guard = new RequestOriginGuard(null); + + Assert.Equal(allowed, guard.IsAllowed(Request(method, headers))); + } + + /// + /// Tests that a websocket is checked although it opens with a GET, since browsers + /// connect it across sites and send the cookies along. + /// + [Fact] + public void CrossSiteWebSocketIsRejected() + { + var guard = new RequestOriginGuard(null); + var request = Request("GET", "Origin: https://evil.example", "Sec-Fetch-Site: cross-site"); + + Assert.False(guard.IsAllowed(request, webSocket: true)); + } + + /// + /// Tests that a configured origin is accepted and that the check can be switched off. + /// + [Fact] + public void SettingsTrustOriginsAndDisableTheCheck() + { + var request = Request("POST", "Origin: https://portal.example", "Sec-Fetch-Site: cross-site"); + + Assert.True(new RequestOriginGuard(new SecuritySettings { TrustedOrigins = ["https://portal.example/"] }).IsAllowed(request)); + Assert.True(new RequestOriginGuard(new SecuritySettings { CsrfProtection = false }).IsAllowed(request)); + Assert.False(new RequestOriginGuard(new SecuritySettings()).IsAllowed(request)); + } + } +} diff --git a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs index 9ca862de..571d250b 100644 --- a/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs +++ b/src/WebExpress.WebCore.Test/Message/UnitTestResponseSender.cs @@ -1,9 +1,13 @@ using System.IO.Pipelines; using System.Net; using System.Text; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebHtml.Parser; using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; namespace WebExpress.WebCore.Test.Message { @@ -21,15 +25,161 @@ public class UnitTestResponseSender /// The response to send. /// The response feature carrying the emitted headers and status. private static async Task Send(string protocol, IResponse response) + { + return (await Send(protocol, "http", response, null)).Feature; + } + + /// + /// Sends the given response over the given scheme with the given security settings and + /// returns the emitted headers together with the written body. + /// + /// The negotiated protocol, e.g. "HTTP/1.1" or "HTTP/2". + /// The request scheme, "http" or "https". + /// The response to send. + /// The security settings, or null for the built-in defaults. + /// The response feature and the body as text. + private static async Task<(IHttpResponseFeature Feature, string Body)> Send(string protocol, string scheme, IResponse response, SecuritySettings settings) { var features = new FeatureCollection(); - features.Set(new HttpRequestFeature { Protocol = protocol, Headers = new HeaderDictionary() }); + var body = new FakeResponseBodyFeature(); + features.Set(new HttpRequestFeature { Protocol = protocol, Scheme = scheme, Headers = new HeaderDictionary() }); features.Set(new HttpResponseFeature { Headers = new HeaderDictionary() }); - features.Set(new FakeResponseBodyFeature()); + features.Set(body); + + await new ResponseSender(new SecurityHeaders(settings)).SendAsync(new FakeHttpContext(features), response); + + return (features.Get(), Encoding.UTF8.GetString(((MemoryStream)body.Stream).ToArray())); + } + + /// + /// Tests that a server without any security configuration still sends the protective + /// headers, and that HSTS stays off over plain http where browsers ignore it. + /// + [Fact] + public async Task SecurityHeadersAreSentByDefault() + { + var (feature, _) = await Send("HTTP/1.1", "http", new ResponseOK(), null); + + Assert.Equal("nosniff", feature.Headers["X-Content-Type-Options"]); + Assert.Equal("SAMEORIGIN", feature.Headers["X-Frame-Options"]); + Assert.Equal("strict-origin-when-cross-origin", feature.Headers["Referrer-Policy"]); + Assert.Contains("object-src 'none'", feature.Headers["Content-Security-Policy"].ToString()); + Assert.False(feature.Headers.ContainsKey("Strict-Transport-Security")); + } + + /// + /// Tests that HSTS is announced on https responses. + /// + [Fact] + public async Task HstsIsSentOverHttps() + { + var (feature, _) = await Send("HTTP/2", "https", new ResponseOK(), null); + + Assert.Equal("max-age=31536000", feature.Headers["Strict-Transport-Security"]); + } + + /// + /// Tests that the inline scripts the server renders carry the very nonce the policy of + /// the same response allows, so they keep running under the strict policy. + /// + [Fact] + public async Task InlineScriptCarriesNonceOfPolicy() + { + var response = new ResponseOK() { Content = new HtmlElementScriptingScript("let a = 1;") }; + + var (feature, body) = await Send("HTTP/1.1", "http", response, null); + + var nonce = Regex.Match(feature.Headers["Content-Security-Policy"].ToString(), "'nonce-([^']+)'").Groups[1].Value; + Assert.NotEmpty(nonce); + Assert.Contains($"nonce=\"{nonce}\"", body); + } + + /// + /// Tests that an html response without an explicit type is still declared as html, since + /// nosniff forbids the browser to guess and it would otherwise display the markup as text. + /// + [Fact] + public async Task HtmlContentIsTypedAsHtml() + { + var response = new ResponseOK() { Content = new HtmlElementTextContentDiv() }; + + var (feature, _) = await Send("HTTP/1.1", "http", response, null); + + Assert.Equal("text/html; charset=utf-8", feature.Headers.ContentType); + } + + /// + /// Tests that a script read from markup, which may stem from user input, gets no nonce + /// and is therefore still blocked by the browser. + /// + [Fact] + public async Task ParsedScriptGetsNoNonce() + { + var parsed = new HtmlParser().Parse("
"); + var response = new ResponseOK() { Content = parsed.First() }; + + var (_, body) = await Send("HTTP/1.1", "http", response, null); + + Assert.Contains(" + /// Tests that the settings can switch individual headers off, replace them, and move the + /// policy into report-only mode. + ///
+ [Fact] + public async Task SettingsOverrideDefaults() + { + var settings = new SecuritySettings + { + ContentSecurityPolicy = "default-src 'self' 'nonce-{nonce}'", + ContentSecurityPolicyReportOnly = true, + HstsMaxAge = 0, + Headers = new() { ["X-Frame-Options"] = "", ["Referrer-Policy"] = "no-referrer" } + }; + + var (feature, _) = await Send("HTTP/1.1", "https", new ResponseOK(), settings); + + Assert.False(feature.Headers.ContainsKey("Content-Security-Policy")); + Assert.StartsWith("default-src 'self' 'nonce-", feature.Headers["Content-Security-Policy-Report-Only"].ToString()); + Assert.False(feature.Headers.ContainsKey("Strict-Transport-Security")); + Assert.False(feature.Headers.ContainsKey("X-Frame-Options")); + Assert.Equal("no-referrer", feature.Headers["Referrer-Policy"]); + } + + /// + /// Tests that cookies take the configured SameSite default unless they state their own. + /// + [Fact] + public async Task CookieSameSiteFollowsSettingsAndPerCookieOverride() + { + var response = new ResponseOK(); + response.Header.Cookies.Add(new Cookie("a", "1")); + response.Header.Cookies.Add(new Cookie("b", "2")); + response.Header.CookieSameSite["b"] = SameSiteMode.Lax; - await new ResponseSender().SendAsync(new FakeHttpContext(features), response); + var (feature, _) = await Send("HTTP/1.1", "http", response, new SecuritySettings { CookieSameSite = SameSiteMode.Strict }); - return features.Get(); + var cookies = feature.Headers.SetCookie.ToArray(); + Assert.Contains(cookies, x => x.StartsWith("a=1") && x.EndsWith("SameSite=Strict")); + Assert.Contains(cookies, x => x.StartsWith("b=2") && x.EndsWith("SameSite=Lax")); } /// diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerProtocols.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerProtocols.cs new file mode 100644 index 00000000..ea771819 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerProtocols.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Unit tests for the protocol resolution of , which offers HTTP/3 + /// by default wherever it can work and removes it wherever it cannot. + /// + public class UnitTestHttpServerProtocols + { + /// + /// Tests the protocols applied for each combination of configuration, TLS and QUIC support. + /// + [Theory] + // tls with quic: http/3 is offered unless the configuration says otherwise + [InlineData(null, true, true, HttpProtocols.Http1AndHttp2AndHttp3)] + [InlineData(HttpProtocols.Http1AndHttp2, true, true, HttpProtocols.Http1AndHttp2)] + [InlineData(HttpProtocols.Http3, true, true, HttpProtocols.Http3)] + // tls without quic: http/3 is removed, and a pure http/3 endpoint falls back to tcp + [InlineData(null, true, false, HttpProtocols.Http1AndHttp2)] + [InlineData(HttpProtocols.Http1AndHttp2AndHttp3, true, false, HttpProtocols.Http1AndHttp2)] + [InlineData(HttpProtocols.Http3, true, false, HttpProtocols.Http1AndHttp2)] + // plain http: quic needs tls, so the kestrel default stays and http/3 is removed + [InlineData(null, false, true, null)] + [InlineData(HttpProtocols.Http1, false, true, HttpProtocols.Http1)] + [InlineData(HttpProtocols.Http1AndHttp2AndHttp3, false, true, HttpProtocols.Http1AndHttp2)] + public void ResolveProtocols(HttpProtocols? configured, bool tls, bool quic, HttpProtocols? expected) + { + Assert.Equal(expected, HttpServer.ResolveProtocols(configured, tls, quic)); + } + } +} diff --git a/src/WebExpress.WebCore/HttpEndpointInfo.cs b/src/WebExpress.WebCore/HttpEndpointInfo.cs new file mode 100644 index 00000000..67075cf8 --- /dev/null +++ b/src/WebExpress.WebCore/HttpEndpointInfo.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Server.Kestrel.Core; + +namespace WebExpress.WebCore +{ + /// + /// Describes a listening endpoint as the server actually opened it, so diagnostics show the + /// served protocols rather than the configured ones. + /// + /// The ip address and port. + /// Whether the endpoint uses TLS. + /// The HTTP protocols the endpoint serves. + public sealed record HttpEndpointInfo(string Endpoint, bool Tls, HttpProtocols Protocols); +} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index f8843ca2..ade78f9e 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -13,6 +13,7 @@ using System.Globalization; using System.Linq; using System.Net; +using System.Net.Quic; using System.Net.Sockets; using System.Reflection; using System.Threading; @@ -40,6 +41,33 @@ public class HttpServer : IHost, IHttpApplication { private readonly Lazy _authenticationEndpoint; private Microsoft.Extensions.Hosting.IHost _webHost; + private SecurityHeaders _securityHeaders; + + /// + /// Gets the security headers of every response. They are resolved on first use because + /// the settings are assigned after construction. + /// + public SecurityHeaders SecurityHeaders => _securityHeaders ??= new SecurityHeaders(Settings?.Security); + + private RequestOriginGuard _originGuard; + + /// + /// Gets the cross-site request forgery check, resolved on first use for the same reason. + /// + public RequestOriginGuard OriginGuard => _originGuard ??= new RequestOriginGuard(Settings?.Security); + + private readonly List _listeningEndpoints = []; + + /// + /// Gets the endpoints the server listens on together with the protocols each one + /// actually serves, which can differ from the configuration when HTTP/3 had to be dropped. + /// + public IReadOnlyList ListeningEndpoints => _listeningEndpoints; + + /// + /// Gets whether the operating system provides QUIC, the transport HTTP/3 depends on. + /// + public static bool QuicSupported => QuicListener.IsSupported; /// /// Event is triggered after the web server is started. @@ -312,11 +340,14 @@ private void AddEndpoint(OptionsWrapper serverOptions, End /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, HttpProtocols? protocols) { + var effective = ResolveProtocols(protocols, tls: false, QuicListener.IsSupported); + _listeningEndpoints.Add(new HttpEndpointInfo(endPoint.ToString(), false, effective ?? HttpProtocols.Http1AndHttp2)); + serverOptions.Value.Listen(endPoint, configure => { - if (protocols is not null) + if (effective is not null) { - configure.Protocols = protocols.Value; + configure.Protocols = effective.Value; } }); HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.listen"), args: endPoint.ToString()); @@ -331,6 +362,19 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE /// The HTTP protocols to enable on the endpoint, or null to keep the Kestrel default. private void AddEndpoint(OptionsWrapper serverOptions, IPEndPoint endPoint, CertificateMaterial certificate, HttpProtocols? protocols) { + var quic = QuicListener.IsSupported; + var effective = ResolveProtocols(protocols, tls: true, quic); + _listeningEndpoints.Add(new HttpEndpointInfo(endPoint.ToString(), true, effective.Value)); + + if (effective.Value.HasFlag(HttpProtocols.Http3)) + { + HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.http3"), args: endPoint.ToString()); + } + else if (!quic && (protocols?.HasFlag(HttpProtocols.Http3) ?? true)) + { + HttpServerContext.Log?.Warning(message: I18N.Translate("webexpress.webcore:httpserver.http3.unsupported"), args: endPoint.ToString()); + } + serverOptions.Value.Listen(endPoint, configure => { configure.UseHttps(new HttpsConnectionAdapterOptions @@ -342,15 +386,41 @@ private void AddEndpoint(OptionsWrapper serverOptions, IPE ) }); - if (protocols is not null) - { - configure.Protocols = protocols.Value; - } + configure.Protocols = effective.Value; }); HttpServerContext.Log?.Info(message: I18N.Translate("webexpress.webcore:httpserver.listen"), args: endPoint.ToString()); } + /// + /// Determines the protocols an endpoint actually serves. A TLS endpoint without explicit + /// configuration offers HTTP/3 next to HTTP/1.1 and HTTP/2; Kestrel then announces it + /// through the Alt-Svc header, so browsers switch to QUIC on their own and fall back to + /// TCP wherever UDP is blocked. HTTP/3 is dropped where it cannot work - without TLS, + /// which QUIC requires, or without QUIC support in the operating system - because an + /// endpoint restricted to it would otherwise answer nothing at all. + /// + /// The configured protocols, or null when nothing was configured. + /// Whether the endpoint uses TLS. + /// Whether the operating system provides QUIC. + /// The protocols to apply, or null to keep the Kestrel default. + internal static HttpProtocols? ResolveProtocols(HttpProtocols? configured, bool tls, bool quicSupported) + { + if (tls && quicSupported) + { + return configured ?? HttpProtocols.Http1AndHttp2AndHttp3; + } + + if (configured is not HttpProtocols value || !value.HasFlag(HttpProtocols.Http3)) + { + return tls ? configured ?? HttpProtocols.Http1AndHttp2 : configured; + } + + var remaining = value & ~HttpProtocols.Http3; + + return remaining == HttpProtocols.None ? HttpProtocols.Http1AndHttp2 : remaining; + } + /// /// Stops the HTTP(S) server. /// @@ -812,7 +882,7 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) httpContext?.Request ); - await new ResponseSender().SendAsync(httpContext, response); + await new ResponseSender(SecurityHeaders).SendAsync(httpContext, response); } } @@ -828,10 +898,13 @@ public async Task ProcessRequestAsync(IHttpContext httpContext) /// The html fragment. private static string Describe(Exception ex) { - return $"

Message

{ex.Message}

" + - $"
Source
{ex.Source}

" + - $"
StackTrace
{ex.StackTrace?.Replace("\n", "
\n")}

" + - $"
InnerException
{ex.InnerException?.ToString().Replace("\n", "
\n")}"; + // messages routinely quote request data such as the path, which must not become markup + static string Encode(string text) => WebUtility.HtmlEncode(text)?.Replace("\n", "
\n"); + + return $"

Message

{Encode(ex.Message)}

" + + $"
Source
{Encode(ex.Source)}

" + + $"
StackTrace
{Encode(ex.StackTrace)}

" + + $"
InnerException
{Encode(ex.InnerException?.ToString())}"; } /// @@ -844,7 +917,7 @@ private static string Describe(Exception ex) /// Provides an asynchronous operation that handles the http context. private async Task ProcessRequestCoreAsync(IHttpContext httpContext) { - var sender = new ResponseSender(); + var sender = new ResponseSender(SecurityHeaders); var stopwatch = Stopwatch.StartNew(); /* @@ -876,6 +949,14 @@ async Task SendAsync(IHttpContext context, IResponse response) return; } + // checked before any handler runs, so no application can forget it + if (!OriginGuard.IsAllowed(httpContext.Request, httpContext is HttpWebSocketContext)) + { + await SendAsync(httpContext, new ResponseForbidden(new StatusMessage("Cross-site request rejected."))); + + return; + } + var authenticationResponse = await _authenticationEndpoint.Value.HandleAsync(httpContext.Request); if (authenticationResponse is not null) { @@ -1025,7 +1106,7 @@ async Task SendAsync(IHttpContext context, IResponse response) /// public async Task HandleWebSocketAsync(IHttpContext httpContext, ISocketContext socketContext) { - var responseSender = new ResponseSender(); + var responseSender = new ResponseSender(SecurityHeaders); var socketManager = WebEx.ComponentHub.SocketManager; // validate that the request is a websocket upgrade diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 7c92885c..3e62cbe3 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -28,6 +28,8 @@ httpserver.connectionlimit=Das Limit für gleichzeitige Anfragen wurde überschr httpserver.endpoint=Endpunkt '{0}' wird registriert. httpserver.listen=Der Webserver lauscht auf den Endpunkt {0}. httpserver.listen.exeption=Fehler beim Einrichten eines Endpunktes für die Uri {0}. +httpserver.http3=HTTP/3 (QUIC) ist auf dem Endpunkt {0} aktiv; der UDP-Port muss in der Firewall freigegeben sein. +httpserver.http3.unsupported=HTTP/3 ist nicht verfügbar, da das Betriebssystem kein QUIC bereitstellt (unter Linux das Paket libmsquic installieren). Der Endpunkt {0} bedient HTTP/1.1 und HTTP/2. httpserver.listener.try=Falls Ihnen Zugriffsrechte fehlen, führen Sie unter Windows folgende Anweisung als Administrator aus: httpserver.listener.windows=netsh http add urlacl url={0} user=Jeder listen=yes diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 8147376e..97bfea04 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -28,6 +28,8 @@ httpserver.connectionlimit=The limit for concurrent requests has been exceeded. httpserver.endpoint=Endpoint '{0}' is registered. httpserver.listen=The web server listens for the endpoint {0}. httpserver.listen.exeption=Failed to set up an endpoint for the Uri {0}. +httpserver.http3=HTTP/3 (QUIC) is active on the endpoint {0}; the UDP port must be open in the firewall. +httpserver.http3.unsupported=HTTP/3 is unavailable because the operating system provides no QUIC (on Linux install the libmsquic package). The endpoint {0} serves HTTP/1.1 and HTTP/2. httpserver.listener.try=If you do not have access rights follow the instruction as an administrator in the console: httpserver.listener.windows=netsh http add urlacl url={0} user=everyone listen=yes diff --git a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs index 12038af7..9785a464 100644 --- a/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs +++ b/src/WebExpress.WebCore/WebHtml/HtmlElementScriptingScript.cs @@ -39,6 +39,13 @@ public string Src set => SetAttribute("src", value); } + /// + /// Gets or sets whether the script was written by the application rather than read from + /// markup. Only a trusted script receives the content security policy nonce of the + /// response, so a script smuggled into parsed user content stays blocked by the browser. + /// + public bool Trusted { get; set; } = true; + /// /// Initializes a new instance of the class. /// @@ -80,5 +87,25 @@ public override void ToString(StringBuilder builder, int deep) ToPostString(builder, deep, false); } + + /// + /// Writes the opening tag and adds the nonce of the response being rendered. The nonce + /// is appended here instead of being stored as an attribute because the same element + /// can be serialized for several responses, each with its own nonce. + /// + /// The string builder. + /// The call depth. + protected override void ToPreString(StringBuilder builder, int deep) + { + base.ToPreString(builder, deep); + + var nonce = Trusted ? HtmlScriptNonce.Current : null; + + if (!string.IsNullOrEmpty(nonce)) + { + // the nonce is base64 and thus never needs attribute escaping + builder.Insert(builder.Length - 1, $" nonce=\"{nonce}\""); + } + } } } diff --git a/src/WebExpress.WebCore/WebHtml/HtmlScriptNonce.cs b/src/WebExpress.WebCore/WebHtml/HtmlScriptNonce.cs new file mode 100644 index 00000000..6154eeb4 --- /dev/null +++ b/src/WebExpress.WebCore/WebHtml/HtmlScriptNonce.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; + +namespace WebExpress.WebCore.WebHtml +{ + /// + /// Carries the content security policy nonce of the response that is currently being + /// rendered to the inline scripts in it. The html tree is built long before the response + /// exists and is shared across requests, so the nonce cannot be stored on the elements; + /// it is scoped to the serialization instead. + /// + public static class HtmlScriptNonce + { + private static readonly AsyncLocal _current = new(); + + /// + /// Gets the nonce of the rendering in progress, or null outside of one. + /// + public static string Current => _current.Value; + + /// + /// Makes the nonce visible to every inline script serialized until the scope is disposed. + /// + /// The nonce announced in the content security policy header. + /// A scope that restores the previous nonce when disposed. + public static IDisposable Begin(string nonce) + { + var previous = _current.Value; + _current.Value = nonce; + + return new Scope(previous); + } + + /// + /// Restores the nonce that was active before the scope began. + /// + private sealed class Scope(string previous) : IDisposable + { + /// + /// Restores the previous nonce. + /// + public void Dispose() + { + _current.Value = previous; + } + } + } +} diff --git a/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs b/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs index b44c04b1..a4c4fac0 100644 --- a/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs +++ b/src/WebExpress.WebCore/WebHtml/Parser/HtmlElementFactory.cs @@ -28,7 +28,8 @@ public class HtmlElementFactory ["title"] = () => new HtmlElementMetadataTitle(), // Scripting - ["script"] = () => new HtmlElementScriptingScript(), + // parsed markup may stem from user input; a nonce would let a stored script run + ["script"] = () => new HtmlElementScriptingScript() { Trusted = false }, ["noscript"] = () => new HtmlElementScriptingNoscript(), ["canvas"] = () => new HtmlElementScriptingCanvas(), diff --git a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs index 54114c10..55712389 100644 --- a/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs +++ b/src/WebExpress.WebCore/WebIdentity/IdentityManager.Authentication.cs @@ -154,6 +154,8 @@ public void ApplyAuthenticationCookies(IRequest request, IResponse response) // an expired access credential still identifies the grant for logout; validation always enforces its signed expiry response.Header.Cookies.Add(CreateCookie(AccessCookie, state.Pair?.AccessToken, "/", state.Pair?.RefreshTokenExpiresAt, RequiresHttps)); response.Header.Cookies.Add(CreateCookie(RefreshCookie, state.Pair?.RefreshToken, RefreshPath, state.Pair?.RefreshTokenExpiresAt, RequiresHttps)); + // only the own scripts ever renew the grant, so no cross-site request needs the refresh token + response.Header.CookieSameSite[RefreshCookie] = Microsoft.AspNetCore.Http.SameSiteMode.Strict; } /// diff --git a/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs index db6c579a..33b7f913 100644 --- a/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs +++ b/src/WebExpress.WebCore/WebIdentity/OpenIdConnectIdentityProvider.cs @@ -102,6 +102,8 @@ public async Task CreateChallengeAsync(IdentityManager identityManage }); response.Header.Cookies.Add(IdentityManager.CreateCookie(ChallengeCookieName, challenge, "/api/auth/callback", DateTimeOffset.UtcNow.AddMinutes(5))); + // the identity provider redirects back cross-site, which a strict cookie would not survive + response.Header.CookieSameSite[ChallengeCookieName] = Microsoft.AspNetCore.Http.SameSiteMode.Lax; response.Header.CacheControl = "no-store"; return response; } diff --git a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs index 43fcc19c..d87ad59c 100644 --- a/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/RequestHeaderFields.cs @@ -89,6 +89,12 @@ public class RequestHeaderFields /// public string AuthenticationRequest { get; private set; } + /// + /// Gets the Sec-Fetch-Site header. Browsers set it themselves and scripts cannot forge it, + /// so it tells reliably whether a request was triggered by another site. + /// + public string SecFetchSite { get; private set; } + /// /// Gets the If-None-Match header value. It carries the entity tag (ETag) the client already /// holds and is used for conditional requests so unchanged resources can be answered with 304. @@ -136,6 +142,7 @@ internal RequestHeaderFields(IFeatureCollection contextFeatures) Referer = requestFeature.Headers.Referer; Origin = requestFeature.Headers.Origin; AuthenticationRequest = requestFeature.Headers["X-WebExpress-Auth"]; + SecFetchSite = requestFeature.Headers["Sec-Fetch-Site"]; IfNoneMatch = requestFeature.Headers.IfNoneMatch; Upgrade = requestFeature.Headers.Upgrade; SecWebSocketKey = requestFeature.Headers.SecWebSocketKey; diff --git a/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs b/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs new file mode 100644 index 00000000..ca79d495 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/RequestOriginGuard.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Protects cookie-authenticated requests against cross-site request forgery by checking + /// where a request comes from, as recommended by OWASP for fetch metadata and origin + /// verification. It needs no token in forms or scripts: browsers attach both headers on + /// their own and a page of another site cannot suppress or forge them. + /// + /// + /// A request carrying neither header does not come from a current browser, and so cannot + /// have been forged within one; command-line and server-side clients keep working. + /// + public sealed class RequestOriginGuard + { + private readonly bool _enabled; + private readonly HashSet _trustedOrigins; + + /// + /// Gets whether cross-site requests are rejected. + /// + public bool Enabled => _enabled; + + /// + /// Gets the further origins that may send state-changing requests. + /// + public IEnumerable TrustedOrigins => _trustedOrigins.Order(StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The configured overrides, or null for the built-in defaults. + public RequestOriginGuard(SecuritySettings settings) + { + _enabled = settings?.CsrfProtection ?? true; + _trustedOrigins = new HashSet + ( + (settings?.TrustedOrigins ?? []).Select(Normalize).Where(x => x is not null), + StringComparer.OrdinalIgnoreCase + ); + } + + /// + /// Determines whether the request may be processed. + /// + /// The incoming request. + /// Whether the request opens a websocket, which browsers allow across sites and which therefore needs the check despite being a GET. + /// True when the request is allowed; false when another site triggered it. + public bool IsAllowed(IRequest request, bool webSocket = false) + { + if (!_enabled || request is null) + { + return true; + } + + if (!webSocket && request.Method is RequestMethod.GET or RequestMethod.HEAD) + { + return true; + } + + // an explicit credential is never sent automatically, so a foreign page cannot borrow it + if (!webSocket && request.Header?.Authorization is not null) + { + return true; + } + + var origin = Normalize(request.Header?.Origin); + + if (origin is not null && (_trustedOrigins.Contains(origin) || origin == OwnOrigin(request))) + { + return true; + } + + // the browser's own verdict also holds behind a proxy that rewrites the host + return request.Header?.SecFetchSite?.ToLowerInvariant() switch + { + "same-origin" or "none" => true, + null => string.IsNullOrEmpty(request.Header?.Origin), + _ => false + }; + } + + /// + /// Determines the origin the browser addressed. It is taken from the host header rather + /// than the request uri, which carries the local listening port and would differ from + /// the browser's view whenever a container or proxy maps the port. + /// + /// The incoming request. + /// The origin, or null when it cannot be determined. + private static string OwnOrigin(IRequest request) + { + var scheme = Uri.TryCreate(request.Uri?.ToString(), UriKind.Absolute, out var uri) ? uri.Scheme : null; + + return scheme is null || string.IsNullOrWhiteSpace(request.Header?.Host) + ? null + : Normalize($"{scheme}://{request.Header.Host}"); + } + + /// + /// Reduces a url to its scheme, host and port, the part an origin consists of. + /// + /// The url or origin. + /// The origin, or null when the value is empty, opaque ("null") or not a url. + private static string Normalize(string url) + { + return Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https" + ? uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant() + : null; + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs index 0495214e..50b04e91 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseHeaderFields.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; using System.Net; using System.Text; @@ -56,6 +58,14 @@ public class ResponseHeaderFields /// public CookieCollection Cookies { get; } = []; + /// + /// Gets the SameSite attribute of individual cookies by name. cannot + /// carry the attribute itself, and a cookie whose flow depends on it - a cross-site login + /// callback, or a credential that must never leave the own site - states it here instead + /// of inheriting the server-wide default. + /// + public IDictionary CookieSameSite { get; } = new Dictionary(StringComparer.Ordinal); + /// /// Gets or sets the Upgrade header (for protocol upgrade responses, e.g. "websocket"). /// diff --git a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs index d4ed9d37..c74d5f76 100644 --- a/src/WebExpress.WebCore/WebMessage/ResponseSender.cs +++ b/src/WebExpress.WebCore/WebMessage/ResponseSender.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Primitives; using System; using System.Collections.Generic; @@ -15,6 +16,17 @@ namespace WebExpress.WebCore.WebMessage /// public class ResponseSender { + private readonly SecurityHeaders _securityHeaders; + + /// + /// Initializes a new instance of the class. + /// + /// The security headers every response carries, or null for the built-in defaults. + public ResponseSender(SecurityHeaders securityHeaders = null) + { + _securityHeaders = securityHeaders ?? SecurityHeaders.Default; + } + /// /// Sends the specified response message asynchronously to the provided context. /// @@ -45,6 +57,28 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA // websocket handshake but are forbidden on HTTP/2+ (RFC 9113 §8.2.2), where Kestrel // rejects them. Only emit them while the connection still speaks HTTP/1.x. var allowConnectionSpecificHeaders = !IsHttp2OrHigher(context); + var nonce = SecurityHeaders.CreateNonce(); + + foreach (var header in _securityHeaders.Resolve(IsHttps(context), nonce)) + { + responseFeature.Headers[header.Key] = header.Value; + } + + // a handler's own header wins over the server-wide default of the same name + foreach (var header in response.Header.CustomHeader) + { + responseFeature.Headers[header.Key] = header.Value; + } + + if (!string.IsNullOrWhiteSpace(response.Header.ContentDisposition)) + { + responseFeature.Headers.ContentDisposition = response.Header.ContentDisposition; + } + + if (!string.IsNullOrWhiteSpace(response.Header.ContentLanguage)) + { + responseFeature.Headers.ContentLanguage = response.Header.ContentLanguage; + } if (response.Header.Location is not null) { @@ -60,6 +94,11 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA { responseFeature.Headers.ContentType = response.Header.ContentType; } + else if (response.Content is IHtmlNode) + { + // with nosniff the browser no longer guesses, and an untyped page would be shown as text + responseFeature.Headers.ContentType = $"text/html; charset={context.Encoding.WebName}"; + } if (response.Header.WWWAuthenticate) { @@ -75,7 +114,9 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA // so attributes survive the round trip. var headerValues = response.Header.Cookies .Cast() - .Select(SerializeSetCookie) + .Select(cookie => SerializeSetCookie(cookie, response.Header.CookieSameSite.TryGetValue(cookie.Name, out var sameSite) + ? sameSite + : _securityHeaders.CookieSameSite)) .Where(s => !string.IsNullOrEmpty(s)) .ToArray(); if (headerValues.Length > 0) @@ -115,7 +156,14 @@ public async Task SendAsync(IHttpContext context, IResponse response, bool keepA } else if (response?.Content is IHtmlNode htmlContent) { - var content = context.Encoding.GetBytes(htmlContent?.ToString()); + string html; + + using (HtmlScriptNonce.Begin(nonce)) + { + html = htmlContent.ToString(); + } + + var content = context.Encoding.GetBytes(html); responseFeature.Headers.ContentLength = content.Length; await responseBodyFeature.Stream.WriteAsync(content); @@ -154,15 +202,29 @@ private static bool IsHttp2OrHigher(IHttpContext context) || protocol.StartsWith("HTTP/3", StringComparison.OrdinalIgnoreCase)); } + /// + /// Determines whether the request arrived over https, the only transport on which a + /// browser honours HSTS. + /// + /// The request context whose scheme is inspected. + /// True for an https request; otherwise false. + private static bool IsHttps(IHttpContext context) + { + var scheme = context?.Features?.Get()?.Scheme; + + return string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase); + } + /// /// Serialises a as a single Set-Cookie header /// value preserving Path, Domain, Expires and the HttpOnly / Secure - /// flags. SameSite defaults to Lax because System.Net.Cookie - /// does not model the attribute directly. + /// flags. SameSite is passed in because System.Net.Cookie does not + /// model the attribute. /// /// The cookie to serialise. + /// The SameSite attribute of the cookie. /// A Set-Cookie header value, or null when the cookie is empty. - private static string SerializeSetCookie(Cookie cookie) + private static string SerializeSetCookie(Cookie cookie, SameSiteMode sameSite) { if (cookie is null || string.IsNullOrEmpty(cookie.Name)) { @@ -196,9 +258,10 @@ private static string SerializeSetCookie(Cookie cookie) parts.Add("Secure"); } - // System.Net.Cookie has no SameSite property; default to Lax for - // first-party fit-for-purpose behaviour without cross-site leaks. - parts.Add("SameSite=Lax"); + if (sameSite != SameSiteMode.Unspecified) + { + parts.Add($"SameSite={sameSite}"); + } return string.Join("; ", parts); } diff --git a/src/WebExpress.WebCore/WebMessage/SecurityHeaders.cs b/src/WebExpress.WebCore/WebMessage/SecurityHeaders.cs new file mode 100644 index 00000000..0e8e9f4e --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/SecurityHeaders.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using WebExpress.WebCore.WebSetting; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Resolves the security headers every response carries. The defaults follow the OWASP secure + /// headers recommendations, so an application is protected without configuring anything; + /// can only loosen them explicitly. + /// + public sealed class SecurityHeaders + { + /// + /// The policy applied when a server runs without a security settings block. + /// + public static SecurityHeaders Default { get; } = new(null); + + /// + /// The built-in content security policy. Scripts are the actual attack surface and are + /// limited to the own origin and the nonce-carrying inline scripts the server renders. + /// Inline styles stay allowed because the controls emit style attributes throughout, + /// and style injection cannot execute code. Images and media may come from any https + /// source, since editor content legitimately references external pictures. + /// + public const string DefaultContentSecurityPolicy = + "default-src 'self'; " + + "script-src 'self' 'nonce-{nonce}'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: blob: https:; " + + "media-src 'self' blob: https:; " + + "font-src 'self' data:; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'self'"; + + private const int DefaultHstsMaxAge = 365 * 24 * 60 * 60; + + private readonly string _contentSecurityPolicy; + private readonly string _contentSecurityPolicyHeader; + private readonly string _hsts; + private readonly Dictionary _headers; + + /// + /// Gets the SameSite attribute of cookies that do not set their own. + /// + public SameSiteMode CookieSameSite { get; } + + /// + /// Gets the content security policy with the {nonce} placeholder still in place, + /// or null when the header is switched off. + /// + public string ContentSecurityPolicy => string.IsNullOrWhiteSpace(_contentSecurityPolicy) ? null : _contentSecurityPolicy; + + /// + /// Gets whether the policy is only reported instead of enforced. + /// + public bool ContentSecurityPolicyReportOnly => _contentSecurityPolicyHeader != "Content-Security-Policy"; + + /// + /// Gets the HSTS value sent on https responses, or null when HSTS is switched off. + /// + public string StrictTransportSecurity => _hsts; + + /// + /// Gets the further headers every response carries, without the switched-off ones. + /// + public IReadOnlyDictionary Headers => _headers + .Where(x => !string.IsNullOrWhiteSpace(x.Value)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase); + + /// + /// Initializes a new instance of the class. + /// + /// The configured overrides, or null for the built-in defaults. + public SecurityHeaders(SecuritySettings settings) + { + _contentSecurityPolicy = settings?.ContentSecurityPolicy ?? DefaultContentSecurityPolicy; + _contentSecurityPolicyHeader = settings?.ContentSecurityPolicyReportOnly == true + ? "Content-Security-Policy-Report-Only" + : "Content-Security-Policy"; + + var maxAge = settings?.HstsMaxAge ?? DefaultHstsMaxAge; + _hsts = maxAge > 0 + ? $"max-age={maxAge}" + (settings?.HstsIncludeSubDomains == true ? "; includeSubDomains" : "") + : null; + + // unspecified is not a valid attribute value, so it falls back to the default + CookieSameSite = settings?.CookieSameSite is SameSiteMode mode && mode != SameSiteMode.Unspecified + ? mode + : SameSiteMode.Lax; + + _headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["X-Content-Type-Options"] = "nosniff", + // frame-ancestors supersedes it, but older browsers only know this header + ["X-Frame-Options"] = "SAMEORIGIN", + ["Referrer-Policy"] = "strict-origin-when-cross-origin", + ["Cross-Origin-Opener-Policy"] = "same-origin", + ["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=()" + }; + + foreach (var header in settings?.Headers ?? []) + { + _headers[header.Key] = header.Value; + } + } + + /// + /// Creates a nonce for one response. 128 random bits make guessing it infeasible, which + /// is all the content security policy relies on. + /// + /// The nonce, base64 encoded. + public static string CreateNonce() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(16)); + } + + /// + /// Resolves the headers of one response. + /// + /// Whether the response travels over https; HSTS is only valid there. + /// The nonce the inline scripts of the response carry. + /// The header names and values, without the switched-off ones. + public IEnumerable> Resolve(bool https, string nonce) + { + if (!string.IsNullOrWhiteSpace(_contentSecurityPolicy)) + { + yield return new(_contentSecurityPolicyHeader, _contentSecurityPolicy.Replace("{nonce}", nonce)); + } + + if (https && _hsts is not null) + { + yield return new("Strict-Transport-Security", _hsts); + } + + foreach (var header in _headers) + { + if (!string.IsNullOrWhiteSpace(header.Value)) + { + yield return header; + } + } + } + } +} diff --git a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs index 817c5206..0bbbf5ec 100644 --- a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs @@ -56,6 +56,12 @@ public sealed class HttpServerSettings ///
public AuthenticationSettings Authentication { get; set; } + /// + /// Optional overrides of the security headers and cookie attributes. When the block is + /// omitted the restrictive built-in defaults apply. + /// + public SecuritySettings Security { get; set; } + /// /// The log settings. A missing block keeps logging switched off. /// diff --git a/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs b/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs index 8d51af92..aecb4bea 100644 --- a/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/KestrelSettings.cs @@ -31,8 +31,11 @@ public sealed class KestrelSettings /// /// The HTTP protocols enabled on every listening endpoint, given as the name of a Kestrel /// value (e.g. Http1, Http2 or Http1AndHttp2). - /// When not specified the Kestrel default (Http1AndHttp2) is kept, which negotiates - /// HTTP/2 over TLS via ALPN and serves plain HTTP as HTTP/1.1. Set Http2 on a plain + /// When not specified, a TLS endpoint serves Http1AndHttp2AndHttp3 - HTTP/3 over QUIC + /// wherever the operating system provides it, announced to browsers via Alt-Svc - and a + /// plain endpoint keeps the Kestrel default, serving HTTP/1.1. Configure + /// Http1AndHttp2 to keep a TLS endpoint off UDP. HTTP/3 is dropped automatically + /// without TLS or QUIC support, so it never leaves an endpoint unreachable. Set Http2 on a plain /// (non-TLS) endpoint to enable cleartext HTTP/2 (h2c), which has no automatic upgrade path. /// /// diff --git a/src/WebExpress.WebCore/WebSetting/SecuritySettings.cs b/src/WebExpress.WebCore/WebSetting/SecuritySettings.cs new file mode 100644 index 00000000..04c13d90 --- /dev/null +++ b/src/WebExpress.WebCore/WebSetting/SecuritySettings.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Http; +using System.Collections.Generic; + +namespace WebExpress.WebCore.WebSetting +{ + /// + /// Optional overrides of the security headers and cookie attributes the server sends with + /// every response. The defaults are restrictive on purpose: a deployment that omits the block + /// is protected, and relaxing a protection is always an explicit, visible decision. + /// + public sealed class SecuritySettings + { + /// + /// The content security policy. Left unset, a strict built-in policy applies that only + /// allows scripts from the own origin plus the inline scripts the server itself renders, + /// which carry a per-response nonce. The placeholder {nonce} in a custom policy is + /// replaced by that nonce. An empty value switches the header off. + /// + public string ContentSecurityPolicy { get; set; } + + /// + /// Sends the content security policy as Content-Security-Policy-Report-Only, so a + /// deployment can observe violations in the browser console before enforcing the policy. + /// + public bool? ContentSecurityPolicyReportOnly { get; set; } + + /// + /// The lifetime of the HSTS policy in seconds. It is sent on https responses only, since + /// browsers ignore it over plain http. Left unset, one year applies; zero switches it off. + /// + public int? HstsMaxAge { get; set; } + + /// + /// Extends HSTS to every subdomain. Off by default, because it also forces https on + /// unrelated hosts below the same domain, which a deployment has to decide consciously. + /// + public bool? HstsIncludeSubDomains { get; set; } + + /// + /// The SameSite attribute of cookies that do not demand a stricter or looser one + /// themselves. Left unset, Lax applies: Strict would drop the session on + /// every link that leads into the application from another site. + /// + public SameSiteMode? CookieSameSite { get; set; } + + /// + /// Overrides further response headers by name. A value replaces the built-in default of + /// that header, an empty value removes it, and an unknown name is added to every response. + /// + public Dictionary Headers { get; set; } + + /// + /// Rejects state-changing requests and websocket connections another site triggers in + /// the browser of a signed-in user. On by default; switching it off leaves cookie + /// authentication open to cross-site request forgery. + /// + public bool? CsrfProtection { get; set; } + + /// + /// Further origins, such as https://portal.example.org, that may send + /// state-changing requests. Needed when a reverse proxy rewrites the host, so the + /// browser's origin differs from the one the server sees. + /// + public List TrustedOrigins { get; set; } + } +} From 92da03e6b2656503864f59984428f4258e3a0400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Wed, 23 Sep 2026 15:27:05 +0200 Subject: [PATCH 42/69] fix: protect settings pages and hide Settings menu for non-admin users --- .../Manager/UnitTestFragmentCheck.cs | 97 +++++++++++++++++++ .../Server/UnitTestHttpServer.cs | 35 +++++++ src/WebExpress.WebCore/HttpServer.cs | 17 ++-- .../FragmentConditionExtentsion.cs | 30 ++++++ .../WebFragment/FragmentManager.cs | 30 ++++++ .../WebFragment/IFragmentManager.cs | 15 +++ .../WebFragment/Model/FragmentItem.cs | 16 +-- .../WebStatusPage/StatusPageManager.cs | 1 + 8 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs new file mode 100644 index 00000000..b5b3f718 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestFragmentCheck.cs @@ -0,0 +1,97 @@ +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebFragment; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebCore.Test.Manager +{ + /// + /// Guards the visibility check every fragment runs before it renders, so a link to a + /// protected page is not offered to identities the server would turn away. + /// + [Collection("NonParallelTests")] + public class UnitTestFragmentCheck + { + /// + /// A fragment without policies stays visible to anonymous requests. + /// + [Fact] + public void FragmentWithoutPolicyIsShownToAnyone() + { + // arrange + using var fixture = new AuthenticationFixture(); + var fragmentContext = new FragmentContext(); + + // act + var visible = fragmentContext.Check(fixture.Request()); + + // validation + Assert.True(visible); + } + + /// + /// A fragment with a policy is shown only to an identity that holds the policy. + /// + /// Whether a real signed login supplies the request identity. + /// Whether the login grants the fragment's policy. + /// Whether the fragment is expected to be visible. + [Theory] + [InlineData(false, false, false)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public void FragmentWithPolicyIsShownOnlyToGrantedIdentity(bool authenticated, bool grantPolicy, bool expected) + { + // arrange + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var fragmentContext = new FragmentContext { Policies = [new TestIdentityPolicyA()] }; + if (authenticated) + { + fixture.Manager.Login(new Identity(Guid.NewGuid(), "test-user", + policyNames: grantPolicy ? [typeof(TestIdentityPolicyA).FullName] : []), request); + } + + // act + var visible = fragmentContext.Check(request); + + // validation + Assert.Equal(expected, visible); + } + + /// + /// A failing condition hides the fragment even from an identity that holds the policy. + /// + [Fact] + public void FailingConditionHidesFragmentDespitePolicy() + { + // arrange + using var fixture = new AuthenticationFixture(); + var request = fixture.Request(); + var fragmentContext = new FragmentContext + { + Conditions = [new ConditionNever()], + Policies = [new TestIdentityPolicyA()] + }; + fixture.Manager.Login(new Identity(Guid.NewGuid(), "test-user", + policyNames: [typeof(TestIdentityPolicyA).FullName]), request); + + // act + var visible = fragmentContext.Check(request); + + // validation + Assert.False(visible); + } + + /// + /// Stands for any condition that is not met, independent of the identity. + /// + private sealed class ConditionNever : WebCondition.ICondition + { + /// + /// Rejects every request. + /// + /// The request. + /// Always false. + public bool Fulfillment(WebMessage.IRequest request) => false; + } + } +} diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index ceeaccd9..2d981f21 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -295,5 +295,40 @@ public async Task ProcessRequestAsync_UnknownRoute_StillIssuesSessionCookie() Assert.Equal(404, response.StatusCode); Assert.Contains($"session={request.Session.Id}", response.Headers.SetCookie.ToString()); } + + /// + /// A protected page is served only to an identity holding its policy. An anonymous + /// request is refused even when no provider of the application can offer a login, + /// rather than the page being served as if it were public. + /// + /// Whether a real signed login supplies the request identity. + /// Whether the login grants the page's policy. + /// The expected response status. + /// A task that completes after the HTTP response has been verified. + [Theory] + [InlineData(false, false, 401)] + [InlineData(true, false, 403)] + [InlineData(true, true, 200)] + public async Task ProcessRequestAsync_ProtectedPage_RequiresPolicy(bool authenticated, bool grantPolicy, int status) + { + // arrange + using var fixture = new AuthenticationFixture(); + var page = (WebPage.PageContext)fixture.Hub.PageManager.GetPages(fixture.Application).Single(); + page.Policies = [new TestIdentityPolicyA()]; + + // act + var (response, _) = await AnswerAsync(fixture.Hub, "GET /server/appa/about HTTP/1.1\nCookie:\n\n", r => + { + if (authenticated) + { + ((WebMessage.RequestBase)r).ApplicationContext = fixture.Application; + fixture.Manager.Login(new WebIdentity.Identity(Guid.NewGuid(), "test-user", + policyNames: grantPolicy ? [typeof(TestIdentityPolicyA).FullName] : []), r); + } + }); + + // validation + Assert.Equal(status, response.StatusCode); + } } } diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index ade78f9e..235c6661 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -1075,6 +1075,17 @@ async Task SendAsync(IHttpContext context, IResponse response) await SendAsync(httpContext, loginResponse); return; } + + // no provider can offer a login, so the page must be refused rather than served as if it were public + var unauthorizedPage = CreateStatusPage + ( + new StatusMessage("Authentication required.").Message, + httpContext.Request, + searchResult + ); + + await SendAsync(httpContext, unauthorizedPage); + return; } else { @@ -1084,12 +1095,6 @@ async Task SendAsync(IHttpContext context, IResponse response) return; } } - - // fallback: no specific denied-response (login prompt / forbidden) could be created - { - var response = HandleClient(httpContext, searchResult); - await SendAsync(httpContext, response); - } } /// diff --git a/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs b/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs index 8cc85c13..2a89e7a1 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentConditionExtentsion.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using WebExpress.WebCore.WebCondition; using WebExpress.WebCore.WebMessage; @@ -27,5 +28,34 @@ public static bool Check(this IEnumerable conditions, IRequest reque return true; } + + /// + /// Decides whether a fragment may appear for the given request. Besides the conditions, the + /// identity must satisfy the fragment's policies, so a link to a protected page is only shown + /// to those the server would serve the page to. + /// + /// The context of the fragment to check. + /// The request whose identity and state are evaluated. + /// True if the conditions are fulfilled and the identity satisfies all policies; otherwise, false. + public static bool Check(this IFragmentContext fragmentContext, IRequest request) + { + if (!(fragmentContext?.Conditions ?? []).Check(request)) + { + return false; + } + + var policies = fragmentContext?.Policies ?? []; + + if (!policies.Any()) + { + return true; + } + + var identityManager = WebEx.ComponentHub?.IdentityManager; + var identity = identityManager?.GetCurrentIdentity(request); + + // without an identity manager no policy can be verified, so the fragment stays hidden + return identityManager is not null && policies.All(x => identityManager.CheckAccess(identity, x)); + } } } diff --git a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs index f284d9f4..56febb4a 100644 --- a/src/WebExpress.WebCore/WebFragment/FragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/FragmentManager.cs @@ -11,6 +11,7 @@ using WebExpress.WebCore.WebHtml; using WebExpress.WebCore.WebIdentity; using WebExpress.WebCore.WebLog; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebPlugin; using WebExpress.WebCore.WebScope; @@ -445,6 +446,35 @@ public IEnumerable GetFragments(IPageContext pag } } + /// + /// Returns the fragments of a page that may appear for the given request. A control that + /// only reads properties of a fragment, instead of rendering it, never reaches the check in + /// the fragment's own render method, so the conditions and policies are evaluated here. + /// + /// The fragment type. + /// The section where the fragment is embedded. + /// The page context. + /// The request whose state and identity decide which fragments are shown. + /// An enumeration of the fragments whose conditions and policies the request fulfills. + public IEnumerable GetFragments(IPageContext pageContext, IRequest request) + where TFragment : IFragmentBase + where TSection : ISection + { + var applicationContext = pageContext?.ApplicationContext; + var scopes = pageContext?.Scopes ?? [typeof(IScope)]; + + var effectiveScopes = (scopes?.Any() == true) ? scopes : [typeof(IScope)]; + + foreach (var item in _dictionary.GetFragmentItems(applicationContext, typeof(TFragment), typeof(TSection), effectiveScopes)) + { + // checked before the instance is created, so a hidden fragment is never constructed + if (item.FragmentContext.Check(request)) + { + yield return item.CreateInstance(pageContext); + } + } + } + /// /// Returns all fragment contexts that belong to a given application. /// diff --git a/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs b/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs index 5dac793a..b4978abb 100644 --- a/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs +++ b/src/WebExpress.WebCore/WebFragment/IFragmentManager.cs @@ -3,6 +3,7 @@ using WebExpress.WebCore.WebApplication; using WebExpress.WebCore.WebComponent; using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebMessage; using WebExpress.WebCore.WebPage; using WebExpress.WebCore.WebScope; using WebExpress.WebCore.WebSection; @@ -103,6 +104,20 @@ IEnumerable GetFragments(IPageContext pageContex where TFragment : IFragmentBase where TSection : ISection; + /// + /// Returns the fragments of a page that may appear for the given request. A control that + /// only reads properties of a fragment, instead of rendering it, never reaches the check in + /// the fragment's own render method, so the conditions and policies are evaluated here. + /// + /// The fragment type. + /// The section where the fragment is embedded. + /// The page context. + /// The request whose state and identity decide which fragments are shown. + /// An enumeration of the fragments whose conditions and policies the request fulfills. + IEnumerable GetFragments(IPageContext pageContext, IRequest request) + where TFragment : IFragmentBase + where TSection : ISection; + /// /// Returns all fragment contexts that belong to a given application. /// diff --git a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs index 82518a77..6868c813 100644 --- a/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs +++ b/src/WebExpress.WebCore/WebFragment/Model/FragmentItem.cs @@ -117,10 +117,10 @@ public IHtmlNode Render(TRenderContext renderContex where TRenderContext : IRenderContext where TVisualTree : IVisualTree { - var instance = CreateInstance(); - - if (CheckConditions(renderContext?.Request)) + if (FragmentContext.Check(renderContext?.Request)) { + var instance = CreateInstance(); + if (!_delegateCache.TryGetValue(FragmentClass, out var del)) { // create and compile the expression @@ -152,16 +152,6 @@ public IHtmlNode Render(TRenderContext renderContex return null; } - /// - /// Checks the component to see if they are displayed or disabled. - /// - /// The request. - /// True if the fragment is active, false otherwise. - public bool CheckConditions(IRequest request) - { - return !FragmentContext.Conditions.Any() || FragmentContext.Conditions.All(x => x.Fulfillment(request)); - } - /// /// Performs application-specific tasks related to sharing, returning, or resetting unmanaged resources. /// diff --git a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs index 299bf0e1..1abd42cf 100644 --- a/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs +++ b/src/WebExpress.WebCore/WebStatusPage/StatusPageManager.cs @@ -354,6 +354,7 @@ public Response CreateStatusResponse(string message, int status, IApplicationCon { 400 => new ResponseBadRequest(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), 401 => new ResponseUnauthorized(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), + 403 => new ResponseForbidden(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), 404 => new ResponseNotFound(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), 500 => new ResponseInternalServerError(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), _ => new ResponseInternalServerError(!string.IsNullOrWhiteSpace(message) ? new StatusMessage(message) : null), From be32ad263dee08175e8981058ba1d279e510ca30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Thu, 24 Sep 2026 16:46:28 +0200 Subject: [PATCH 43/69] feat: general improvements and minor bugs --- .../Server/UnitTestHttpServerAccessDenied.cs | 64 +++++++++ src/WebExpress.WebCore/HttpServer.cs | 136 +++++++++--------- .../WebMessage/ForbiddenException.cs | 36 +++++ 3 files changed, 164 insertions(+), 72 deletions(-) create mode 100644 src/WebExpress.WebCore.Test/Server/UnitTestHttpServerAccessDenied.cs create mode 100644 src/WebExpress.WebCore/WebMessage/ForbiddenException.cs diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerAccessDenied.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerAccessDenied.cs new file mode 100644 index 00000000..51443f1a --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServerAccessDenied.cs @@ -0,0 +1,64 @@ +using WebExpress.WebCore.Test.Data; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSitemap; + +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Tests the answer to a refused request (HttpServer.CreateAccessDeniedResponse), which + /// a policy refusal and a share: in place, never a redirect. + /// + [Collection("NonParallelTests")] + public class UnitTestHttpServerAccessDenied + { + /// + /// A REST endpoint has no page to show: a signed-in caller is answered 403, a caller who + /// is not signed in 401. + /// + [Fact] + public void AnEndpointWithoutAPageIsAnsweredWithTheBareStatus() + { + UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock(); + var identity = new MockIdentity(Guid.NewGuid(), "caller", "caller@example.com", "x"); + + Assert.IsType(HttpServer.CreateAccessDeniedResponse(request, new SearchResult(), identity)); + Assert.IsType(HttpServer.CreateAccessDeniedResponse(request, new SearchResult(), null)); + } + + /// + /// A page is refused where it was asked for: whatever the answer - the forbidden page, + /// the sign-in prompt or the status page standing in for either - it is not a redirect. + /// + [Fact] + public void APageIsRefusedInPlace() + { + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + var request = UnitTestFixture.CreateRequestMock(); + var page = componentHub.PageManager.Pages.First(); + var searchResult = new SearchResult { EndpointContext = page }; + var identity = new MockIdentity(Guid.NewGuid(), "caller", "caller@example.com", "x"); + + foreach (var caller in new[] { identity, null }) + { + var response = HttpServer.CreateAccessDeniedResponse(request, searchResult, caller); + + Assert.NotNull(response); + Assert.IsNotType(response); + Assert.IsNotType(response); + Assert.IsNotType(response); + } + } + + /// + /// The exception names nothing the caller sees; its message is for the log. + /// + [Fact] + public void TheExceptionCarriesItsReasonForTheLog() + { + Assert.False(string.IsNullOrWhiteSpace(new ForbiddenException().Message)); + Assert.Equal("no grant on SD", new ForbiddenException("no grant on SD").Message); + } + } +} diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 235c6661..3a332188 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -516,6 +516,11 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) response = new ResponseMovedTemporarily(ex.Uri); } } + catch (ForbiddenException) + { + // the endpoint refused the caller itself; answered in place like a policy refusal + response = CreateAccessDeniedResponse(request, searchResult, WebEx.ComponentHub?.IdentityManager?.GetCurrentIdentity(request)); + } catch (BadRequestException ex) { var message = $"

Message

{ex.Message}

" + @@ -537,6 +542,11 @@ private IResponse HandleClient(IHttpContext context, SearchResult searchResult) ? new ResponseMovedPermanently(rex.Uri) : new ResponseMovedTemporarily(rex.Uri); } + else if (ex is TargetInvocationException { InnerException: ForbiddenException }) + { + // a page's Process is invoked through reflection, which wraps what it throws + response = CreateAccessDeniedResponse(request, searchResult, WebEx.ComponentHub?.IdentityManager?.GetCurrentIdentity(request)); + } else { HttpServerContext.Log?.Exception(ex); @@ -722,6 +732,58 @@ private static void UpdateStatistics(IResponse response, long duration) } } + /// + /// Builds the answer to a request that is refused access, in place - at the address that + /// was asked for, without a redirect. + /// + /// + /// One answer for both ways a request is refused: the endpoint's own policies, checked + /// before it runs, and a an endpoint throws once it + /// knows more than its policies could say (the resource its route names, say). A + /// signed-in caller who lacks the right sees the forbidden page, a caller who is not + /// signed in is asked to sign in; a REST endpoint, which has no page to show, answers + /// the bare status. + /// + /// The refused request. + /// The resolved endpoint. + /// The caller, or when nobody is signed in. + /// The response to send. + internal static IResponse CreateAccessDeniedResponse(IRequest request, SearchResult searchResult, IIdentity identity) + { + var pageContext = searchResult?.EndpointContext as IPageContext; + var identityManager = WebEx.ComponentHub?.IdentityManager; + + if (identity is not null) + { + if (pageContext is null) + { + return new ResponseForbidden(new StatusMessage("You do not have permission to access this resource.")); + } + + return identityManager?.CreateForbiddenResponse(request, pageContext, identity) + ?? CreateStatusPage + ( + new StatusMessage("You do not have permission to access this resource.").Message, + request, + searchResult + ); + } + + if (pageContext is null) + { + return new ResponseUnauthorized(new StatusMessage("Authentication required. Provide a valid access token.")); + } + + // no provider can offer a login, so the page must be refused rather than served as if it were public + return identityManager?.CreateAuthenticationPrompt(request, pageContext, identity) + ?? CreateStatusPage + ( + new StatusMessage("Authentication required.").Message, + request, + searchResult + ); + } + /// /// Creates a status page. /// @@ -1023,78 +1085,8 @@ async Task SendAsync(IHttpContext context, IResponse response) return; } - // access is denied (the grant case returned above) - determine the appropriate response - { - // if the user is authenticated but lacks the required permissions, show the forbidden page - if (identity is not null && searchResult.EndpointContext is IPageContext) - { - var forbiddenResponse = WebEx.ComponentHub.IdentityManager.CreateForbiddenResponse - ( - httpContext.Request, - searchResult.EndpointContext as IPageContext, - identity - ); - - if (forbiddenResponse is not null) - { - await SendAsync(httpContext, forbiddenResponse); - return; - } - else - { - forbiddenResponse = CreateStatusPage - ( - new StatusMessage("You do not have permission to access this resource.").Message, - httpContext.Request, - searchResult - ); - - await SendAsync(httpContext, forbiddenResponse); - return; - } - } - else if (identity is not null) - { - var forbiddenResponse = new ResponseForbidden(new StatusMessage("You do not have permission to access this resource.")); - - await SendAsync(httpContext, forbiddenResponse); - return; - } - else if (searchResult.EndpointContext is IPageContext pageContext) - { - // if the user is not authenticated, show the login prompt - var loginResponse = WebEx.ComponentHub.IdentityManager.CreateAuthenticationPrompt - ( - httpContext.Request, - searchResult.EndpointContext as IPageContext, - identity - ); - - if (loginResponse is not null) - { - await SendAsync(httpContext, loginResponse); - return; - } - - // no provider can offer a login, so the page must be refused rather than served as if it were public - var unauthorizedPage = CreateStatusPage - ( - new StatusMessage("Authentication required.").Message, - httpContext.Request, - searchResult - ); - - await SendAsync(httpContext, unauthorizedPage); - return; - } - else - { - var unauthorizedResponse = new ResponseUnauthorized(new StatusMessage("Authentication required. Provide a valid access token.")); - - await SendAsync(httpContext, unauthorizedResponse); - return; - } - } + // access is denied (the grant case returned above) + await SendAsync(httpContext, CreateAccessDeniedResponse(httpContext.Request, searchResult, identity)); } /// diff --git a/src/WebExpress.WebCore/WebMessage/ForbiddenException.cs b/src/WebExpress.WebCore/WebMessage/ForbiddenException.cs new file mode 100644 index 00000000..2c6fe593 --- /dev/null +++ b/src/WebExpress.WebCore/WebMessage/ForbiddenException.cs @@ -0,0 +1,36 @@ +using System; + +namespace WebExpress.WebCore.WebMessage +{ + /// + /// Represents an exception that is thrown to refuse the caller access to the requested + /// endpoint, answered in place rather than by a redirect. + /// + /// + /// An endpoint's policies are checked before it runs, and a refusal there is answered at the + /// address that was asked for: the forbidden page for a signed-in caller, the sign-in prompt + /// for one who is not signed in. An endpoint that can only decide once it knows more - the + /// record its route names, the grants on it - throws this from its processing (a page's + /// Process, a fragment while the page renders) and receives the same answer, instead + /// of redirecting to a page of its own and losing the address the caller asked for. + /// + public class ForbiddenException : Exception + { + /// + /// Initializes a new instance of the class. + /// + public ForbiddenException() + : base("Access to the requested resource is refused.") + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Why access is refused; for the log, never shown to the caller. + public ForbiddenException(string message) + : base(message) + { + } + } +} From af6afc800254e8180261481159be924fdcee19a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Fri, 25 Sep 2026 14:50:52 +0200 Subject: [PATCH 44/69] feat: general improvements and minor bugs --- .../Server/UnitTestHttpServer.cs | 35 +++++++++++++++++++ src/WebExpress.WebCore/HttpServer.cs | 27 +++++++++++--- .../Internationalization/de | 1 + .../Internationalization/en | 1 + src/WebExpress.WebCore/WebEx.cs | 17 ++++++--- 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index 2d981f21..2ace217a 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -45,6 +45,41 @@ public void HttpsWithoutCertificateNeverRegistersPlainHttpListener(string scheme Assert.Empty((System.Collections.IEnumerable)listeners); } + /// + /// An endpoint another process already holds - a second instance, say - ends the start + /// with a result instead of an exception, so the host can log it and exit cleanly. + /// + [Fact] + public void Start_EndpointInUse_ReturnsFalseInsteadOfThrowing() + { + // arrange + using var occupant = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + occupant.Start(); + var port = ((System.Net.IPEndPoint)occupant.LocalEndpoint).Port; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock()) + { + Settings = new HttpServerSettings { Endpoints = [new() { Uri = $"http://127.0.0.1:{port}" }] } + }; + + var errors = server.HttpServerContext.Log.ErrorCount; + + try + { + // act + var started = true; + var exception = Record.Exception(() => started = server.Start()); + + // validation + Assert.Null(exception); + Assert.False(started); + Assert.Equal(errors + 1, server.HttpServerContext.Log.ErrorCount); + } + finally + { + server.Stop(); + } + } + /// /// Preserves normal and missing-route responses when startup constructs the server before its component hub. /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 3a332188..50b98120 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Connections; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -11,6 +12,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Net; using System.Net.Quic; @@ -153,20 +155,37 @@ public HttpServer(IHttpServerContext context) } /// - /// Starts the HTTP(S) server. + /// Starts the HTTP(S) server. An endpoint that another process - typically a second + /// instance - already holds is an operating condition rather than a defect, so it is + /// logged and reported through the result instead of escaping as an exception. /// - public void Start() + /// + /// when the server listens on all endpoints; + /// when an endpoint is already in use and the server was not started. + /// + public bool Start() { try { StartCore(); + + return true; } - catch + catch (Exception ex) { _webHost?.Dispose(); _webHost = null; Kestrel = null; HttpServerContext.CertificateManager.Dispose(); + + // kestrel wraps the socket error of an occupied address into an io exception + if (ex is IOException { InnerException: AddressInUseException }) + { + HttpServerContext.Log?.Error(message: I18N.Translate("webexpress.webcore:httpserver.listen.inuse"), args: ex.Message); + + return false; + } + throw; } } diff --git a/src/WebExpress.WebCore/Internationalization/de b/src/WebExpress.WebCore/Internationalization/de index 3e62cbe3..3ab5a403 100644 --- a/src/WebExpress.WebCore/Internationalization/de +++ b/src/WebExpress.WebCore/Internationalization/de @@ -28,6 +28,7 @@ httpserver.connectionlimit=Das Limit für gleichzeitige Anfragen wurde überschr httpserver.endpoint=Endpunkt '{0}' wird registriert. httpserver.listen=Der Webserver lauscht auf den Endpunkt {0}. httpserver.listen.exeption=Fehler beim Einrichten eines Endpunktes für die Uri {0}. +httpserver.listen.inuse=Der Endpunkt ist bereits durch einen anderen Prozess belegt, etwa eine weitere WebExpress-Instanz. Der HttpServer wird nicht gestartet und die Anwendung beendet. ({0}) httpserver.http3=HTTP/3 (QUIC) ist auf dem Endpunkt {0} aktiv; der UDP-Port muss in der Firewall freigegeben sein. httpserver.http3.unsupported=HTTP/3 ist nicht verfügbar, da das Betriebssystem kein QUIC bereitstellt (unter Linux das Paket libmsquic installieren). Der Endpunkt {0} bedient HTTP/1.1 und HTTP/2. httpserver.listener.try=Falls Ihnen Zugriffsrechte fehlen, führen Sie unter Windows folgende Anweisung als Administrator aus: diff --git a/src/WebExpress.WebCore/Internationalization/en b/src/WebExpress.WebCore/Internationalization/en index 97bfea04..ebfe3b9b 100644 --- a/src/WebExpress.WebCore/Internationalization/en +++ b/src/WebExpress.WebCore/Internationalization/en @@ -28,6 +28,7 @@ httpserver.connectionlimit=The limit for concurrent requests has been exceeded. httpserver.endpoint=Endpoint '{0}' is registered. httpserver.listen=The web server listens for the endpoint {0}. httpserver.listen.exeption=Failed to set up an endpoint for the Uri {0}. +httpserver.listen.inuse=The endpoint is already in use by another process, such as another WebExpress instance. The HttpServer is not started and the application exits. ({0}) httpserver.http3=HTTP/3 (QUIC) is active on the endpoint {0}; the UDP port must be open in the firewall. httpserver.http3.unsupported=HTTP/3 is unavailable because the operating system provides no QUIC (on Linux install the libmsquic package). The endpoint {0} serves HTTP/1.1 and HTTP/2. httpserver.listener.try=If you do not have access rights follow the instruction as an administrator in the console: diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index d0c11564..ec721bc6 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -224,12 +224,12 @@ public int Execution(string[] args) (_componentHub as ComponentHub).Execute(); // starting the web server - OnStart(); + var started = OnStart(); // finish OnExit(); - return 0; + return started ? 0 : 1; } /// @@ -367,13 +367,22 @@ private static string ResolveDirectory(string directory) /// /// Initiates the HTTP server and raises the start event. /// - private void OnStart() + /// + /// when the server could not listen because an endpoint is + /// already in use, so the application ends instead of waiting on a server that never ran. + /// + private bool OnStart() { - _httpServer.Start(); + if (!_httpServer.Start()) + { + return false; + } Start?.Invoke(this, EventArgs.Empty); Thread.CurrentThread.Join(); + + return true; } /// From 5015d2b368b0a5af3a81ac2a79145d6575663342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Sat, 26 Sep 2026 11:13:38 +0200 Subject: [PATCH 45/69] feat: add ExternalUri for public URL configuration --- docs/user-guide.md | 17 +++++++++++++++++ .../Fixture/UnitTestFixture.cs | 10 ++++++---- .../Manager/UnitTestSitemapManager.cs | 19 +++++++++++++++++++ .../Server/UnitTestHttpServer.cs | 14 ++++++++++++++ .../WebSetting/UnitTestKestrelSettings.cs | 17 +++++++++++++++++ src/WebExpress.WebCore/HttpServer.cs | 3 ++- src/WebExpress.WebCore/HttpServerContext.cs | 10 +++++++++- src/WebExpress.WebCore/IHttpServerContext.cs | 6 ++++++ src/WebExpress.WebCore/WebEx.cs | 3 ++- .../WebSetting/HttpServerSettings.cs | 7 +++++++ .../WebSitemap/SitemapManager.cs | 6 ++++-- 11 files changed, 103 insertions(+), 9 deletions(-) diff --git a/docs/user-guide.md b/docs/user-guide.md index fc406caa..985fe9ff 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -15,3 +15,20 @@ To get started with `WebExpress.WebCore`, use the following guides: - [WebExpress.WebIndex API Documentation](https://webexpress-framework.github.io/WebExpress.WebIndex/) We hope you enjoy using `WebExpress.WebCore` and find it valuable for your projects. Happy coding! + +## Public server URI + +Configure `WebExpress:ExternalUri` when the listener binding is not the URL used by clients, for example when WebExpress runs behind a reverse proxy. The server continues to bind to the addresses in `Endpoints`, while applications and components can obtain the public URL through `IHttpServerContext.ExternalUri`. + +```json +{ + "WebExpress": { + "Endpoints": [ + { "Uri": "http://0.0.0.0:8080/" } + ], + "ExternalUri": "https://www.example.com/" + } +} +``` + +`ExternalUri` is optional. Leave it unset when the listener address is also the public URL. diff --git a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs index 4682bf47..835ac107 100644 --- a/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs +++ b/src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs @@ -38,8 +38,9 @@ public UnitTestFixture() /// settings file never sees one left behind by another. /// /// The configuration. Defaults to an empty one. + /// The optional public base URI. /// The server context. - public static IHttpServerContext CreateHttpServerContextMock(string settingsPath = null, IConfigurationRoot configuration = null) + public static IHttpServerContext CreateHttpServerContextMock(string settingsPath = null, IConfigurationRoot configuration = null, string externalUri = null) { return new HttpServerContext ( @@ -52,7 +53,8 @@ public static IHttpServerContext CreateHttpServerContextMock(string settingsPath configuration ?? new ConfigurationBuilder().Build(), CultureInfo.GetCultureInfo("en"), new Log() { LogMode = LogMode.Off }, - null + null, + externalUri: externalUri ); } @@ -89,9 +91,9 @@ public static ComponentHub CreateComponentHubMock(IHttpServerContext httpServerC /// Create a component hub and register the plugins. /// /// The component hub. - public static ComponentHub CreateAndRegisterComponentHubMock() + public static ComponentHub CreateAndRegisterComponentHubMock(IHttpServerContext httpServerContext = null) { - var componentHub = CreateComponentHubMock(); + var componentHub = CreateComponentHubMock(httpServerContext); var pluginManager = componentHub.PluginManager as PluginManager; pluginManager.Register(); diff --git a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs index 76c0eb55..a4db694e 100644 --- a/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs +++ b/src/WebExpress.WebCore.Test/Manager/UnitTestSitemapManager.cs @@ -135,6 +135,25 @@ public void GetUri(Type applicationType, Type resourceType, int? param, string e Assert.Equal(expected, uri?.ToString()); } + /// + /// Uses the configured public URI instead of the listener binding for absolute sitemap links. + /// + [Fact] + public void GetUriUsesExternalUri() + { + // arrange + var httpServerContext = UnitTestFixture.CreateHttpServerContextMock(externalUri: "https://www.example.com/"); + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(httpServerContext); + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).FirstOrDefault(); + componentHub.SitemapManager.Refresh(); + + // act + var uri = componentHub.SitemapManager.GetUri(typeof(TestResourceA), application); + + // validation + Assert.Equal("https://www.example.com/server/appa/resources/testresourcea", uri?.ToString()); + } + /// /// Test the get endpoint function of the sitemap. /// diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs index 2ace217a..65715e36 100644 --- a/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs +++ b/src/WebExpress.WebCore.Test/Server/UnitTestHttpServer.cs @@ -17,6 +17,20 @@ public class UnitTestHttpServer // in between - the access check, the login prompt - decides the outcome private const string Endpoint = "/server/appa/api/2/testrestapib"; + /// + /// Preserves the configured public URI when the server creates its shared context. + /// + [Fact] + public void ExternalUriIsAvailableFromServerContext() + { + // arrange + const string externalUri = "https://www.example.com/"; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock(externalUri: externalUri)); + + // validation + Assert.Equal(externalUri, server.HttpServerContext.ExternalUri); + } + /// /// Prevents a missing HTTPS certificate from silently registering an unencrypted listener. /// diff --git a/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs b/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs index 8ba5a6a3..57ba981d 100644 --- a/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs +++ b/src/WebExpress.WebCore.Test/WebSetting/UnitTestKestrelSettings.cs @@ -44,6 +44,23 @@ public void MissingBlockIsNull() Assert.Equal("http://localhost/", settings.Endpoints[0].Uri); } + /// + /// Tests that the public URI is bound separately from an internal listener binding. + /// + [Fact] + public void ExternalUriIsBoundIndependentlyOfEndpoint() + { + // arrange + var json = """{ "WebExpress": { "Endpoints": [ { "Uri": "http://0.0.0.0:8080/" } ], "ExternalUri": "https://www.example.com/" } }"""; + + // act + var settings = Bind(json); + + // validation + Assert.Equal("http://0.0.0.0:8080/", settings.Endpoints[0].Uri); + Assert.Equal("https://www.example.com/", settings.ExternalUri); + } + /// /// Tests that all kestrel settings are read when present. /// diff --git a/src/WebExpress.WebCore/HttpServer.cs b/src/WebExpress.WebCore/HttpServer.cs index 50b98120..6a54d5cb 100644 --- a/src/WebExpress.WebCore/HttpServer.cs +++ b/src/WebExpress.WebCore/HttpServer.cs @@ -145,7 +145,8 @@ public HttpServer(IHttpServerContext context) context.Culture, context.Log, this, - context.CertificateManager + context.CertificateManager, + context.ExternalUri ); Culture = HttpServerContext.Culture; diff --git a/src/WebExpress.WebCore/HttpServerContext.cs b/src/WebExpress.WebCore/HttpServerContext.cs index 6d3ce77d..68c56bcc 100644 --- a/src/WebExpress.WebCore/HttpServerContext.cs +++ b/src/WebExpress.WebCore/HttpServerContext.cs @@ -32,6 +32,11 @@ public class HttpServerContext : IHttpServerContext /// public ICollection Endpoints { get; protected set; } + /// + /// Gets the optional public base URI of the server, independent of its listener bindings. + /// + public string ExternalUri { get; protected set; } + /// /// Gets the version of the http(s) server. /// @@ -91,6 +96,7 @@ public class HttpServerContext : IHttpServerContext /// The log. /// The host. /// The optional shared certificate service owned by the host. + /// The optional public base URI, independent of the listener bindings. public HttpServerContext ( IRoute route, @@ -103,7 +109,8 @@ public HttpServerContext CultureInfo culture, ILog log, IHost host, - ICertificateManager certificateManager = null + ICertificateManager certificateManager = null, + string externalUri = null ) { var assembly = typeof(HttpServer).Assembly; @@ -111,6 +118,7 @@ public HttpServerContext Route = route; Endpoints = endpoints; + ExternalUri = externalUri; PackagePath = packageBaseFolder; AssetPath = assetBaseFolder; DataPath = dataBaseFolder; diff --git a/src/WebExpress.WebCore/IHttpServerContext.cs b/src/WebExpress.WebCore/IHttpServerContext.cs index ccd87d97..4e767358 100644 --- a/src/WebExpress.WebCore/IHttpServerContext.cs +++ b/src/WebExpress.WebCore/IHttpServerContext.cs @@ -31,6 +31,12 @@ public interface IHttpServerContext /// ICollection Endpoints { get; } + /// + /// Gets the optional public base URI of the server. It can differ from the listener + /// bindings when a reverse proxy exposes the server. + /// + string ExternalUri { get; } + /// /// Gets the version of the http(s) server. /// diff --git a/src/WebExpress.WebCore/WebEx.cs b/src/WebExpress.WebCore/WebEx.cs index ec721bc6..50c87d55 100644 --- a/src/WebExpress.WebCore/WebEx.cs +++ b/src/WebExpress.WebCore/WebEx.cs @@ -296,7 +296,8 @@ private bool OnInitialization(string args, string settingsFile) configuration, culture, log, - null + null, + externalUri: settings.ExternalUri ); _httpServer = new HttpServer(context) diff --git a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs index 0bbbf5ec..d00ef398 100644 --- a/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs +++ b/src/WebExpress.WebCore/WebSetting/HttpServerSettings.cs @@ -33,6 +33,13 @@ public sealed class HttpServerSettings ///
public List Endpoints { get; set; } = []; + /// + /// The public base URI of the server, e.g. https://www.example.com/. This can + /// differ from a listener binding such as http://0.0.0.0:8080/ when the server is + /// deployed behind a reverse proxy. + /// + public string ExternalUri { get; set; } + /// /// Gets or sets the shared certificate inventory and expiry warning policy used for production HTTPS. /// diff --git a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs index 4b9d6147..3c81085f 100644 --- a/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs +++ b/src/WebExpress.WebCore/WebSitemap/SitemapManager.cs @@ -45,8 +45,10 @@ private SitemapManager(IComponentHub componentHub, IHttpServerContext httpServer { _componentHub = componentHub; _httpServerContext = httpServerContext; - _serverUri = new UriEndpoint(_httpServerContext?.Endpoints.FirstOrDefault(e => e.Uri.StartsWith("https"))?.ToString() - ?? _httpServerContext?.Endpoints.FirstOrDefault()?.ToString() ?? ""); + _serverUri = new UriEndpoint(!string.IsNullOrWhiteSpace(_httpServerContext?.ExternalUri) + ? _httpServerContext.ExternalUri + : _httpServerContext?.Endpoints.FirstOrDefault(e => e.Uri.StartsWith("https"))?.ToString() + ?? _httpServerContext?.Endpoints.FirstOrDefault()?.ToString() ?? ""); _httpServerContext?.Log?.Debug ( From bd791c51b7363f2d45922d2fef328db586c0b701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 28 Sep 2026 16:32:15 +0200 Subject: [PATCH 46/69] fix: resolve WebSocket handshake failure by using ExternalUri --- .../Server/UnitTestWebSocketHandshake.cs | 131 ++++++++++++++++++ .../WebMessage/HttpWebSocketContext.cs | 2 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/WebExpress.WebCore.Test/Server/UnitTestWebSocketHandshake.cs diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestWebSocketHandshake.cs b/src/WebExpress.WebCore.Test/Server/UnitTestWebSocketHandshake.cs new file mode 100644 index 00000000..b19e27b3 --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestWebSocketHandshake.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using System.Net; +using System.Net.WebSockets; +using System.Reflection; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebSetting; +using WebExpress.WebCore.WebSocket; + +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Protects WebSocket upgrades when public authorities differ from the listening endpoint. + /// + [Collection("NonParallelTests")] + public class UnitTestWebSocketHandshake + { + /// + /// Keeps a port in the Host header from failing context creation before the handshake. + /// + /// The authority sent by the client. + /// The scheme of the incoming HTTP upgrade request. + /// The listening port, which may differ from the public port. + /// The public base URI configured for generated links. + /// The host name expected after separating the public port. + [Theory] + [InlineData("localhost:8080", "http", 8080, null, "localhost")] + [InlineData("localhost:8080", "http", 8080, "http://localhost:8080/", "localhost")] + [InlineData("localhost:8080", "http", 5000, "http://localhost:8080/", "localhost")] + [InlineData("192.168.0.5:8080", "http", 5000, "http://192.168.0.5:8080/", "192.168.0.5")] + [InlineData("[::1]:8080", "http", 5000, "http://[::1]:8080/", "[::1]")] + [InlineData("[::1]", "http", 80, null, "[::1]")] + [InlineData("example.org:8443", "https", 5001, "https://example.org:8443/", "example.org")] + [InlineData("example.org", "http", 5000, "https://example.org/", "example.org")] + public void CreateContext_WithPublicHost_PreservesWebSocketRequest( + string host, string scheme, int localPort, string externalUri, string expectedHost) + { + // arrange + const string target = "/kleenestar/webexpress.webapp/ws/messagequeue?domains=updates"; + const string key = "dGhlIHNhbXBsZSBub25jZQ=="; + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock(externalUri: externalUri)); + var context = new DefaultHttpContext(); + context.TraceIdentifier = "websocket-handshake-test"; + context.Connection.LocalIpAddress = IPAddress.Loopback; + context.Connection.LocalPort = localPort; + context.Connection.RemoteIpAddress = IPAddress.Loopback; + context.Connection.RemotePort = 54321; + context.Request.Method = "GET"; + context.Request.Scheme = scheme; + context.Request.Protocol = "HTTP/1.1"; + context.Request.Host = new HostString(host); + context.Request.QueryString = new QueryString("?domains=updates"); + context.Request.Headers.Upgrade = "websocket"; + context.Request.Headers.Connection = "Upgrade"; + context.Request.Headers.SecWebSocketKey = key; + context.Request.Headers.SecWebSocketVersion = "13"; + context.Features.Get().RawTarget = target; + + // act + var result = server.CreateContext(context.Features); + + // validation + var socketContext = Assert.IsType(result); + Assert.IsType(socketContext.Request); + Assert.Equal(expectedHost, socketContext.Uri.Host); + Assert.Equal(localPort, socketContext.Uri.Port); + Assert.Equal(target, socketContext.Uri.PathAndQuery); + Assert.Equal("updates", socketContext.Request.GetParameter("domains")?.Value); + Assert.Equal(key, socketContext.WebSocketKey); + Assert.Equal(scheme == "https", socketContext.IsSecureWebSocket); + Assert.Same(server.HttpServerContext, socketContext.HttpServerContext); + Assert.Equal(externalUri, socketContext.HttpServerContext.ExternalUri); + } + + /// + /// Verifies a real upgrade with a public Host and Origin while Kestrel listens on another port. + /// + /// A task that completes after the public URI and WebSocket handshake have been verified. + [Fact] + public async Task ConnectAsync_WithExternalUriAndMappedPort_CompletesHandshake() + { + // arrange + const string externalUri = "http://localhost:8080/"; + var previousHub = WebEx.ComponentHub; + var hubField = typeof(WebEx).GetField("_componentHub", BindingFlags.Static | BindingFlags.NonPublic); + var server = new HttpServer(UnitTestFixture.CreateHttpServerContextMock(externalUri: externalUri)) + { + Settings = new HttpServerSettings { Endpoints = [new() { Uri = "http://127.0.0.1:0/" }] } + }; + var componentHub = UnitTestFixture.CreateAndRegisterComponentHubMock(server.HttpServerContext); + using var client = new ClientWebSocket(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + try + { + var application = componentHub.ApplicationManager.GetApplications(typeof(TestApplicationA)).First(); + var socket = (SocketContext)componentHub.SocketManager.GetSockets(application).First(); + socket.SupportedSubProtocol = "wxmsg"; + componentHub.SitemapManager.Refresh(); + var publicUri = componentHub.SitemapManager.GetUri(application); + Assert.True(server.Start()); + var kestrel = (IServer)typeof(HttpServer).GetProperty("Kestrel", BindingFlags.Instance | BindingFlags.NonPublic) + .GetValue(server); + var address = kestrel.Features.Get().Addresses.Single(); + var target = new UriBuilder(address) { Scheme = "ws", Path = socket.Route.ToString() }.Uri; + client.Options.SetRequestHeader("Host", "localhost:8080"); + client.Options.SetRequestHeader("Origin", "http://localhost:8080"); + client.Options.AddSubProtocol("wxmsg"); + + // act + await client.ConnectAsync(target, timeout.Token); + + // validation + Assert.Equal("http://localhost:8080/server/appa/testsocketa", publicUri.ToString()); + Assert.Equal(WebSocketState.Open, client.State); + Assert.Equal("wxmsg", client.SubProtocol); + await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "test complete", timeout.Token); + } + finally + { + client.Abort(); + server.Stop(); + componentHub.IdentityProviderManager.Dispose(); + hubField.SetValue(null, previousHub); + } + } + } +} diff --git a/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs index 95cee9a6..d7e6d9d3 100644 --- a/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs +++ b/src/WebExpress.WebCore/WebMessage/HttpWebSocketContext.cs @@ -70,7 +70,7 @@ public HttpWebSocketContext(IFeatureCollection contextFeatures, IHttpServerConte var connectionFeature = contextFeatures.Get(); var requestFeature = contextFeatures.Get(); var header = new RequestHeaderFields(contextFeatures); - var baseUri = new UriBuilder(requestFeature.Scheme, header.Host, connectionFeature.LocalPort).Uri; + var baseUri = new UriBuilder(requestFeature.Scheme, HttpContext.HostNameOf(header.Host), connectionFeature.LocalPort).Uri; Features = contextFeatures; HttpServerContext = httpServerContext; From b7be1313fcdaeebdad19c391c77fb63da957db24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schwarzer?= Date: Mon, 28 Sep 2026 22:11:28 +0200 Subject: [PATCH 47/69] feat: handle root URL based on configured applications --- docs/user-guide.md | 32 +++ .../Server/UnitTestRootEndpoint.cs | 224 ++++++++++++++++++ src/WebExpress.WebCore/HttpServer.cs | 9 + .../Internationalization/de | 4 + .../Internationalization/en | 4 + .../WebApplication/RootEndpoint.cs | 138 +++++++++++ .../WebSetting/HttpServerSettings.cs | 6 + .../WebSetting/RootSettings.cs | 20 ++ 8 files changed, 437 insertions(+) create mode 100644 src/WebExpress.WebCore.Test/Server/UnitTestRootEndpoint.cs create mode 100644 src/WebExpress.WebCore/WebApplication/RootEndpoint.cs create mode 100644 src/WebExpress.WebCore/WebSetting/RootSettings.cs diff --git a/docs/user-guide.md b/docs/user-guide.md index 985fe9ff..1f9811ed 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -32,3 +32,35 @@ Configure `WebExpress:ExternalUri` when the listener binding is not the URL used ``` `ExternalUri` is optional. Leave it unset when the listener address is also the public URL. + +## Server entry point + +By default, a `GET` or `HEAD` request to `/` redirects to the application path when exactly one application is registered. With several applications, the server displays an overview containing their current names and links. With no applications, the overview displays an empty state. The overview supports English and German according to the request culture and requires no UI plugin. + +For deployments with a `ContextPath`, the same behavior is available at that prefix with or without a trailing slash. Generated links and redirect targets include the prefix. Application URLs and their authentication checks remain unchanged. An application already mounted directly at an entry point retains that route, including when redirects are disabled. + +To select one application even when several are installed, configure `WebExpress:Root:ApplicationId` in the active host's `settings/webexpress.settings.json`. The value is the registered application identifier exposed by `IApplicationContext.ApplicationId`, which is the application class's fully qualified name in lowercase. It is not the display name or URL. An unknown identifier displays the overview instead of redirecting to an arbitrary target. + +```json +{ + "WebExpress": { + "Root": { + "ApplicationId": "example.plugin.application" + } + } +} +``` + +To always display the overview, set `WebExpress:Root:RedirectEnabled` to `false`. This setting takes precedence over `ApplicationId`. Omitting the `Root` block restores automatic selection. Apply configuration changes by restarting the host. + +```json +{ + "WebExpress": { + "Root": { + "RedirectEnabled": false + } + } +} +``` + +For container configuration, the equivalent environment variables are `WEBEXPRESS_WebExpress__Root__ApplicationId` and `WEBEXPRESS_WebExpress__Root__RedirectEnabled`. Redirects use HTTP 302 with `Cache-Control: no-store` so application changes are reflected on the next visit. Root query parameters do not override the configured target or propagate to application links. diff --git a/src/WebExpress.WebCore.Test/Server/UnitTestRootEndpoint.cs b/src/WebExpress.WebCore.Test/Server/UnitTestRootEndpoint.cs new file mode 100644 index 00000000..04f8c91d --- /dev/null +++ b/src/WebExpress.WebCore.Test/Server/UnitTestRootEndpoint.cs @@ -0,0 +1,224 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Configuration; +using System.Reflection; +using System.Text; +using WebExpress.WebCore.Test.Fixture; +using WebExpress.WebCore.WebApplication; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebSetting; +using ApplicationContext = WebExpress.WebCore.WebApplication.ApplicationContext; + +namespace WebExpress.WebCore.Test.Server +{ + /// + /// Verifies the shared entry point without changing application routing or authentication. + /// + [Collection("NonParallelTests")] + public class UnitTestRootEndpoint : IDisposable + { + private readonly IComponentHub _previousHub = WebEx.ComponentHub; + private readonly ComponentHub _hub = UnitTestFixture.CreateAndRegisterComponentHubMock(); + + /// + /// Restores the process-wide hub so entry point scenarios cannot affect unrelated tests. + /// + public void Dispose() + { + _hub.IdentityProviderManager.Dispose(); + typeof(WebEx).GetField("_componentHub", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, _previousHub); + GC.SuppressFinalize(this); + } + + /// + /// Covers automatic selection, explicit selection, disabled redirects, and missing targets. + /// + /// The number of configured applications. + /// The optional administrator preference. + /// The optional application identifier to select. + /// The expected redirect path, or null for the overview. + [Theory] + [InlineData(0, null, null, null)] + [InlineData(1, null, null, "/server/app0")] + [InlineData(2, null, null, null)] + [InlineData(1, false, null, null)] + [InlineData(2, false, "app1", null)] + [InlineData(2, true, "app1", "/server/app1")] + [InlineData(2, true, " APP1 ", "/server/app1")] + [InlineData(1, true, "unknown", null)] + [InlineData(2, true, "https://example.com", null)] + [InlineData(1, true, " ", "/server/app0")] + public void EntryPointSelectsConfiguredBehavior(int count, bool? redirectEnabled, string applicationId, string target) + { + // arrange + var applications = Enumerable.Range(0, count).Select(i => new ApplicationContext + { + ApplicationId = $"app{i}", + ApplicationName = $"Application {i}", + Route = new RouteEndpoint($"server/app{i}") + }).ToArray(); + var settings = redirectEnabled.HasValue + ? new RootSettings { RedirectEnabled = redirectEnabled.Value, ApplicationId = applicationId } + : null; + var request = UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\r\nAccept-Language: en\r\n\r\n"); + + // act + var response = RootEndpoint.Handle(request, new RouteEndpoint("server"), applications, settings); + + // validation + Assert.Equal(target is null ? 200 : 302, response.Status); + Assert.Equal(target, response.Header.Location); + Assert.Equal("no-store", response.Header.CacheControl); + if (target is null) + { + var html = response.Content.ToString(); + Assert.Contains(count == 0 ? "No applications are available." : "Select an application", html); + foreach (var application in applications) + { + Assert.Contains(application.ApplicationName, html); + Assert.Contains($"href=\"{application.Route}\"", html); + } + } + } + + /// + /// Accepts both entry points and leaves application URLs, unknown routes, and unsafe methods alone. + /// + /// The method supplied by the client. + /// The requested path, including any query string. + /// Whether the shared entry point owns this request. + [Theory] + [InlineData("GET", "/", true)] + [InlineData("GET", "/?next=https://example.com", true)] + [InlineData("GET", "/server", true)] + [InlineData("GET", "/server/", true)] + [InlineData("HEAD", "/", true)] + [InlineData("GET", "/server/app", false)] + [InlineData("GET", "/server/missing", false)] + [InlineData("GET", "/server-other", false)] + [InlineData("POST", "/", false)] + public void OnlyEntryPointNavigationIsHandled(string method, string path, bool handled) + { + // arrange + var request = UnitTestFixture.CreateRequestMock($"{method} {path} HTTP/1.1\r\n\r\n"); + var application = new ApplicationContext { Route = new RouteEndpoint("server/app") }; + + // act + var response = RootEndpoint.Handle(request, new RouteEndpoint("server"), [application], null); + + // validation + Assert.Equal(handled, response is not null); + if (handled) + { + Assert.Equal("/server/app", response.Header.Location); + } + } + + /// + /// Keeps an application mounted at an entry point reachable through its normal endpoint pipeline. + /// + /// The entry point already owned by an application. + [Theory] + [InlineData("/")] + [InlineData("/server")] + public void ApplicationAtEntryPointKeepsItsRoute(string path) + { + // arrange + var request = UnitTestFixture.CreateRequestMock($"GET {path} HTTP/1.1\r\n\r\n"); + var application = new ApplicationContext { Route = new RouteEndpoint(path) }; + + // act + var response = RootEndpoint.Handle(request, new RouteEndpoint("server"), [application], null); + + // validation + Assert.Null(response); + } + + /// + /// Renders application branding as text and uses the requested language for the selector. + /// + [Fact] + public void OverviewEncodesNamesAndUsesRequestCulture() + { + // arrange + var request = UnitTestFixture.CreateRequestMock("GET / HTTP/1.1\r\nAccept-Language: de\r\n\r\n"); + var application = new ApplicationContext + { + ApplicationName = " & Anwendung", + Route = new RouteEndpoint("server/app") + }; + + // act + var response = RootEndpoint.Handle(request, new RouteEndpoint("server"), [application], new RootSettings { RedirectEnabled = false }); + + // validation + var html = response.Content.ToString(); + Assert.Contains("lang=\"de\"", html); + Assert.Contains("Anwendungen", html); + Assert.Contains("<script>", html); + Assert.Contains("& Anwendung", html); + Assert.DoesNotContain(""); + + try + { + // without a hub the built-in page renders the message as given, so nothing else encodes it + hubField.SetValue(null, null); + + var response = (WebMessage.IResponse)create.Invoke(server, [rejection, null, null]); + var content = response.Content as string; + + Assert.Equal(400, response.Status); + Assert.Contains("unknown value <script>", content); + Assert.DoesNotContain("