Compare commits
34 Commits
64717fbba5
...
6ac2c86520
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ac2c86520 | ||
|
|
4e941ed35f | ||
|
|
e925c175a0 | ||
|
|
eaf41adb58 | ||
|
|
a2c74cbdd9 | ||
|
|
63c37551be | ||
|
|
6198008475 | ||
|
|
8682f1f9e0 | ||
|
|
fb486296f2 | ||
|
|
aa2572fd17 | ||
|
|
7153d6ec46 | ||
|
|
4e3448b4d4 | ||
|
|
36891b5abb | ||
|
|
6664a1f342 | ||
|
|
b2a287cab5 | ||
|
|
db52e97d03 | ||
|
|
4c001d4087 | ||
|
|
3c37176d5e | ||
|
|
0935573b93 | ||
|
|
f30f1f127d | ||
|
|
1fe3fb9008 | ||
|
|
d21da5028e | ||
|
|
062942b2d2 | ||
|
|
c47197606b | ||
|
|
137ccaa563 | ||
|
|
4062fe750a | ||
|
|
cb6ec8b5e6 | ||
|
|
7873542aca | ||
|
|
6694e4b626 | ||
|
|
0cce082cb7 | ||
|
|
7f39cbe24a | ||
|
|
484cc86a29 | ||
|
|
5ab1f24ce5 | ||
|
|
33ead6ebf4 |
@@ -1,6 +0,0 @@
|
||||
namespace DigitalData.Auth.Abstractions;
|
||||
|
||||
public static class ClientExtensions
|
||||
{
|
||||
public static bool IsConnectionFailed(this IAuthClient client) => client.ConnectionError is not null;
|
||||
}
|
||||
@@ -4,6 +4,27 @@
|
||||
<TargetFrameworks>net7.0;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Description>DigitalData.Auth.Abstractions defines lightweight interfaces for sending and receiving authentication keys in .NET applications. It provides a unified IAuthClient for managing connections and errors, enabling seamless integration with authentication systems.</Description>
|
||||
<PackageId>DigitalData.Auth.Abstractions</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>Digital Data GmbH</Product>
|
||||
<Copyright>Copyright 2025</Copyright>
|
||||
<PackageProjectUrl>http://git.dd:3000/AppStd/DigitalData.Auth</PackageProjectUrl>
|
||||
<PackageIcon>auth_icon.png</PackageIcon>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/DigitalData.Auth</RepositoryUrl>
|
||||
<PackageTags>Digital Data Auth Authorization Authentication Abstractions</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="PublicKey.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\nuget-package-icons\auth_icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -4,11 +4,7 @@ public interface IAuthClient : IAuthListenHandler, IAuthSenderHandler
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
|
||||
Exception? ConnectionError { get; }
|
||||
|
||||
bool IsConnectionFailed => ConnectionError is not null;
|
||||
|
||||
Task StartAsync();
|
||||
|
||||
Task<bool> TryStartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
public interface IAuthListenHandler
|
||||
{
|
||||
Task ReceiveKeyAsync(string topic, string key);
|
||||
Task ReceivePublicKeyAsync(string issuer, string audience, string value);
|
||||
}
|
||||
@@ -2,5 +2,7 @@
|
||||
|
||||
public interface IAuthSenderHandler
|
||||
{
|
||||
Task SendKeyAsync(string topic, string key);
|
||||
Task SendPublicKeyAsync(string issuer, string audience, string key);
|
||||
|
||||
Task GetPublicKeyAsync(string issuer, string audience);
|
||||
}
|
||||
14
DigitalData.Auth.Client/AsymmetricPublicKey.cs
Normal file
14
DigitalData.Auth.Client/AsymmetricPublicKey.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
|
||||
namespace DigitalData.Auth.Client;
|
||||
|
||||
public class AsymmetricPublicKey : IUniqueSecurityContext, IAsymmetricPublicKey
|
||||
{
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
public required string Audience { get; init; }
|
||||
|
||||
public string? Id { get; init; }
|
||||
|
||||
public string Content { get; internal set; } = string.Empty;
|
||||
}
|
||||
@@ -9,61 +9,65 @@ public class AuthClient : IAuthClient, IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
|
||||
private readonly Lazy<Task<bool>> _lazyInitiator;
|
||||
|
||||
private readonly ILogger? _logger;
|
||||
private readonly ILogger<AuthClient>? _logger;
|
||||
|
||||
private readonly ClientParams _params;
|
||||
|
||||
public AuthClient(IOptions<ClientParams> paramsOptions, HubConnectionBuilder connectionBuilder, ILogger<AuthClient>? logger = null)
|
||||
{
|
||||
_connection = connectionBuilder
|
||||
.WithUrl(paramsOptions.Value.Url)
|
||||
.Build();
|
||||
_params = paramsOptions.Value;
|
||||
|
||||
_connection.On<string, string>(nameof(ReceiveKeyAsync), ReceiveKeyAsync);
|
||||
var cnnBuilder = connectionBuilder.WithUrl(_params.Url);
|
||||
|
||||
// set RetryPolicy if it exists
|
||||
if (_params.RetryPolicy is not null)
|
||||
cnnBuilder = cnnBuilder.WithAutomaticReconnect(_params.RetryPolicy);
|
||||
|
||||
_connection = cnnBuilder.Build();
|
||||
|
||||
_connection.On<string, string, string>(nameof(ReceivePublicKeyAsync), ReceivePublicKeyAsync);
|
||||
|
||||
_connection.Reconnected += async cnnId => await GetAllPublicKeysAsync();
|
||||
|
||||
_logger = logger;
|
||||
|
||||
_params = paramsOptions.Value;
|
||||
|
||||
_lazyInitiator = new(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _connection.StartAsync();
|
||||
IsConnected = true;
|
||||
return true;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
ConnectionError = ex;
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public bool IsConnected { get; private set; } = false;
|
||||
|
||||
public Exception? ConnectionError { get; private set; }
|
||||
public IEnumerable<AsymmetricPublicKey> PublicKeys => _params.PublicKeys;
|
||||
|
||||
public async Task StartAsync() => await _lazyInitiator.Value;
|
||||
public async Task StartAsync()
|
||||
{
|
||||
await _connection.StartAsync();
|
||||
IsConnected = true;
|
||||
await GetAllPublicKeysAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> TryStartAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _lazyInitiator.Value;
|
||||
await StartAsync();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch(Exception ex)
|
||||
{
|
||||
_logger?.LogError(ex, "{message}", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public Task ReceiveKeyAsync(string user, string message) => Task.Run(() => _params.Events.OnMessageReceived(user, message, _logger));
|
||||
public Task ReceivePublicKeyAsync(string issuer, string audience, string message) => Task.Run(() => _params.TriggerOnMessageReceived(this, issuer, audience, message, _logger));
|
||||
|
||||
public Task SendKeyAsync(string user, string message) => _connection.InvokeAsync(nameof(SendKeyAsync), user, message);
|
||||
public Task SendPublicKeyAsync(string issuer, string audience, string message) => _connection.InvokeAsync(nameof(SendPublicKeyAsync), issuer, audience, message);
|
||||
|
||||
public Task GetPublicKeyAsync(string issuer, string audience) => _connection.InvokeAsync(nameof(GetPublicKeyAsync), issuer, audience);
|
||||
|
||||
public async Task GetAllPublicKeysAsync()
|
||||
{
|
||||
foreach (var publicKey in PublicKeys)
|
||||
await GetPublicKeyAsync(publicKey.Issuer, publicKey.Audience);
|
||||
}
|
||||
|
||||
public virtual async ValueTask DisposeAsync()
|
||||
{
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.Auth.Client
|
||||
namespace DigitalData.Auth.Client;
|
||||
|
||||
public delegate void ClientEvent(AuthClient client, string issuer, string audience, string content, ILogger? logger = null);
|
||||
|
||||
public static class ClientEvents
|
||||
{
|
||||
public class ClientEvents
|
||||
public static readonly ClientEvent UpdatePublicKeys = (client, issuer, audience, content, logger) =>
|
||||
{
|
||||
public Action<string, string, ILogger?> OnMessageReceived { get; set; } = (user, message, logger)
|
||||
=> logger?.LogInformation("{user}: {message}", user, message);
|
||||
}
|
||||
if(client.PublicKeys.TryGet(issuer, audience, out var publicKey))
|
||||
publicKey.Content = content;
|
||||
else
|
||||
logger?.LogWarning(
|
||||
"Failed to update public key: No matching key found. Issuer: {Issuer}, Audience: {Audience}. Ensure the key exists before attempting an update.", issuer, audience);
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,46 @@
|
||||
namespace DigitalData.Auth.Client;
|
||||
using DigitalData.Auth.Client;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.Auth.Client;
|
||||
|
||||
public class ClientParams
|
||||
{
|
||||
#pragma warning disable CS8618 // throw exception in DI extension if it not set
|
||||
public string Url { get; set; }
|
||||
#pragma warning restore CS8618
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
public readonly ClientEvents Events = new();
|
||||
private readonly Lazy<IRetryPolicy?> _lazyRetryPolicy;
|
||||
|
||||
/// <summary>
|
||||
/// Controls when the client attempts to reconnect and how many times it does so.
|
||||
/// </summary>
|
||||
public IRetryPolicy? RetryPolicy => _lazyRetryPolicy.Value;
|
||||
|
||||
/// <summary>
|
||||
/// To simplify the assignment of <seealso cref="RetryPolicy"/>
|
||||
/// </summary>
|
||||
public Func<RetryContext, TimeSpan?>? NextRetryDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// To be able to serilize the simple <seealso cref="RetryPolicy"/>
|
||||
/// </summary>
|
||||
public TimeSpan? RetryDelay { get; set; }
|
||||
|
||||
public event ClientEvent OnMessageReceived = ClientEvents.UpdatePublicKeys;
|
||||
|
||||
internal void TriggerOnMessageReceived(AuthClient client, string issuer, string audience, string key, ILogger? logger = null)
|
||||
=> OnMessageReceived(client, issuer, audience, key, logger);
|
||||
|
||||
public ClientParams()
|
||||
{
|
||||
_lazyRetryPolicy = new(() =>
|
||||
{
|
||||
if (RetryDelay is not null)
|
||||
return new RetryPolicy(ctx => RetryDelay);
|
||||
else if(NextRetryDelay is not null)
|
||||
return new RetryPolicy(NextRetryDelay);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
public List<AsymmetricPublicKey> PublicKeys { get; init; } = new();
|
||||
}
|
||||
@@ -4,9 +4,29 @@
|
||||
<TargetFrameworks>net7.0;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PackageId>DigitalData.Auth.Client</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Description>DigitalData.Auth.Client is a SignalR-based authentication client that enables applications to connect to a central authentication hub for real-time message exchange. It provides seamless connection management, automatic reconnection (RetryPolicy), and event-driven communication (ClientEvents). The package includes dependency injection support via DIExtensions, allowing easy integration into ASP.NET Core applications. With built-in retry policies and secure message handling, it ensures a reliable and scalable authentication client for real-time authentication workflows.</Description>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>Digital Data GmbH</Product>
|
||||
<Copyright>Copyright 2025</Copyright>
|
||||
<PackageProjectUrl>http://git.dd:3000/AppStd/DigitalData.Auth</PackageProjectUrl>
|
||||
<PackageIcon>auth_icon.png</PackageIcon>
|
||||
<RepositoryUrl>http://git.dd:3000/AppStd/DigitalData.Auth</RepositoryUrl>
|
||||
<PackageTags>Digital Data Auth Authorization Authentication</PackageTags>
|
||||
<AssemblyVersion>1.0.0</AssemblyVersion>
|
||||
<FileVersion>1.0.0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\nuget-package-icons\auth_icon.png">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DigitalData.Core.Abstractions" Version="3.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
15
DigitalData.Auth.Client/RetryPolicy.cs
Normal file
15
DigitalData.Auth.Client/RetryPolicy.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace DigitalData.Auth.Client;
|
||||
|
||||
public class RetryPolicy : IRetryPolicy
|
||||
{
|
||||
private readonly Func<RetryContext, TimeSpan?> _nextRetryDelay;
|
||||
|
||||
public RetryPolicy(Func<RetryContext, TimeSpan?> nextRetryDelay)
|
||||
{
|
||||
_nextRetryDelay = nextRetryDelay;
|
||||
}
|
||||
|
||||
public TimeSpan? NextRetryDelay(RetryContext retryContext) => _nextRetryDelay(retryContext);
|
||||
}
|
||||
129
DigitalData.Auth.Tests/API/AuthHubTests.cs
Normal file
129
DigitalData.Auth.Tests/API/AuthHubTests.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using DigitalData.Auth.Abstractions;
|
||||
using DigitalData.Auth.Client;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using DigitalData.Auth.API.Services;
|
||||
using DigitalData.Auth.API.Services.Contracts;
|
||||
|
||||
namespace DigitalData.Auth.Tests.API;
|
||||
|
||||
// TODO: The test checks if the services are working. Performance measurement is ignored. Update it to measure performance as well.
|
||||
[TestFixture]
|
||||
public class AuthHubTests
|
||||
{
|
||||
private string _hubUrl;
|
||||
|
||||
private Func<Action<ClientParams>, ServiceProvider> Build;
|
||||
|
||||
private readonly WebApplication? _app = null;
|
||||
|
||||
private readonly Queue<IAsyncDisposable> _disposableAsync = new();
|
||||
|
||||
private INotifier _notifier;
|
||||
|
||||
private static int AvailablePort
|
||||
{
|
||||
get
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
Build = options =>
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddAuthHubClient(options)
|
||||
.BuildServiceProvider();
|
||||
|
||||
_disposableAsync.Enqueue(provider);
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Create and run test server
|
||||
// Create builder and add SignalR service
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var config = builder.Configuration;
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddAuthService(
|
||||
config,
|
||||
consumers => consumers.Add(new(0, "mock", "123", "client.mock")) // Set mock consumer
|
||||
);
|
||||
|
||||
// Listen AvailablePort and map hub
|
||||
var _app = builder.Build();
|
||||
var url = $"http://localhost:{AvailablePort}";
|
||||
var hubRoute = "/auth-hub";
|
||||
_hubUrl = url + hubRoute;
|
||||
_app.Urls.Add(url);
|
||||
_app.MapHub<Auth.API.Hubs.AuthHub>(hubRoute);
|
||||
_app.Start();
|
||||
|
||||
// Create notifier by app services
|
||||
_notifier = _app.Services.GetRequiredService<INotifier>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown()
|
||||
{
|
||||
// Stop test server
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
Console.WriteLine("Test server stopped.");
|
||||
}
|
||||
|
||||
while (_disposableAsync.Count > 0)
|
||||
await _disposableAsync.Dequeue().DisposeAsync();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReceiveMessage_ShouldCallOnMessageReceived()
|
||||
{
|
||||
// Arrange
|
||||
string rcv_issuer = string.Empty;
|
||||
string rcv_audience = string.Empty;
|
||||
string rcv_key = string.Empty;
|
||||
|
||||
// Receiver client
|
||||
var provider_receiver = Build(opt =>
|
||||
{
|
||||
opt.Url = _hubUrl;
|
||||
opt.OnMessageReceived += (client, issuer, audience, key, logger) =>
|
||||
{
|
||||
rcv_issuer = issuer;
|
||||
rcv_audience = audience;
|
||||
rcv_key = key;
|
||||
};
|
||||
});
|
||||
var client_receiver = provider_receiver.GetRequiredService<IAuthClient>();
|
||||
await client_receiver.StartAsync();
|
||||
|
||||
string issuer = "issuer";
|
||||
string audience = "audience";
|
||||
string key = "key";
|
||||
|
||||
// Act
|
||||
await _notifier.UpdateKeyAsync(issuer, audience, key);
|
||||
|
||||
// delay fort getting answer
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(rcv_issuer, Is.EqualTo(issuer));
|
||||
Assert.That(rcv_audience, Is.EqualTo(audience));
|
||||
Assert.That(rcv_key, Is.EqualTo(key));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
using DigitalData.Auth.Abstractions;
|
||||
using DigitalData.Auth.API.Hubs;
|
||||
using DigitalData.Auth.Client;
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using DigitalData.Core.Security;
|
||||
using DigitalData.Core.Security.Config;
|
||||
using DigitalData.Core.Security.RSAKey;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -31,6 +35,17 @@ public class AuthClientTests
|
||||
}
|
||||
}
|
||||
|
||||
private readonly IEnumerable<RSATokenDescriptor> _tokenDescriptors =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Issuer = "Foo",
|
||||
Audience = "Bar",
|
||||
Lifetime = new TimeSpan(1, 0, 0),
|
||||
Content = Instance.RSAFactory.CreatePrivateKeyPem()
|
||||
}
|
||||
];
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
@@ -49,6 +64,13 @@ public class AuthClientTests
|
||||
// Create builder and add SignalR service
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddCryptoFactory(new CryptoFactoryParams()
|
||||
{
|
||||
PemDirectory = "/",
|
||||
Decryptors = [new RSADecryptor()],
|
||||
TokenDescriptors = _tokenDescriptors
|
||||
});
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
// Listen AvailablePort and map hub
|
||||
var _app = builder.Build();
|
||||
@@ -87,19 +109,15 @@ public class AuthClientTests
|
||||
await client.StartAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(client.IsConnected);
|
||||
Assert.That(!client.IsConnectionFailed);
|
||||
Assert.That(client.ConnectionError, Is.Null);
|
||||
});
|
||||
Assert.That(client.IsConnected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReceiveMessage_ShouldCallOnMessageReceived()
|
||||
{
|
||||
// Arrange
|
||||
string rcv_topic = string.Empty;
|
||||
string rcv_issuer = string.Empty;
|
||||
string rcv_audience = string.Empty;
|
||||
string rcv_key = string.Empty;
|
||||
|
||||
// Sender client
|
||||
@@ -111,20 +129,22 @@ public class AuthClientTests
|
||||
var provider_receiver = Build(opt =>
|
||||
{
|
||||
opt.Url = _hubUrl;
|
||||
opt.Events.OnMessageReceived = (topic, key, logger) =>
|
||||
opt.OnMessageReceived += (client, issuer, audience, key, logger) =>
|
||||
{
|
||||
rcv_topic = topic;
|
||||
rcv_issuer = issuer;
|
||||
rcv_audience = audience;
|
||||
rcv_key = key;
|
||||
};
|
||||
});
|
||||
var client_receiver = provider_receiver.GetRequiredService<IAuthClient>();
|
||||
await client_receiver.StartAsync();
|
||||
|
||||
string topic = "topic";
|
||||
string issuer = "issuer";
|
||||
string audience = "audience";
|
||||
string key = "key";
|
||||
|
||||
// Act
|
||||
await sender_client.SendKeyAsync(topic, key);
|
||||
await sender_client.SendPublicKeyAsync(issuer, audience, key);
|
||||
|
||||
// delay fort getting answer
|
||||
await Task.Delay(2000);
|
||||
@@ -132,8 +152,60 @@ public class AuthClientTests
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(rcv_topic, Is.EqualTo(topic));
|
||||
Assert.That(rcv_issuer, Is.EqualTo(issuer));
|
||||
Assert.That(rcv_audience, Is.EqualTo(audience));
|
||||
Assert.That(rcv_key, Is.EqualTo(key));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPublicKey_ShouldReturnExpectedPublicKey()
|
||||
{
|
||||
// Arrange
|
||||
string? publicKey = null;
|
||||
var provider = Build(opt =>
|
||||
{
|
||||
opt.Url = _hubUrl;
|
||||
opt.OnMessageReceived += (client, issuer, audience, key, logger) => publicKey = key;
|
||||
});
|
||||
var client = provider.GetRequiredService<IAuthClient>();
|
||||
await client.StartAsync();
|
||||
var expectedPublicKey = _tokenDescriptors.Get("Foo", "Bar").PublicKey.Content;
|
||||
|
||||
// Act
|
||||
await client.GetPublicKeyAsync("Foo", "Bar");
|
||||
|
||||
// wait for network
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(publicKey, Is.Not.Null);
|
||||
Assert.That(publicKey, Is.EqualTo(expectedPublicKey));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartAsync_ShouldUpdateAllPublicKey()
|
||||
{
|
||||
// Arrange
|
||||
var publicKey = new AsymmetricPublicKey() { Issuer = "Foo", Audience = "Bar" };
|
||||
var provider = Build(opt =>
|
||||
{
|
||||
opt.Url = _hubUrl;
|
||||
opt.PublicKeys.Add(publicKey);
|
||||
});
|
||||
var client = provider.GetRequiredService<IAuthClient>();
|
||||
await client.StartAsync();
|
||||
|
||||
// Act
|
||||
var expectedPublicKey = _tokenDescriptors.Get("Foo", "Bar").PublicKey;
|
||||
|
||||
// wait for network
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Assert
|
||||
Assert.That(publicKey.Content, Is.EqualTo(expectedPublicKey.Content));
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,13 @@
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="API\appsettings.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||
<PackageReference Include="DigitalData.Core.Security" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
|
||||
|
||||
@@ -11,7 +11,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigitalData.Auth.Client", "
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigitalData.Auth.Tests", "DigitalData.Auth.Tests\DigitalData.Auth.Tests.csproj", "{AF517FD9-3EBE-4452-AAEC-DFF17CC270E3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.Auth.Abstractions", "DigitalData.Auth.Abstractions\DigitalData.Auth.Abstractions.csproj", "{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigitalData.Auth.Abstractions", "DigitalData.Auth.Abstractions\DigitalData.Auth.Abstractions.csproj", "{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -23,16 +23,16 @@ Global
|
||||
{1AF05BC2-6F15-420A-85F6-E6F8740CD557}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1AF05BC2-6F15-420A-85F6-E6F8740CD557}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1AF05BC2-6F15-420A-85F6-E6F8740CD557}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{521A2BC0-AEA8-4500-AAA9-1951556EDF9F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AF517FD9-3EBE-4452-AAEC-DFF17CC270E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AF517FD9-3EBE-4452-AAEC-DFF17CC270E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AF517FD9-3EBE-4452-AAEC-DFF17CC270E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AF517FD9-3EBE-4452-AAEC-DFF17CC270E3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Debug|Any CPU.Build.0 = Release|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{09FF9BF0-25BB-4EB2-B1B2-6D2873B9538C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
|
||||
@@ -12,6 +12,8 @@ namespace DigitalData.Auth.API.Config
|
||||
|
||||
public string DefaultCookieName { get; init; } = "AuthToken";
|
||||
|
||||
public string DefaultQueryStringKey { get; init; } = "AuthToken";
|
||||
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; init; } = true;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DigitalData.Core.Abstractions" Version="3.2.0" />
|
||||
<PackageReference Include="DigitalData.Core.Abstractions" Version="3.3.0" />
|
||||
<PackageReference Include="DigitalData.Core.Application" Version="3.2.0" />
|
||||
<PackageReference Include="DigitalData.Core.Security" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.12" />
|
||||
|
||||
@@ -1,16 +1,47 @@
|
||||
using DigitalData.Auth.Abstractions;
|
||||
using DigitalData.Core.Abstractions.Security;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace DigitalData.Auth.API.Hubs;
|
||||
|
||||
public class AuthHub : Hub<IAuthListenHandler>, IAuthSenderHandler
|
||||
{
|
||||
public async Task SendKeyAsync(string user, string message)
|
||||
=> await Clients.All.ReceiveKeyAsync(user, message);
|
||||
private readonly ICryptoFactory _cFactory;
|
||||
|
||||
public async Task SendMessageToCaller(string user, string message)
|
||||
=> await Clients.Caller.ReceiveKeyAsync(user, message);
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public async Task SendMessageToGroup(string user, string message)
|
||||
=> await Clients.Group("Auth.API Consumers").ReceiveKeyAsync(user, message);
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
private readonly static string CacheId = Guid.NewGuid().ToString();
|
||||
|
||||
public AuthHub(ICryptoFactory cryptoFactory, ILogger<AuthHub> logger, IMemoryCache cache)
|
||||
{
|
||||
_cFactory = cryptoFactory;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task GetPublicKeyAsync(string issuer, string audience)
|
||||
{
|
||||
if(_cFactory.TokenDescriptors.TryGet(issuer, audience, out var tDesc))
|
||||
{
|
||||
await Clients.Caller.ReceivePublicKeyAsync(issuer, audience, tDesc.PublicKey.Content);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Clients.Caller.ReceivePublicKeyAsync(issuer, audience, string.Empty);
|
||||
|
||||
// Log this warning only once per minute to avoid unnecessary repetition.
|
||||
_cache.GetOrCreate(CacheId + "LastLoggingDate", e =>
|
||||
{
|
||||
_logger.LogWarning("Token description is not found. Issuer: {issuer} Audience: {audience}", issuer, audience);
|
||||
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendPublicKeyAsync(string issuer, string audience, string value)
|
||||
=> await Clients.All.ReceivePublicKeyAsync(issuer, audience, value);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ var apiParams = config.Get<AuthApiParams>() ?? throw new InvalidOperationExcepti
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.Configure<AuthApiParams>(config);
|
||||
builder.Services.AddConsumerService(config);
|
||||
builder.Services.AddAuthService(config);
|
||||
builder.Services.AddCryptoFactory(config.GetSection("CryptParams"));
|
||||
builder.Services.AddJwtSignatureHandler<Consumer>(api => new Dictionary<string, object>
|
||||
{
|
||||
@@ -32,7 +32,7 @@ builder.Services.AddJwtSignatureHandler<Consumer>(api => new Dictionary<string,
|
||||
builder.Services.AddJwtSignatureHandler<UserReadDto>(user => new Dictionary<string, object>
|
||||
{
|
||||
{ JwtRegisteredClaimNames.Sub, user.Id },
|
||||
{ JwtRegisteredClaimNames.UniqueName, user.Id },
|
||||
{ JwtRegisteredClaimNames.UniqueName, user.Username },
|
||||
{ JwtRegisteredClaimNames.Email, user.Email ?? string.Empty },
|
||||
{ JwtRegisteredClaimNames.GivenName, user.Prename ?? string.Empty },
|
||||
{ JwtRegisteredClaimNames.FamilyName, user.Name ?? string.Empty },
|
||||
@@ -102,11 +102,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
{
|
||||
// if there is no token read related cookie
|
||||
if (context.Token is null // if there is no token
|
||||
&& context.Request.Cookies.TryGetValue(apiParams!.DefaultCookieName, out var token) // get token from cookies
|
||||
&& token is not null)
|
||||
context.Token = token;
|
||||
// if there is no token read related cookie or query string
|
||||
if (context.Token is null) // if there is no token
|
||||
{
|
||||
if (context.Request.Cookies.TryGetValue(apiParams!.DefaultCookieName, out var cookieToken) && cookieToken is not null)
|
||||
context.Token = cookieToken;
|
||||
else if (context.Request.Query.TryGetValue(apiParams.DefaultQueryStringKey, out var queryStrToken))
|
||||
context.Token = queryStrToken;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
|
||||
6
src/DigitalData.Auth.API/Services/Contracts/INotifier.cs
Normal file
6
src/DigitalData.Auth.API/Services/Contracts/INotifier.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace DigitalData.Auth.API.Services.Contracts;
|
||||
|
||||
public interface INotifier
|
||||
{
|
||||
Task UpdateKeyAsync(string issuer, string audience, string value);
|
||||
}
|
||||
@@ -2,16 +2,27 @@
|
||||
using DigitalData.Auth.API.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.Auth.API.Services
|
||||
namespace DigitalData.Auth.API.Services;
|
||||
|
||||
public static class DIExtensions
|
||||
{
|
||||
public static class DIExtensions
|
||||
public static IServiceCollection AddAuthService(this IServiceCollection services, IConfiguration? configuration = null, Action<List<Consumer>>? consumerOptions = null)
|
||||
{
|
||||
public static IServiceCollection AddConsumerService(this IServiceCollection services, IConfiguration configuration, string key = "Consumers")
|
||||
{
|
||||
var consumers = configuration.GetSection(key).Get<IEnumerable<Consumer>>() ?? throw new InvalidOperationException($"No Consumer list found in {key} in configuration.");
|
||||
services.AddSingleton(Options.Create(consumers));
|
||||
services.AddSingleton<IConsumerService, ConfiguredConsumerService>();
|
||||
return services;
|
||||
}
|
||||
List<Consumer> consumers = new();
|
||||
|
||||
var consumerKey = $"{nameof(Consumer)}s";
|
||||
if (configuration?.GetSection(consumerKey).Get<IEnumerable<Consumer>>() is IEnumerable<Consumer> consumersFromConfig)
|
||||
consumers.AddRange(consumersFromConfig);
|
||||
|
||||
consumerOptions?.Invoke(consumers);
|
||||
|
||||
if(consumers.Count == 0)
|
||||
throw new InvalidOperationException($"No Consumer list found in {consumerKey} in configuration.");
|
||||
|
||||
services.AddSingleton(Options.Create(consumers));
|
||||
services.AddSingleton<IConsumerService, ConfiguredConsumerService>();
|
||||
services.AddSingleton<INotifier, Notifier>();
|
||||
services.AddMemoryCache();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
21
src/DigitalData.Auth.API/Services/Notifier.cs
Normal file
21
src/DigitalData.Auth.API/Services/Notifier.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using DigitalData.Auth.Abstractions;
|
||||
using DigitalData.Auth.API.Hubs;
|
||||
using DigitalData.Auth.API.Services.Contracts;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace DigitalData.Auth.API.Services;
|
||||
|
||||
public class Notifier : INotifier
|
||||
{
|
||||
private readonly IHubContext<AuthHub, IAuthListenHandler> _hubContext;
|
||||
|
||||
public Notifier(IHubContext<AuthHub, IAuthListenHandler> hubContext)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task UpdateKeyAsync(string issuer, string audience, string value)
|
||||
{
|
||||
await _hubContext.Clients.All.ReceivePublicKeyAsync(issuer, audience, value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user