Implemented LiteCharms Security TokenService
continuous-integration/drone/pr Build is passing

This commit is contained in:
Khwezi Mngoma
2026-06-12 16:09:51 +02:00
parent f5f1035598
commit a98adea8f3
29 changed files with 313 additions and 100 deletions
@@ -0,0 +1,14 @@
namespace LiteCharms.Features.Api.Configuration;
public sealed class LiteCharmsClientSettings
{
public string? Authority { get; set; }
public string? GrantType { get; set; }
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Scope { get; set; }
}
@@ -0,0 +1,13 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenErrorResponse
{
[JsonPropertyName("error")]
public string? Error { get; set; }
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; set; }
[JsonPropertyName("error_uri")]
public string? ErrorUri { get; set; }
}
@@ -0,0 +1,20 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenRequest
{
[JsonPropertyName("grant_type")]
[AliasAs("grant_type")]
public string? GrantType { get; set; }
[JsonPropertyName("client_id")]
[AliasAs("client_id")]
public string? ClientId { get; set; }
[JsonPropertyName("client_secret")]
[AliasAs("client_secret")]
public string? ClientSecret { get; set; }
[JsonPropertyName("scope")]
[AliasAs("scope")]
public string? Scope { get; set; }
}
@@ -0,0 +1,17 @@
namespace LiteCharms.Features.Api.Models;
public sealed class TokenResponse
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; set; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
[JsonPropertyName("scope")]
public string? Scope { get; set; }
}
@@ -0,0 +1,10 @@
using LiteCharms.Features.Api.Models;
namespace LiteCharms.Features.Api.Sdk;
public interface IConnectApi
{
[Post("/connect/token")]
ValueTask<HttpResponseMessage> GetToken([Body(BodySerializationMethod.UrlEncoded)] TokenRequest request,
CancellationToken cancellationToken = default);
}
+67
View File
@@ -0,0 +1,67 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Api.Configuration;
using LiteCharms.Features.Api.Models;
using LiteCharms.Features.Api.Sdk;
namespace LiteCharms.Features.Api;
public sealed class TokenService(IConnectApi connectApi, IOptions<LiteCharmsClientSettings> clientOptions) : IService
{
private readonly LiteCharmsClientSettings clientSettings = clientOptions.Value;
public async Task<Result<TokenResponse>> GenerateAsync(CancellationToken cancellationToken = default)
{
try
{
var request = new TokenRequest
{
ClientId = clientSettings.ClientId,
ClientSecret = clientSettings.ClientSecret,
GrantType = clientSettings.GrantType,
Scope = clientSettings.Scope,
};
using var response = await connectApi.GetToken(request, cancellationToken);
var contentRaw = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(contentRaw))
return Result.Fail(new Error($"The authentication endpoint returned an empty payload. Status code: {response.StatusCode}"));
if (response.IsSuccessStatusCode)
{
var tokenResponse = JsonSerializer.Deserialize<TokenResponse>(contentRaw);
return !string.IsNullOrWhiteSpace(tokenResponse?.AccessToken)
? Result.Ok(tokenResponse)
: Result.Fail<TokenResponse>(new Error("Authentication succeeded, but no access token was found in the response payload."));
}
try
{
var errorResult = JsonSerializer.Deserialize<TokenErrorResponse>(contentRaw);
if (errorResult != null)
{
string summary = $"{errorResult.Error}: {errorResult.ErrorDescription}";
return Result.Fail(new Error(summary));
}
}
catch
{
return Result.Fail(new Error($"Authentication failed: {contentRaw}"));
}
return Result.Fail(new Error($"Authentication failed with status code: {response.StatusCode}"));
}
catch (OperationCanceledException ex)
{
return Result.Fail(new Error("The token generation request was canceled.").CausedBy(ex));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
+31 -1
View File
@@ -1,7 +1,7 @@
using LiteCharms.Features.Abstractions;
using LiteCharms.Features.Api;
using LiteCharms.Features.Api.Configuration;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using LiteCharms.Features.Api.Sdk;
namespace LiteCharms.Features.Extensions;
@@ -9,6 +9,36 @@ public static class Api
{
public const string Books = nameof(Books);
public const string Payments = nameof(Payments);
public static IServiceCollection AddSecurityApiSdk(this IServiceCollection services, IConfiguration configuration)
{
var configSection = configuration.GetSection(nameof(LiteCharmsClientSettings));
var authOptions = new LiteCharmsClientSettings();
configSection.Bind(authOptions);
services.Configure<LiteCharmsClientSettings>(configSection);
if (string.IsNullOrWhiteSpace(authOptions.Authority))
return services;
if (!authOptions.Authority.EndsWith("/", StringComparison.Ordinal)) authOptions.Authority += "/";
services.AddRefitClient<IConnectApi>()
.ConfigureHttpClient(config =>
{
config.BaseAddress = new Uri(authOptions.Authority);
config.Timeout = TimeSpan.FromSeconds(15);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.Retry.BackoffType = Polly.DelayBackoffType.Exponential;
});
return services;
}
public static IServiceCollection AddLiteCharmsWebSecurity(this IServiceCollection services, IConfiguration configuration)
{
@@ -37,11 +37,23 @@
<PackageReference Include="IdentityModel" Version="6.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="10.7.0" />
<PackageReference Include="Polly" Version="8.7.0" />
<PackageReference Include="Polly.Extensions" Version="8.7.0" />
<Using Include="Microsoft.AspNetCore.Authentication" />
<Using Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
<Using Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Using Include="IdentityModel.AspNetCore.OAuth2Introspection" />
<Using Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
<!-- API SDK Composer-->
<ItemGroup>
<PackageReference Include="Refit.HttpClientFactory" Version="11.0.1" />
<Using Include="Refit" />
</ItemGroup>
<!-- API Versioning -->
@@ -183,6 +195,7 @@
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage" />
<Using Include="System.Text.Json.Serialization" />
<Using Include="System.Reflection" />
<Using Include="Microsoft.Extensions.DependencyInjection.Extensions" />
<Using Include="Microsoft.AspNetCore.Routing" />