Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using NotoriousClient.Builder;
using NotoriousClient.Sender;

namespace NotoriousClient.Clients
namespace NotoriousClient.Clients.Authentication
{
/// <summary>
/// Base class for HTTP Client preconfigured with Basic Authentication.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AuthorizationServerOptions> server) : base(sender, server)
{
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
}

/// <summary>
/// Get a preconfigured <see cref="IRequestBuilder"/> with Bearer Authentication using ClientCredentials OAuth flow.
/// </summary>
protected override async Task<IRequestBuilder> 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<TokenEndpointResponse> GetToken(DiscoveryDocument? discovery)

{
IOrderedEnumerable<string> scopes = AuthenticationServerOptions.Value.Scopes.OrderBy(s => s);
IOrderedEnumerable<string> 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<TokenEndpointResponse>(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;

Check warning on line 56 in NotoriousClient/Clients/Authentication/CachedClientCredentialsBaseClient.cs

View workflow job for this annotation

GitHub Actions / publish

Possible null reference return.

Check warning on line 56 in NotoriousClient/Clients/Authentication/CachedClientCredentialsBaseClient.cs

View workflow job for this annotation

GitHub Actions / publish

Possible null reference return.
}

protected override async Task<DiscoveryDocument?> GetDiscoveryDocument()
{
string key = $"{AuthenticationServerOptions.Value.Authority}::discovery";
if (!_cache.TryGetValue<DiscoveryDocument>(key, out var discovery))
{
discovery = await base.GetDiscoveryDocument();
_cache.Set(key, discovery, TimeSpan.FromHours(24));
}

return discovery;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AuthorizationServerOptions> AuthenticationServerOptions { get; private init; }
private readonly Endpoint DISCOVERY_ENDPOINT = new Endpoint("/.well-known/openid-configuration", Method.Get);

public ClientCredentialsBaseClient(IRequestSender sender, IOptions<AuthorizationServerOptions> server) : base(sender, server.Value.Authority)
{
AuthenticationServerOptions = server ?? throw new ArgumentNullException(nameof(server));
}

protected override async Task<IRequestBuilder> 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<TokenEndpointResponse> GetToken(DiscoveryDocument? discovery)
{
HttpRequestMessage request = new RequestBuilder(discovery.TokenEndpoint, "", Method.Post)

Check warning on line 30 in NotoriousClient/Clients/Authentication/ClientCredentialsBaseClient.cs

View workflow job for this annotation

GitHub Actions / publish

Dereference of a possibly null reference.

Check warning on line 30 in NotoriousClient/Clients/Authentication/ClientCredentialsBaseClient.cs

View workflow job for this annotation

GitHub Actions / publish

Dereference of a possibly null reference.
.WithContentBody(new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "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<TokenEndpointResponse>();
}

protected virtual async Task<DiscoveryDocument?> GetDiscoveryDocument()
{
HttpResponseMessage discoResponse = await Sender.SendAsync(GetBuilder(DISCOVERY_ENDPOINT).Build());
discoResponse.EnsureSuccessStatusCode();

return await discoResponse.ReadAsAsync<DiscoveryDocument>();
}
}
}
10 changes: 10 additions & 0 deletions NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs
Original file line number Diff line number Diff line change
@@ -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; }

Check warning on line 8 in NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'TokenEndpoint' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 8 in NotoriousClient/Clients/Authentication/Models/DiscoveryDocument.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'TokenEndpoint' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
}
}
Original file line number Diff line number Diff line change
@@ -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;
}

}
31 changes: 31 additions & 0 deletions NotoriousClient/Clients/AuthorizationServerOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace NotoriousClient.Clients
{
public class AuthorizationServerOptions
{
/// <summary>
/// Gets or sets the unique identifier for the client.
/// </summary>
public string ClientId { get; set; }

Check warning on line 8 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'ClientId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 8 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'ClientId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// Gets or sets the client secret used for authentication.
/// </summary>
public string ClientSecret { get; set; }

Check warning on line 13 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'ClientSecret' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 13 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'ClientSecret' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// Gets or sets the authority information for the current context.
/// </summary>
public string Authority { get; set; }

Check warning on line 18 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Authority' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 18 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Authority' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// Gets or sets the list of audiences that are allowed to access the resource.
/// </summary>
public string[] Audiences { get; set; }

Check warning on line 23 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Audiences' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 23 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Audiences' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// Gets or sets the scopes requested during the authorization process.
/// </summary>
public string[] Scopes { get; set; }

Check warning on line 28 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Scopes' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check warning on line 28 in NotoriousClient/Clients/AuthorizationServerOptions.cs

View workflow job for this annotation

GitHub Actions / publish

Non-nullable property 'Scopes' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

}
}
3 changes: 2 additions & 1 deletion NotoriousClient/NotoriousClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

<ItemGroup>
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading