diff --git a/CHANGELOG.md b/CHANGELOG.md index da7b6e5..426590d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ ### ✨ Features - Added support for Versioned API routes via `VersionedEndpoint` class, and `GetBuilder(VersionedEndpoint endpoint)` method signatures. -- Added support for M2M authentication via M2MAuthenticationClient. +- Added support for ClientCredentials OAuth authentication flow via `ClientCredentialsBaseClient` and `CachedClientCredentialsBaseClient`. ### 🛠 Technical diff --git a/NotoriousClient/Clients/BasicAuthBaseClient.cs b/NotoriousClient/Clients/Authentication/BasicAuthBaseClient.cs similarity index 97% rename from NotoriousClient/Clients/BasicAuthBaseClient.cs rename to NotoriousClient/Clients/Authentication/BasicAuthBaseClient.cs index 04d19c5..4ed57fc 100644 --- a/NotoriousClient/Clients/BasicAuthBaseClient.cs +++ b/NotoriousClient/Clients/Authentication/BasicAuthBaseClient.cs @@ -1,7 +1,7 @@ using NotoriousClient.Builder; using NotoriousClient.Sender; -namespace NotoriousClient.Clients +namespace NotoriousClient.Clients.Authentication { /// /// Base class for HTTP Client preconfigured with Basic Authentication. diff --git a/NotoriousClient/Clients/Authentication/CachedClientCredentialsBaseClient.cs b/NotoriousClient/Clients/Authentication/CachedClientCredentialsBaseClient.cs new file mode 100644 index 0000000..b4947d9 --- /dev/null +++ b/NotoriousClient/Clients/Authentication/CachedClientCredentialsBaseClient.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + +using NotoriousClient.Builder; +using NotoriousClient.Clients.Authentication.Models; +using NotoriousClient.Sender; + +namespace NotoriousClient.Clients.Authentication +{ + public class MemoryCachedClientCredentialsBaseClient : ClientCredentialsBaseClient + { + private const int SKEW_IN_SECONDS = 60; + private const int MinimumCacheExpiry = 1; + private readonly IMemoryCache _cache; + + public MemoryCachedClientCredentialsBaseClient(IRequestSender sender, IMemoryCache cache, IOptions server) : base(sender, server) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + /// + /// Get a preconfigured with Bearer Authentication using ClientCredentials OAuth flow. + /// + protected override async Task GetBuilderAsync(string route, Method method = Method.Get, string? version = null) + { + DiscoveryDocument? discovery = await GetDiscoveryDocument(); + TokenEndpointResponse response = await GetToken(discovery); + + return (await base.GetBuilderAsync(route, method, version)).WithAuthentication(response.AccessToken); + } + + protected override async Task GetToken(DiscoveryDocument? discovery) + + { + IOrderedEnumerable scopes = AuthenticationServerOptions.Value.Scopes.OrderBy(s => s); + IOrderedEnumerable audiences = AuthenticationServerOptions.Value.Audiences.OrderBy(a => a); + string authority = AuthenticationServerOptions.Value.Authority; + string clientId = AuthenticationServerOptions.Value.ClientId; + + string cacheKey = $"{authority}::{clientId}::{string.Join("|", audiences)}::{string.Join("|", scopes)}"; + + if (!_cache.TryGetValue(cacheKey, out var cachedToken)) + { + TokenEndpointResponse response = await base.GetToken(discovery); + + int expirySeconds = Math.Max(MinimumCacheExpiry, response.ExpiresIn - SKEW_IN_SECONDS); + var cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(expirySeconds) + }; + + _cache.Set(cacheKey, response, cacheOptions); + return response; + } + + return cachedToken; + } + + protected override async Task GetDiscoveryDocument() + { + string key = $"{AuthenticationServerOptions.Value.Authority}::discovery"; + if (!_cache.TryGetValue(key, out var discovery)) + { + discovery = await base.GetDiscoveryDocument(); + _cache.Set(key, discovery, TimeSpan.FromHours(24)); + } + + return discovery; + } + } +} diff --git a/NotoriousClient/Clients/Authentication/ClientCredentialsBaseClient.cs b/NotoriousClient/Clients/Authentication/ClientCredentialsBaseClient.cs new file mode 100644 index 0000000..101ed83 --- /dev/null +++ b/NotoriousClient/Clients/Authentication/ClientCredentialsBaseClient.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Options; + +using NotoriousClient.Builder; +using NotoriousClient.Clients.Authentication.Models; +using NotoriousClient.Sender; + +namespace NotoriousClient.Clients.Authentication +{ + public class ClientCredentialsBaseClient : BaseClient + { + protected IOptions AuthenticationServerOptions { get; private init; } + private readonly Endpoint DISCOVERY_ENDPOINT = new Endpoint("/.well-known/openid-configuration", Method.Get); + + public ClientCredentialsBaseClient(IRequestSender sender, IOptions server) : base(sender, server.Value.Authority) + { + AuthenticationServerOptions = server ?? throw new ArgumentNullException(nameof(server)); + } + + protected override async Task GetBuilderAsync(string route, Method method = Method.Get, string? version = null) + { + string tokenEndpoint = string.Empty; + DiscoveryDocument? discovery = await GetDiscoveryDocument(); + TokenEndpointResponse response = await GetToken(discovery); + + return (await base.GetBuilderAsync(route, method, version)).WithAuthentication(response.AccessToken); + } + + protected virtual async Task GetToken(DiscoveryDocument? discovery) + { + HttpRequestMessage request = new RequestBuilder(discovery.TokenEndpoint, "", Method.Post) + .WithContentBody(new FormUrlEncodedContent(new Dictionary + { + { "grant_type", "client_credentials" }, + { "client_id", AuthenticationServerOptions.Value.ClientId }, + { "client_secret", AuthenticationServerOptions.Value.ClientSecret }, + { "audience", string.Join(" ", AuthenticationServerOptions.Value.Audiences) }, + { "scope", string.Join(" ", AuthenticationServerOptions.Value.Scopes) } + })).Build(); + + return (await Sender.SendAsync(request)).ReadAs(); + } + + protected virtual async Task GetDiscoveryDocument() + { + HttpResponseMessage discoResponse = await Sender.SendAsync(GetBuilder(DISCOVERY_ENDPOINT).Build()); + discoResponse.EnsureSuccessStatusCode(); + + return await discoResponse.ReadAsAsync(); + } + } +} diff --git a/NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs b/NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs new file mode 100644 index 0000000..f1b08d9 --- /dev/null +++ b/NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace NotoriousClient.Clients.Authentication.Models +{ + public class DiscoveryDocument + { + [JsonPropertyName("token_endpoint")] + public string TokenEndpoint { get; set; } + } +} diff --git a/NotoriousClient/Clients/Authentication/Models/TokenEndpointResponse.cs b/NotoriousClient/Clients/Authentication/Models/TokenEndpointResponse.cs new file mode 100644 index 0000000..adb04c8 --- /dev/null +++ b/NotoriousClient/Clients/Authentication/Models/TokenEndpointResponse.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace NotoriousClient.Clients.Authentication.Models +{ + public class TokenEndpointResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = string.Empty; + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + [JsonPropertyName("refresh_expires_in")] + public int RefreshExpiresIn { get; set; } + + [JsonPropertyName("token_type")] + public string TokenType { get; set; } = string.Empty; + + [JsonPropertyName("not-before-policy")] + public int NotBeforePolicy { get; set; } + + [JsonPropertyName("scope")] + public string Scope { get; set; } = string.Empty; + } + +} diff --git a/NotoriousClient/Clients/AuthorizationServerOptions.cs b/NotoriousClient/Clients/AuthorizationServerOptions.cs new file mode 100644 index 0000000..d9be66d --- /dev/null +++ b/NotoriousClient/Clients/AuthorizationServerOptions.cs @@ -0,0 +1,31 @@ +namespace NotoriousClient.Clients +{ + public class AuthorizationServerOptions + { + /// + /// Gets or sets the unique identifier for the client. + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the client secret used for authentication. + /// + public string ClientSecret { get; set; } + + /// + /// Gets or sets the authority information for the current context. + /// + public string Authority { get; set; } + + /// + /// Gets or sets the list of audiences that are allowed to access the resource. + /// + public string[] Audiences { get; set; } + + /// + /// Gets or sets the scopes requested during the authorization process. + /// + public string[] Scopes { get; set; } + + } +} diff --git a/NotoriousClient/NotoriousClient.csproj b/NotoriousClient/NotoriousClient.csproj index 74c393f..9659dd5 100644 --- a/NotoriousClient/NotoriousClient.csproj +++ b/NotoriousClient/NotoriousClient.csproj @@ -15,7 +15,8 @@ - + +