diff --git a/README.md b/README.md index ece70ad..f986971 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ WART is a C# .NET library that enables you to extend any Web API controller and ## Features - Converts REST API calls into SignalR events, enabling real-time communication. -- Provides controllers (`WartController`, `WartControllerJwt`) for automatic SignalR event broadcasting. +- Provides controllers (`WartController`, `WartControllerJwt`, `WartControllerCookie`) for automatic SignalR event broadcasting. - Supports JWT authentication for SignalR hub connections. - Allows API exclusion from event broadcasting with `[ExcludeWart]` attribute. - Enables group-specific event dispatching with `[GroupWart("group_name")]`. @@ -134,6 +134,18 @@ hubConnection.On("Send", (data) => In the source code you can find a simple test client and WebApi project. +## Supported Authentication Modes + +The project supports three authentication modes for accessing the SignalR Hub: + +| Mode | Description | Hub Class | Required Middleware | +|--------------------------|---------------------------------------------------------------------------|----------------------|---------------------------| +| **No Authentication** | Open access without identity verification | `WartHub` | None | +| **JWT (Bearer Token)** | Authentication via JWT token in the `Authorization: Bearer ` header | `WartHubJwt` | `UseJwtMiddleware()` | +| **Cookie Authentication**| Authentication via HTTP cookies issued after login | `WartHubCookie` | `UseCookieMiddleware()` | + +> ⚙️ Authentication mode is selected through the `HubType` configuration in the application startup. + ### Excluding APIs from Event Propagation There might be scenarios where you want to exclude specific APIs from propagating events to connected clients. This can be particularly useful when certain endpoints should not trigger updates, notifications, or other real-time messages through SignalR. To achieve this, you can use a custom filter called `ExcludeWartAttribute`. By decorating the desired API endpoints with this attribute, you can prevent them from being included in the SignalR event propagation logic, for example: diff --git a/src/WART-Client/Program.cs b/src/WART-Client/Program.cs index bc83f57..aa1a7dd 100755 --- a/src/WART-Client/Program.cs +++ b/src/WART-Client/Program.cs @@ -22,16 +22,27 @@ private static async Task Main() Console.WriteLine($"Connecting to {wartHubUrl}"); - var auth = configuration["AuthenticationJwt"]; + var auth = configuration["AuthenticationType"] ?? "NoAuth"; - if (bool.Parse(auth)) + switch (auth.ToLowerInvariant()) { - var key = configuration["Key"]; - await WartTestClientJwt.ConnectAsync(wartHubUrl, key); - } - else - { - await WartTestClient.ConnectAsync(wartHubUrl); + default: + case "noauth": + { + await WartTestClient.ConnectAsync(wartHubUrl); + break; + } + case "jwt": + { + var key = configuration["Key"]; + await WartTestClientJwt.ConnectAsync(wartHubUrl, key); + break; + } + case "cookie": + { + await WartTestClientCookie.ConnectAsync(wartHubUrl); + break; + } } Console.WriteLine($"Connected to {wartHubUrl}"); diff --git a/src/WART-Client/WART-Client.csproj b/src/WART-Client/WART-Client.csproj index bef0a81..b211044 100755 --- a/src/WART-Client/WART-Client.csproj +++ b/src/WART-Client/WART-Client.csproj @@ -2,7 +2,7 @@ Exe - net8.0 + net9.0 WART_Client WART_Client.Program false @@ -21,10 +21,10 @@ - - - - + + + + diff --git a/src/WART-Client/WartTestClientCookie.cs b/src/WART-Client/WartTestClientCookie.cs new file mode 100644 index 0000000..9e692c6 --- /dev/null +++ b/src/WART-Client/WartTestClientCookie.cs @@ -0,0 +1,91 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Http.Connections; +using Microsoft.AspNetCore.SignalR.Client; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace WART_Client +{ + /// + /// A simple SignalR WART test client with Cookie authentication. + /// + public static class WartTestClientCookie + { + public static async Task ConnectAsync(string hubUrl) + { + try + { + var cookieContainer = new CookieContainer(); + var handler = new HttpClientHandler + { + CookieContainer = cookieContainer, + UseCookies = true, + AllowAutoRedirect = true + }; + + using var httpClient = new HttpClient(handler); + + var loginContent = new FormUrlEncodedContent(new[] + { + new KeyValuePair("username", "test_username"), + new KeyValuePair("password", "test_password") + }); + + var loginUri = new Uri(new Uri(hubUrl), "/api/TestCookie/login"); + var loginResponse = await httpClient.PostAsync(loginUri, loginContent); + loginResponse.EnsureSuccessStatusCode(); + + Console.WriteLine("Login successful. Connecting to SignalR..."); + + //var uri = new Uri(hubUrl); + //cookieContainer.Add(uri, new Cookie("WART.AuthCookie", "sample_value")); + + var hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + options.HttpMessageHandlerFactory = _ => handler; + options.Transports = HttpTransportType.WebSockets | + HttpTransportType.ServerSentEvents | + HttpTransportType.LongPolling; + }) + .WithAutomaticReconnect() + .Build(); + + hubConnection.On("Send", (data) => + { + Console.WriteLine(data); + Console.WriteLine($"Message size: {Encoding.UTF8.GetBytes(data).Length} bytes"); + Console.WriteLine(); + }); + + hubConnection.Closed += async (ex) => + { + Console.WriteLine($"Connection closed: {ex?.Message}"); + await Task.Delay(new Random().Next(0, 5) * 1000); + if (hubConnection != null) + await hubConnection.StartAsync(); + }; + + hubConnection.On("ConnectionFailed", (ex) => + { + Console.WriteLine($"Connection failed: {ex.Message}"); + return Task.CompletedTask; + }); + + await hubConnection.StartAsync(); + Console.WriteLine("SignalR connection started."); + } + catch (Exception e) + { + Console.WriteLine(e.Message); + } + + await Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/WART-Client/appsettings.json b/src/WART-Client/appsettings.json index 544e8d3..f990e0e 100644 --- a/src/WART-Client/appsettings.json +++ b/src/WART-Client/appsettings.json @@ -3,7 +3,7 @@ "Host": "localhost", "Port": "54644", "Hubname": "warthub", - "AuthenticationJwt": "true", + "AuthenticationType": "JWT", "Key": "dn3341fmcscscwe28419brhwbwgbss4t", "WartGroup": "SampleGroupName" } \ No newline at end of file diff --git a/src/WART-Core/Authentication/Cookie/CookieApplicationBuilderExtension.cs b/src/WART-Core/Authentication/Cookie/CookieApplicationBuilderExtension.cs new file mode 100644 index 0000000..667f578 --- /dev/null +++ b/src/WART-Core/Authentication/Cookie/CookieApplicationBuilderExtension.cs @@ -0,0 +1,22 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Builder; + +namespace WART_Core.Authentication.Cookie +{ + public static class CookieApplicationBuilderExtension + { + /// + /// Use Cookie authentication dependency to IApplicationBuilder. + /// + /// The IApplicationBuilder to configure the middleware pipeline. + /// + public static IApplicationBuilder UseCookieMiddleware(this IApplicationBuilder app) + { + app.UseAuthentication(); + app.UseAuthorization(); + + return app; + } + } +} \ No newline at end of file diff --git a/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs new file mode 100644 index 0000000..df778ca --- /dev/null +++ b/src/WART-Core/Authentication/Cookie/CookieServiceCollectionExtension.cs @@ -0,0 +1,85 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System; +using System.IO; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using WART_Core.Hubs; +using WART_Core.Services; + +namespace WART_Core.Authentication.Cookie +{ + public static class CookieServiceCollectionExtension + { + /// + /// Adds Cookie authentication middleware to the service collection. + /// Configures the authentication parameters, SignalR settings, and response compression. + /// + /// The service collection to add the middleware to. + /// Optional path for the login redirect (default: /Account/Login). + /// Optional path for access denied redirect (default: /Account/Denied). + /// The updated service collection. + public static IServiceCollection AddCookieMiddleware( + this IServiceCollection services, + string loginPath = "/Account/Login", + string accessDeniedPath = "/Account/AccessDenied") + { + // Configure forwarded headers (support for reverse proxy) + services.Configure(options => + { + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + }); + + // Add logging support + services.AddLogging(configure => configure.AddConsole()); + + // Add Data Protection with key persistence + var keysPath = Path.Combine(AppContext.BaseDirectory, "keys"); + Directory.CreateDirectory(keysPath); + services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo(keysPath)) + .SetApplicationName("WART_App"); + + // Configure cookie-based authentication + services.AddAuthentication("WartCookieAuth") + .AddCookie("WartCookieAuth", options => + { + options.LoginPath = loginPath; + options.AccessDeniedPath = accessDeniedPath; + options.Cookie.Name = "WART.AuthCookie"; + options.ExpireTimeSpan = TimeSpan.FromHours(1); + options.SlidingExpiration = true; + }); + + // Register WART event queue service + services.AddSingleton(); + + // Register the WART event worker for the cookie-authenticated hub + services.AddHostedService>(); + + // SignalR configuration + services.AddSignalR(options => + { + options.EnableDetailedErrors = true; + options.HandshakeTimeout = TimeSpan.FromSeconds(15); + options.KeepAliveInterval = TimeSpan.FromSeconds(15); + options.ClientTimeoutInterval = TimeSpan.FromSeconds(30); + }); + + // Compression for SignalR WebSocket/Binary transport + services.AddResponseCompression(opts => + { + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( + new[] { "application/octet-stream" }); + }); + + return services; + } + } +} \ No newline at end of file diff --git a/src/WART-Core/Controllers/WartControllerCookie.cs b/src/WART-Core/Controllers/WartControllerCookie.cs new file mode 100644 index 0000000..27473c1 --- /dev/null +++ b/src/WART-Core/Controllers/WartControllerCookie.cs @@ -0,0 +1,19 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using WART_Core.Hubs; + +namespace WART_Core.Controllers +{ + /// + /// The WART Controller with Cookie authentication + /// + public class WartControllerCookie : WartBaseController + { + public WartControllerCookie(IHubContext hubContext, ILogger logger) + : base(hubContext, logger) + { + } + } +} diff --git a/src/WART-Core/Enum/HubType.cs b/src/WART-Core/Enum/HubType.cs index c099237..2b407a0 100755 --- a/src/WART-Core/Enum/HubType.cs +++ b/src/WART-Core/Enum/HubType.cs @@ -11,9 +11,15 @@ public enum HubType /// Simple SignalR hub without authentication /// NoAuthentication, + /// /// SignalR hub with JWT authentication /// - JwtAuthentication + JwtAuthentication, + + /// + /// SignalR hub with Cookie authentication + /// + CookieAuthentication } } \ No newline at end of file diff --git a/src/WART-Core/Hubs/WartHubCookie.cs b/src/WART-Core/Hubs/WartHubCookie.cs new file mode 100644 index 0000000..7508a7e --- /dev/null +++ b/src/WART-Core/Hubs/WartHubCookie.cs @@ -0,0 +1,16 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; + +namespace WART_Core.Hubs +{ + /// + /// The WART SignalR hub with Cookie-based authentication. + /// + [Authorize] + public class WartHubCookie : WartHubBase + { + public WartHubCookie(ILogger logger) : base(logger) { } + } +} \ No newline at end of file diff --git a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs index a40e5d8..d5fa39b 100755 --- a/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs +++ b/src/WART-Core/Middleware/WartApplicationBuilderExtension.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using WART_Core.Authentication.Cookie; using WART_Core.Authentication.JWT; using WART_Core.Enum; using WART_Core.Hubs; @@ -47,22 +48,38 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app { app.UseRouting(); - if (hubType == HubType.NoAuthentication) + switch(hubType) { - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); - }); - } - else - { - app.UseJwtMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{DefaultHubName}"); - }); + default: + case HubType.NoAuthentication: + { + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{DefaultHubName}"); + }); + break; + } + case HubType.JwtAuthentication: + { + app.UseJwtMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{DefaultHubName}"); + }); + break; + } + case HubType.CookieAuthentication: + { + app.UseCookieMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{DefaultHubName}"); + }); + break; + } } app.UseForwardedHeaders(); @@ -141,22 +158,38 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app app.UseRouting(); - if (hubType == HubType.NoAuthentication) + switch (hubType) { - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); - }); - } - else - { - app.UseJwtMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); - }); + default: + case HubType.NoAuthentication: + { + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } + case HubType.JwtAuthentication: + { + app.UseJwtMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } + case HubType.CookieAuthentication: + { + app.UseCookieMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } } app.UseForwardedHeaders(); @@ -183,22 +216,38 @@ public static IApplicationBuilder UseWartMiddleware(this IApplicationBuilder app foreach (var hubName in hubNameList.Distinct()) { - if (hubType == HubType.NoAuthentication) + switch (hubType) { - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); - }); - } - else - { - app.UseJwtMiddleware(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllers(); - endpoints.MapHub($"/{hubName.Trim()}"); - }); + default: + case HubType.NoAuthentication: + { + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } + case HubType.JwtAuthentication: + { + app.UseJwtMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } + case HubType.CookieAuthentication: + { + app.UseCookieMiddleware(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHub($"/{hubName.Trim()}"); + }); + break; + } } } diff --git a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs index 52bbb27..4886c4b 100755 --- a/src/WART-Core/Middleware/WartServiceCollectionExtension.cs +++ b/src/WART-Core/Middleware/WartServiceCollectionExtension.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using System; using System.Linq; +using WART_Core.Authentication.Cookie; using WART_Core.Authentication.JWT; using WART_Core.Enum; using WART_Core.Hubs; @@ -70,15 +71,27 @@ public static IServiceCollection AddWartMiddleware(this IServiceCollection servi public static IServiceCollection AddWartMiddleware(this IServiceCollection services, HubType hubType, string tokenKey = "") { // Check the hub type to determine if authentication is required. - if (hubType == HubType.NoAuthentication) + switch(hubType) { - // If no authentication is required, configure WART middleware without authentication. - services.AddWartMiddleware(); - } - else - { - // If authentication is required, configure JWT middleware for authentication. - services.AddJwtMiddleware(tokenKey); + default: + case HubType.NoAuthentication: + { + // If no authentication is required, configure WART middleware without authentication. + services.AddWartMiddleware(); + break; + } + case HubType.JwtAuthentication: + { + // If authentication is required, configure JWT middleware for authentication. + services.AddJwtMiddleware(tokenKey); + break; + } + case HubType.CookieAuthentication: + { + // If authentication is required, configure Cookie middleware for authentication. + services.AddCookieMiddleware(); + break; + } } return services; diff --git a/src/WART-Core/WART-Core.csproj b/src/WART-Core/WART-Core.csproj index ce614c7..4c3cbf6 100644 --- a/src/WART-Core/WART-Core.csproj +++ b/src/WART-Core/WART-Core.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 WART_Core true Francesco Del Re @@ -14,7 +14,7 @@ 4.0.0.0 4.0.0.0 - 5.3.4 + 5.4.0 icon.png README.md @@ -22,8 +22,8 @@ - - + + diff --git a/src/WART-Tests/WART-Tests.csproj b/src/WART-Tests/WART-Tests.csproj index ad6b507..c9673cb 100644 --- a/src/WART-Tests/WART-Tests.csproj +++ b/src/WART-Tests/WART-Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 WART_Tests enable enable @@ -11,15 +11,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/WART-WebApiRealTime/Controllers/TestCookieController.cs b/src/WART-WebApiRealTime/Controllers/TestCookieController.cs new file mode 100644 index 0000000..a632762 --- /dev/null +++ b/src/WART-WebApiRealTime/Controllers/TestCookieController.cs @@ -0,0 +1,126 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authentication; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using WART_Api.Entity; +using WART_Core.Controllers; +using WART_Core.Filters; +using WART_Core.Hubs; + +namespace WART_Api.Controllers +{ + /// + /// A simple controller example extended by the WartController with Cookie authentication. + /// + [ApiController] + [Route("api/[controller]")] + public class TestCookieController : WartControllerCookie + { + private static List Items = new List + { + new TestEntity { Id = 1, Param = "Item1" }, + new TestEntity { Id = 2, Param = "Item2" }, + new TestEntity { Id = 3, Param = "Item3" } + }; + + public TestCookieController(IHubContext messageHubContext, ILogger logger) : base(messageHubContext, logger) + { + } + + // Login endpoint: issues authentication cookie using "WartCookieAuth" scheme + [HttpPost("login")] + [ExcludeWart] // Exclude from event interception if using custom filters + public async Task Login([FromForm] string username, [FromForm] string password) + { + if (!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password)) + { + var claims = new List + { + new Claim(ClaimTypes.Name, username) + }; + + var identity = new ClaimsIdentity(claims, "WartCookieAuth"); + var principal = new ClaimsPrincipal(identity); + + await HttpContext.SignInAsync("WartCookieAuth", principal, new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTime.UtcNow.AddHours(1) + }); + + return Ok("Login successful."); + } + + return Unauthorized("Invalid credentials."); + } + + [HttpGet] + public IEnumerable Get() + { + return Items; + } + + [HttpGet("{id}")] + [ExcludeWart] + public ActionResult Get(int id) + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item == null) + { + return NotFound(); + } + return item; + } + + [HttpPost] + [GroupWart("SampleGroupName")] + public ActionResult Post([FromBody] TestEntity entity) + { + Items.Add(entity); + return entity; + } + + [HttpPatch("{id}")] + public ActionResult Patch(int id, [FromBody] TestEntity entity) + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item == null) + { + return NotFound(); + } + item.Param = entity.Param; + return item; + } + + [HttpPut("{id}")] + public ActionResult Put(int id, [FromBody] TestEntity entity) + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item == null) + { + return NotFound(); + } + item.Param = entity.Param; + return item; + } + + [HttpDelete("{id}")] + public ActionResult Delete(int id) + { + var item = Items.FirstOrDefault(x => x.Id == id); + if (item == null) + { + return NotFound(); + } + Items.Remove(item); + return item; + } + } +} \ No newline at end of file diff --git a/src/WART-WebApiRealTime/Startup.cs b/src/WART-WebApiRealTime/Startup.cs index 1c0e1bc..8a84660 100755 --- a/src/WART-WebApiRealTime/Startup.cs +++ b/src/WART-WebApiRealTime/Startup.cs @@ -28,10 +28,15 @@ public void ConfigureServices(IServiceCollection services) services.AddControllers(); // add the Wart middleware service extension - // default without authentication + + // default without authentication //services.AddWartMiddleware(); - // with authentication - services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t"); + + // with JWT authentication + //services.AddWartMiddleware(hubType: HubType.JwtAuthentication, tokenKey: "dn3341fmcscscwe28419brhwbwgbss4t"); + + // with Cookie authentication + services.AddWartMiddleware(hubType: HubType.CookieAuthentication); // Register the Swagger generator, defining 1 or more Swagger documents services.AddSwaggerGen(c => @@ -69,8 +74,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) // default without authentication //app.UseWartMiddleware(); - // with authentication - app.UseWartMiddleware(HubType.JwtAuthentication); + // with JWT authentication + //app.UseWartMiddleware(HubType.JwtAuthentication); + + // with Cookie authentication + app.UseWartMiddleware(HubType.CookieAuthentication); // multiple hub with authentication //var hubNameList = new List diff --git a/src/WART-WebApiRealTime/WART-Api.csproj b/src/WART-WebApiRealTime/WART-Api.csproj index b4a5560..792f9b1 100755 --- a/src/WART-WebApiRealTime/WART-Api.csproj +++ b/src/WART-WebApiRealTime/WART-Api.csproj @@ -1,14 +1,14 @@ - net8.0 + net9.0 WART_Api False false - +