diff --git a/Examples.md b/Examples.md index 6f43f82e5..b034c32d1 100644 --- a/Examples.md +++ b/Examples.md @@ -13,6 +13,9 @@ - [3.4. Challenge an already enrolled user](#34-challenge-an-already-enrolled-user) - [3.5. Get the list of Authenticators for a user](#35-get-the-list-of-authenticators-for-a-user) - [3.6. Delete an enrolled authenticator](#36-delete-an-enrolled-authenticator) +- [4. Monitor Client / Organization Quota when authenticating using Client Credentials (M2M)](#4-client-organization-quota-when-authenticating-using-client-credentials-m2m) + - [4.1. Monitor quota when authenticating using Client Credentials (M2M)](#41-monitor-quota-when-authenticating-using-client-credentials-m2m) + - [4.1 Rate limit exception when quota is breached while authenticating using Client Credentials (M2M)](#42-rate-limit-exception-when-quota-is-breached-while-authenticating-using-client-credentials-m2m) ## 1. Client Initialization @@ -173,6 +176,83 @@ await authClient.DeleteMfaAuthenticatorAsync( ``` ⬆️ [Go to Top](#) +## 4. Client / Organization Quota when authenticating using Client Credentials (M2M) +Assuming the Client and Organization Quota is configured using one of the options as shown [here](#2-update-m2m-token-quota-at-different-levels). + +### 4.1 Monitor quota when authenticating using Client Credentials (M2M) +You can monitor the usage on every authentication request like below +```csharp +public async Task LoginWithClientCredentialsAndMonitorClientQuota() +{ + var authClient = new AuthenticationApiClient("my.custom.domain"); + + // Fetch the access token using the Client Credentials. + var accessTokenResponse = await authClient.GetTokenAsync(new ClientCredentialsTokenRequest() + { + Audience = "audience", + ClientId = "clientId", + ClientSecret = "clientSecret", + }); + + Console.WriteLine($"Access Token : {accessTokenResponse.AccessToken}"); + + var clientQuota = accessTokenResponse.Headers.GetClientQuotaLimit(); + Console.WriteLine($"Client Quota remaining in the hour bucket : {clientQuota.PerHour.Remaining }"); + Console.WriteLine($"Client Quota configured in the hour bucket : {clientQuota.PerHour.Quota }"); + Console.WriteLine($"Client Quota for the hour bucket is refreshed after (in secs): {clientQuota.PerHour.ResetAfter }"); + + Console.WriteLine($"Client Quota remaining in the Day bucket : {clientQuota.PerDay.Remaining }"); + Console.WriteLine($"Client Quota configured in the Day bucket : {clientQuota.PerDay.Quota }"); + Console.WriteLine($"Client Quota for the Day bucket is refreshed after (in secs): {clientQuota.PerDay.ResetAfter }"); + + var orgQuota = accessTokenResponse.Headers.GetOrganizationQuotaLimit(); + Console.WriteLine($"Organization Quota remaining in the hour bucket : {orgQuota.PerHour.Remaining }"); + Console.WriteLine($"Organization Quota configured in the hour bucket : {orgQuota.PerHour.Quota }"); + Console.WriteLine($"Organization Quota for the hour bucket is refreshed after (in secs): {orgQuota.PerHour.ResetAfter }"); + + Console.WriteLine($"Organization Quota remaining in the Day bucket : {orgQuota.PerDay.Remaining }"); + Console.WriteLine($"Organization Quota configured in the Day bucket : {orgQuota.PerDay.Quota }"); + Console.WriteLine($"Organization Quota for the Day bucket is refreshed after (in secs): {orgQuota.PerDay.ResetAfter }"); +} +``` +⬆️ [Go to Top](#) + +### 4.2 Rate limit exception when quota is breached while authenticating using Client Credentials (M2M) +When the token quota is breached (either at client level OR at org level), the server returns a 429 with some extra headers that we can use to extract details of the failure. +Below is an example where there is a client quota breach. +```csharp +public async Task LoginWithClientCredentialsAndMonitorClientQuota() +{ + var authClient = new AuthenticationApiClient("my.custom.domain"); + + try + { + // Fetch the access token using the Client Credentials. + var accessTokenResponse = await authClient.GetTokenAsync(new ClientCredentialsTokenRequest() + { + Audience = "audience", + ClientId = "clientId", + ClientSecret = "clientSecret", + }); + Console.WriteLine($"Access Token : {accessTokenResponse.AccessToken}"); + } + catch (RateLimitApiException ex) + { + Console.WriteLine($"Error message : {ex.ApiError.Message}"); + Console.WriteLine($"x-RateLimit-Limit : {ex.RateLimit.Limit}") + Console.WriteLine($"x-RateLimit-Remaining : {ex.RateLimit.Remaining}") + Console.WriteLine($"x-RateLimit-Reset : {ex.RateLimit.Reset}") + Console.WriteLine($"Retry-After : {ex.RateLimit.RetryAfter}") + Console.WriteLine($"Time to reset the breached client quota: {ex.RateLimit.ClientQuotaLimit.PerHour.ResetAfter}"); + } + catch (Exception ex) + { + Console.WriteLine("An exception occurred"); + } +} +``` +⬆️ [Go to Top](#) + # Management API - [1. Management Client Initialization](#1-management-client-initialization) diff --git a/src/Auth0.AuthenticationApi/ExtensionMethods.cs b/src/Auth0.AuthenticationApi/ExtensionMethods.cs index 38e730f96..567c44460 100644 --- a/src/Auth0.AuthenticationApi/ExtensionMethods.cs +++ b/src/Auth0.AuthenticationApi/ExtensionMethods.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Linq; using Auth0.AuthenticationApi.Models; +using Auth0.Core.Exceptions; namespace Auth0.AuthenticationApi { @@ -21,13 +23,14 @@ public static void AddIfNotEmpty(this IDictionary dictionary, st if (!string.IsNullOrEmpty(value)) dictionary.Add(key, value); } - + /// /// Adds all items from the source to the target dictionary. /// /// Dictionary to add the items to. /// Dictionary whose items you want to add to the target. - public static void AddAll(this IDictionary targetDictionary, IDictionary sourceDictionary) + public static void AddAll(this IDictionary targetDictionary, + IDictionary sourceDictionary) { foreach (var keyValuePair in sourceDictionary) { @@ -41,7 +44,7 @@ public static void AddAll(this IDictionary targetDictionary, IDi } } } - + /// /// Get the string value for the corresponding . /// @@ -56,7 +59,7 @@ public static string ToStringValue(this AuthorizationResponseMode responseMode) return null; } - + /// /// Get the string value for the corresponding . /// diff --git a/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs b/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs index 775d14066..e4cc17b94 100644 --- a/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs +++ b/src/Auth0.AuthenticationApi/HttpClientAuthenticationConnection.cs @@ -9,6 +9,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using Auth0.AuthenticationApi.Models; namespace Auth0.AuthenticationApi { @@ -74,7 +75,7 @@ public async Task SendAsync(HttpMethod method, Uri uri, object body, IDict return await SendRequest(request, cancellationToken).ConfigureAwait(false); } } - + private async Task SendRequest(HttpRequestMessage request, CancellationToken cancellationToken = default) { if (!ownHttpClient) @@ -86,8 +87,11 @@ private async Task SendRequest(HttpRequestMessage request, CancellationTok throw await ApiException.CreateSpecificExceptionAsync(response).ConfigureAwait(false); var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - return DeserializeContent(content); + + var parsedResponse = DeserializeContent(content); + + AddResponseHeaders(parsedResponse, response); + return parsedResponse; } } @@ -128,6 +132,19 @@ private static HttpContent CreateFormUrlEncodedContent(IDictionary new KeyValuePair(p.Key, p.Value ?? ""))); } + + internal static void AddResponseHeaders(T parsedResponse, HttpResponseMessage httpResponse) + { + if (parsedResponse == null || httpResponse == null) return; + + var headers = httpResponse.Headers?.ToDictionary(h => h.Key, h => h.Value); + var headersProperty = typeof(T).GetProperty("Headers"); + + if (headersProperty != null && headersProperty.PropertyType == typeof(IDictionary>)) + { + headersProperty.SetValue(parsedResponse, headers); + } + } private static string CreateAgentString() { diff --git a/src/Auth0.AuthenticationApi/Models/AccessTokenResponse.cs b/src/Auth0.AuthenticationApi/Models/AccessTokenResponse.cs index cf62cfe1a..bf0df0b97 100644 --- a/src/Auth0.AuthenticationApi/Models/AccessTokenResponse.cs +++ b/src/Auth0.AuthenticationApi/Models/AccessTokenResponse.cs @@ -1,3 +1,7 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http.Headers; +using Auth0.AuthenticationApi.Models.Mfa; using Newtonsoft.Json; namespace Auth0.AuthenticationApi.Models @@ -24,5 +28,7 @@ public class AccessTokenResponse : TokenBase /// [JsonProperty("refresh_token")] public string RefreshToken { get; set; } + + public IDictionary> Headers { get; set; } } } \ No newline at end of file diff --git a/src/Auth0.Core/Exceptions/QuotaLimit.cs b/src/Auth0.Core/Exceptions/QuotaLimit.cs new file mode 100644 index 000000000..d0c5ca7a2 --- /dev/null +++ b/src/Auth0.Core/Exceptions/QuotaLimit.cs @@ -0,0 +1,41 @@ +namespace Auth0.Core.Exceptions +{ + /// + /// Represents the Client Quota Headers returned as part of the response. + /// + public class ClientQuotaLimit + { + public QuotaLimit PerHour { get; set; } + public QuotaLimit PerDay { get; set; } + } + + /// + /// Represents the Organization Quota Headers returned as part of the response. + /// + public class OrganizationQuotaLimit + { + public QuotaLimit PerHour { get; set; } + public QuotaLimit PerDay { get; set; } + } + + /// + /// Represents the structure of the quota limit headers returned as part of the response. + /// + public class QuotaLimit + { + /// + /// The current configured quota + /// + public int Quota { get; set; } + + /// + /// The remaining quota + /// + public int Remaining { get; set; } + + /// + /// Remaining number of seconds when the quota resets. + /// + public int ResetAfter { get; set; } + } +} \ No newline at end of file diff --git a/src/Auth0.Core/Exceptions/RateLimit.cs b/src/Auth0.Core/Exceptions/RateLimit.cs index 3167b051e..c113893a0 100644 --- a/src/Auth0.Core/Exceptions/RateLimit.cs +++ b/src/Auth0.Core/Exceptions/RateLimit.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net.Http.Headers; @@ -20,12 +21,27 @@ public class RateLimit /// The number of requests remaining in the current rate limit window. /// public long Remaining { get; internal set; } + + /// + /// Indicates how long the user should wait before making a follow-up request + /// + public long RetryAfter { get; internal set; } /// /// The date and time offset at which the current rate limit window is reset. /// public DateTimeOffset? Reset { get; internal set; } + /// + /// Represents Client Quota Headers returned. + /// + public ClientQuotaLimit ClientQuotaLimit { get; internal set; } + + /// + /// Represents Client Quota Headers returned. + /// + public OrganizationQuotaLimit OrganizationQuotaLimit { get; internal set; } + /// /// Parse the rate limit headers into a object. /// @@ -33,18 +49,23 @@ public class RateLimit /// Instance of containing parsed rate limit headers. public static RateLimit Parse(HttpHeaders headers) { - var reset = GetHeaderValue(headers, "x-ratelimit-reset"); + var headersKvp = + headers?.ToDictionary(h => h.Key, h => h.Value); + var reset = GetHeaderValue(headersKvp, "x-ratelimit-reset"); return new RateLimit { - Limit = GetHeaderValue(headers, "x-ratelimit-limit"), - Remaining = GetHeaderValue(headers, "x-ratelimit-remaining"), - Reset = reset == 0 ? null : (DateTimeOffset?)epoch.AddSeconds(reset) + Limit = GetHeaderValue(headersKvp, "x-ratelimit-limit"), + Remaining = GetHeaderValue(headersKvp, "x-ratelimit-remaining"), + Reset = reset == 0 ? null : (DateTimeOffset?)epoch.AddSeconds(reset), + RetryAfter = GetHeaderValue(headersKvp, "Retry-After"), + ClientQuotaLimit = headersKvp.GetClientQuotaLimit(), + OrganizationQuotaLimit = headersKvp.GetOrganizationQuotaLimit() }; } - private static long GetHeaderValue(HttpHeaders headers, string name) + private static long GetHeaderValue(IDictionary> headers, string name) { - if (headers.TryGetValues(name, out var v) && long.TryParse(v?.FirstOrDefault(), out var value)) + if (headers.TryGetValue(name, out var v) && long.TryParse(v?.FirstOrDefault(), out var value)) return value; return 0; diff --git a/src/Auth0.Core/Extensions.cs b/src/Auth0.Core/Extensions.cs new file mode 100644 index 000000000..09a4accf8 --- /dev/null +++ b/src/Auth0.Core/Extensions.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Linq; + +using Auth0.Core.Exceptions; + +namespace Auth0.Core +{ + public static class Extensions + { + /// + /// Extracts the from the response headers. + /// + /// The source response headers + /// + public static ClientQuotaLimit GetClientQuotaLimit(this IDictionary> headers) + { + return ParseClientLimit(GetRawHeaders(headers, "Auth0-Client-Quota-Limit")); + } + + /// + /// Extracts the from the response headers + /// + /// The source response headers + /// + public static OrganizationQuotaLimit GetOrganizationQuotaLimit( + this IDictionary> headers) + { + return ParseOrganizationLimit(GetRawHeaders(headers, "Auth0-Organization-Quota-Limit")); + } + + internal static string GetRawHeaders(IDictionary> headers, string headerName) + { + if (headers == null) + { + return null; + } + return !headers.TryGetValue(headerName, out var values) ? null : values.FirstOrDefault(); + } + + internal static ClientQuotaLimit ParseClientLimit(string headerValue) + { + if (string.IsNullOrEmpty(headerValue)) + { + return null; + } + var buckets = headerValue.Split(','); + var quotaClientLimit = new ClientQuotaLimit(); + foreach (var eachBucket in buckets) + { + var quotaLimit = ParseQuotaLimit(eachBucket, out var bucket); + if (bucket == "per_hour") + { + quotaClientLimit.PerHour = quotaLimit; + } + else + { + quotaClientLimit.PerDay = quotaLimit; + } + } + + return quotaClientLimit; + } + + internal static OrganizationQuotaLimit ParseOrganizationLimit(string headerValue) + { + if (string.IsNullOrEmpty(headerValue)) + { + return null; + } + + var buckets = headerValue.Split(','); + var quotaOrganizationLimit = new OrganizationQuotaLimit(); + foreach (var eachBucket in buckets) + { + var quotaLimit = ParseQuotaLimit(eachBucket, out var bucket); + if (bucket == "per_hour") + { + quotaOrganizationLimit.PerHour = quotaLimit; + continue; + } + + quotaOrganizationLimit.PerDay = quotaLimit; + } + + return quotaOrganizationLimit; + } + + internal static QuotaLimit ParseQuotaLimit(string headerValue, out string bucket) + { + bucket = null; + + if (string.IsNullOrEmpty(headerValue)) + return null; + + var kvp = headerValue + .Split(';') + .Select(x => x.Split('=')) + .ToDictionary(keyValue => keyValue[0], keyValue => keyValue[1]); + bucket = kvp["b"]; + return new QuotaLimit + { + Quota = int.Parse(kvp["q"]), + Remaining = int.Parse(kvp["r"]), + ResetAfter = int.Parse(kvp["t"]) + }; + } + } +} \ No newline at end of file diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/ExtensionMethodsTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/ExtensionMethodsTests.cs index c944e6522..9adf95706 100644 --- a/tests/Auth0.AuthenticationApi.IntegrationTests/ExtensionMethodsTests.cs +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/ExtensionMethodsTests.cs @@ -5,6 +5,8 @@ using Xunit; using Auth0.AuthenticationApi.Models.Mfa; +using Auth0.Core; +using Auth0.Core.Exceptions; using Auth0.Tests.Shared; namespace Auth0.AuthenticationApi.IntegrationTests @@ -17,7 +19,7 @@ public void ThrowIfNull_Should_Throw_For_Null_Input() MfaOtpTokenRequest request = null; Assert.Throws(request.ThrowIfNull); } - + [Fact] public void ThrowIfNull_Should_Not_Throw_For_Non_Null_Input() { @@ -25,7 +27,7 @@ public void ThrowIfNull_Should_Not_Throw_For_Non_Null_Input() var ex = Record.Exception(() => request.ThrowIfNull()); ex.Should().BeNull(); } - + [Fact] public void AddIfNotEmpty_Should_Add_Non_Empty_Value() { @@ -34,7 +36,7 @@ public void AddIfNotEmpty_Should_Add_Non_Empty_Value() dictionary.Should().ContainKey("key"); dictionary["key"].Should().Be("value"); } - + [Fact] public void AddIfNotEmpty_Should_Not_Add_Empty_Value() { @@ -42,12 +44,12 @@ public void AddIfNotEmpty_Should_Not_Add_Empty_Value() // Empty Value dictionary.AddIfNotEmpty("key", ""); dictionary.Should().NotContainKey("key"); - + // Null Value dictionary.AddIfNotEmpty("key", ""); dictionary.Should().NotContainKey("key"); } - + [Fact] public void AddAll_Should_Add_All_Items() { @@ -57,10 +59,10 @@ public void AddAll_Should_Add_All_Items() }; var sourceDictionary = new Dictionary { - {"key1", "value1"}, - {"key2", "value2"} + { "key1", "value1" }, + { "key2", "value2" } }; - + targetDictionary.AddAll(sourceDictionary); targetDictionary.Should().ContainKey("key1"); diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/HttpClientAuthenticationConnectionTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/HttpClientAuthenticationConnectionTests.cs index f112d7c6b..4ba3b3b73 100644 --- a/tests/Auth0.AuthenticationApi.IntegrationTests/HttpClientAuthenticationConnectionTests.cs +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/HttpClientAuthenticationConnectionTests.cs @@ -1,7 +1,11 @@ using Auth0.Tests.Shared; using System; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading.Tasks; +using Auth0.AuthenticationApi.Models; +using Auth0.AuthenticationApi.Models.Ciba; +using FluentAssertions; using Xunit; namespace Auth0.AuthenticationApi.IntegrationTests @@ -24,5 +28,69 @@ public async Task Does_not_dispose_HttpClient_it_was_given_on_dispose() apiConnection.Dispose(); await httpClient.GetAsync(new Uri("https://www.auth0.com")); } + + [Fact] + public void AddResponseHeaders_Should_Set_Headers_When_Parsed_Response_Has_Headers() + { + // Arrange + var parsedResponse = new AccessTokenResponse(); + var httpResponse = new HttpResponseMessage + { + Headers = { { "Test-Header", ["Value1", "Value2"] } } + }; + + // Act + HttpClientAuthenticationConnection.AddResponseHeaders(parsedResponse, httpResponse); + + // Assert + Assert.NotNull(parsedResponse.Headers); + Assert.True(parsedResponse.Headers.ContainsKey("Test-Header")); + Assert.Equal(["Value1", "Value2"], parsedResponse.Headers["Test-Header"]); + } + + [Fact] + public void AddResponseHeaders_Should_Not_Throw_When_Parsed_Response_Is_Null() + { + // Arrange + var httpResponse = new HttpResponseMessage(); + + // Act & Assert + HttpClientAuthenticationConnection.AddResponseHeaders(null, httpResponse); + } + + [Fact] + public void AddResponseHeaders_Should_Not_Throw_When_HttpResponse_Is_Null() + { + // Arrange + var parsedResponse = new AccessTokenResponse(); + + // Act & Assert + HttpClientAuthenticationConnection.AddResponseHeaders(parsedResponse, null); + } + + [Fact] + public void AddResponseHeaders_Should_Not_Set_Headers_When_Parsed_Response_Does_Not_Have_Headers() + { + // Arrange + var parsedResponse = new AccessTokenResponse(); + var httpResponse = new HttpResponseMessage(); + + // Act + HttpClientAuthenticationConnection.AddResponseHeaders(parsedResponse, httpResponse); + + // Assert + // No exception should be thrown, and no headers should be set + parsedResponse.Headers.Should().BeEmpty(); + } + + [Fact] + public void AddResponseHeaders_Should_Not_Throw_When_Response_Has_No_Headers_Property() + { + // Arrange + var parsedResponse = new ClientInitiatedBackchannelAuthorizationResponse(); + + // Act & Assert + HttpClientAuthenticationConnection.AddResponseHeaders(parsedResponse, null); + } } } diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/TokenTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/TokenTests.cs index 8b007dd9a..06c5271d3 100644 --- a/tests/Auth0.AuthenticationApi.IntegrationTests/TokenTests.cs +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/TokenTests.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; +using Auth0.Core; using Xunit; namespace Auth0.AuthenticationApi.IntegrationTests @@ -182,5 +183,28 @@ await authenticationApiClient.RevokeRefreshTokenAsync(new RevokeRefreshTokenRequ }); } } + + [Fact] + public async Task Can_get_response_headers_using_client_credentials() + { + using var authenticationApiClient = + new AuthenticationApiClient(GetVariable("AUTH0_AUTHENTICATION_API_URL")); + + // Get the access token + var token = await authenticationApiClient.GetTokenAsync(new ClientCredentialsTokenRequest + { + ClientId = GetVariable("AUTH0_CLIENT_ID"), + ClientSecret = GetVariable("AUTH0_CLIENT_SECRET"), + Audience = GetVariable("AUTH0_MANAGEMENT_API_AUDIENCE"), + }); + + // Ensure that we received an access token back + token.Should().NotBeNull(); + + token.Headers.Should().NotBeNull(); + + var clientQuota = token.Headers.GetClientQuotaLimit(); + clientQuota.Should().NotBeNull(); + } } -} +} \ No newline at end of file diff --git a/tests/Auth0.Core.UnitTests/ExtensionTests.cs b/tests/Auth0.Core.UnitTests/ExtensionTests.cs new file mode 100644 index 000000000..5e6dd8e30 --- /dev/null +++ b/tests/Auth0.Core.UnitTests/ExtensionTests.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; + +using FluentAssertions; +using Xunit; + +namespace Auth0.Core.UnitTests +{ + public class ExtensionTests + { + [Theory] + [InlineData("b=per_hour;q=2;r=1;t=3452", "per_hour", 2, 1, 3452)] + [InlineData("b=per_day;q=100;r=99;t=3524", "per_day", 100, 99, 3524)] + public async void ParseQuotaLimit_Parses_Successfully_For_Valid_Values( + string input, string bucket, int q, int r, int t) + { + var quotaLimit = Extensions.ParseQuotaLimit(input, out string actualBucket); + + quotaLimit.Should().NotBeNull(); + quotaLimit.Quota.Should().Be(q); + quotaLimit.Remaining.Should().Be(r); + quotaLimit.ResetAfter.Should().Be(t); + actualBucket.Should().Be(bucket); + } + + [Fact] + public async void ParseQuotaLimit_Should_Return_NULL_For_NULL_Inupt() + { + var quotaLimit = Extensions.ParseQuotaLimit(null, out string actualBucket); + quotaLimit.Should().BeNull(); + } + + [Theory] + [InlineData("b=per_hour;q=2;r=1;t=924", "per_hour", 2, 1, 924)] + [InlineData("b=per_day;q=2;r=1;t=924", "per_day", 2, 1, 924)] + public async void ParseClientQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( + string input, string bucket, int q, int r, int t) + { + var clientLimit = Extensions.ParseClientLimit(input); + + if (bucket == "per_hour") + { + clientLimit.Should().NotBeNull(); + clientLimit.PerHour.Quota.Should().Be(q); + clientLimit.PerHour.Remaining.Should().Be(r); + clientLimit.PerHour.ResetAfter.Should().Be(t); + + clientLimit.PerDay.Should().BeNull(); + } + else if(bucket == "per_day") + { + clientLimit.Should().NotBeNull(); + clientLimit.PerDay.Quota.Should().Be(q); + clientLimit.PerDay.Remaining.Should().Be(r); + clientLimit.PerDay.ResetAfter.Should().Be(t); + + clientLimit.PerHour.Should().BeNull(); + } + } + + [Fact] + public async void ParseClientQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() + { + var headerValue = "b=per_hour;q=10;r=9;t=924,b=per_day;q=100;r=99;t=924"; + var clientQuota = Extensions.ParseClientLimit(headerValue); + + clientQuota.PerDay.Quota.Should().Be(100); + clientQuota.PerDay.Remaining.Should().Be(99); + clientQuota.PerDay.ResetAfter.Should().Be(924); + + clientQuota.PerHour.Quota.Should().Be(10); + clientQuota.PerHour.Remaining.Should().Be(9); + clientQuota.PerHour.ResetAfter.Should().Be(924); + } + + [Theory] + [InlineData("b=per_hour;q=2;r=1;t=924", "per_hour", 2, 1, 924)] + [InlineData("b=per_day;q=2;r=1;t=924", "per_day", 2, 1, 924)] + public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Either_Value_Is_Missing( + string input, string bucket, int q, int r, int t) + { + var organizationLimit = Extensions.ParseOrganizationLimit(input); + + if (bucket == "per_hour") + { + organizationLimit.Should().NotBeNull(); + organizationLimit.PerHour.Quota.Should().Be(q); + organizationLimit.PerHour.Remaining.Should().Be(r); + organizationLimit.PerHour.ResetAfter.Should().Be(t); + + organizationLimit.PerDay.Should().BeNull(); + } + else if(bucket == "per_day") + { + organizationLimit.Should().NotBeNull(); + organizationLimit.PerDay.Quota.Should().Be(q); + organizationLimit.PerDay.Remaining.Should().Be(r); + organizationLimit.PerDay.ResetAfter.Should().Be(t); + + organizationLimit.PerHour.Should().BeNull(); + } + } + + [Fact] + public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Both_Values_Are_Present_And_Valid() + { + var headerValue = "b=per_hour;q=10;r=9;t=924,b=per_day;q=100;r=99;t=924"; + var organisationQuota = Extensions.ParseOrganizationLimit(headerValue); + + organisationQuota.PerDay.Quota.Should().Be(100); + organisationQuota.PerDay.Remaining.Should().Be(99); + organisationQuota.PerDay.ResetAfter.Should().Be(924); + + organisationQuota.PerHour.Quota.Should().Be(10); + organisationQuota.PerHour.Remaining.Should().Be(9); + organisationQuota.PerHour.ResetAfter.Should().Be(924); + } + + [Fact] + public async void ParseOrganisationQuotaLimit_Parses_Successfully_When_Header_Is_NULL() + { + var organisationQuota = Extensions.ParseOrganizationLimit(null); + organisationQuota.Should().BeNull(); + } + + [Fact] + public async void ParseClientQuotaLimit_Parses_Successfully_When_Header_Is_NULL() + { + var clientQuota = Extensions.ParseClientLimit(null); + clientQuota.Should().BeNull(); + } + + [Fact] + public async void GetRawHeaders_Returns_Valid_Headers() + { + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] }, + { "Authorization", ["Bearer dummy_access_token"] }, + { "X-RateLimit-Limit", ["1000"] }, + { "X-RateLimit-Remaining", ["500"] }, + { "X-RateLimit-Reset", ["1633036800"] }, + { "Auth0-Client-Quota-Limit", ["b=per_hour;q=2;r=1;t=924"] }, + { "Auth0-Organization-Quota-Limit", ["b=per_hour;q=2;r=1;t=924"] } + }; + var rawHeaders = Extensions.GetRawHeaders(headers, "Auth0-Client-Quota-Limit"); + rawHeaders.Should().Be("b=per_hour;q=2;r=1;t=924"); + } + + [Fact] + public async void GetRawHeaders_Returns_NULL_When_Headers_Is_NULL() + { + var rawHeaders = Extensions.GetRawHeaders(null, "Auth0-Client-Quota-Limit"); + rawHeaders.Should().BeNull(); + } + + [Fact] + public async void GetClientQuotaLimit_Returns_Valid_Quota() + { + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] }, + { "Authorization", ["Bearer dummy_access_token"] }, + { "X-RateLimit-Limit", ["1000"] }, + { "X-RateLimit-Remaining", ["500"] }, + { "X-RateLimit-Reset", ["1633036800"] }, + { "Auth0-Client-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] }, + { "Auth0-Organization-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] } + }; + var clientQuotaLimit = headers.GetClientQuotaLimit(); + + clientQuotaLimit.Should().NotBeNull(); + clientQuotaLimit.PerDay.Should().NotBeNull(); + clientQuotaLimit.PerHour.Should().NotBeNull(); + + clientQuotaLimit.PerDay.Quota.Should().Be(20); + clientQuotaLimit.PerDay.Remaining.Should().Be(10); + clientQuotaLimit.PerDay.ResetAfter.Should().Be(924); + + clientQuotaLimit.PerHour.Quota.Should().Be(2); + clientQuotaLimit.PerHour.Remaining.Should().Be(1); + clientQuotaLimit.PerHour.ResetAfter.Should().Be(924); + } + + [Fact] + public async void GetOrganizationQuotaLimit_Returns_Valid_Quota() + { + var headers = new Dictionary> + { + { "Content-Type", ["application/json"] }, + { "Authorization", ["Bearer dummy_access_token"] }, + { "X-RateLimit-Limit", ["1000"] }, + { "X-RateLimit-Remaining", ["500"] }, + { "X-RateLimit-Reset", ["1633036800"] }, + { "Auth0-Client-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] }, + { "Auth0-Organization-Quota-Limit", ["b=per_hour;q=2;r=1;t=924,b=per_day;q=20;r=10;t=924"] } + }; + var organizationQuotaLimit = headers.GetOrganizationQuotaLimit(); + + organizationQuotaLimit.Should().NotBeNull(); + organizationQuotaLimit.PerDay.Should().NotBeNull(); + organizationQuotaLimit.PerHour.Should().NotBeNull(); + + organizationQuotaLimit.PerDay.Quota.Should().Be(20); + organizationQuotaLimit.PerDay.Remaining.Should().Be(10); + organizationQuotaLimit.PerDay.ResetAfter.Should().Be(924); + + organizationQuotaLimit.PerHour.Quota.Should().Be(2); + organizationQuotaLimit.PerHour.Remaining.Should().Be(1); + organizationQuotaLimit.PerHour.ResetAfter.Should().Be(924); + } + } +} \ No newline at end of file diff --git a/tests/Auth0.Core.UnitTests/RateLimitApiExceptionTests.cs b/tests/Auth0.Core.UnitTests/RateLimitApiExceptionTests.cs index fe884a91d..ec027479f 100644 --- a/tests/Auth0.Core.UnitTests/RateLimitApiExceptionTests.cs +++ b/tests/Auth0.Core.UnitTests/RateLimitApiExceptionTests.cs @@ -30,12 +30,28 @@ public void Should_deserialize_rate_limit() var response = CreateResponseMessage(string.Empty); var actual = RateLimitApiException.CreateAsync(response).GetAwaiter().GetResult(); - + actual.RateLimit.Should().BeEquivalentTo(new RateLimit { Limit = 10, Remaining = 5, - Reset = new DateTimeOffset(2020, 3, 31, 22, 38, 58, TimeSpan.Zero) + Reset = new DateTimeOffset(2020, 3, 31, 22, 38, 58, TimeSpan.Zero), + RetryAfter = 22374, + ClientQuotaLimit = new ClientQuotaLimit() + { + PerDay = new QuotaLimit() + { + Quota = 100, + Remaining = 99, + ResetAfter = 22374 + }, + PerHour = new QuotaLimit() + { + Quota = 10, + Remaining = 9, + ResetAfter = 774 + } + } }); } @@ -50,7 +66,9 @@ private static HttpResponseMessage CreateResponseMessage(string json) responseMessage.Headers.Add("x-ratelimit-limit", "10"); responseMessage.Headers.Add("x-ratelimit-remaining", "5"); responseMessage.Headers.Add("x-ratelimit-reset", "1585694338"); - + responseMessage.Headers.Add("Auth0-Client-Quota-Limit","b=per_hour;q=10;r=9;t=774,b=per_day;q=100;r=99;t=22374"); + responseMessage.Headers.Add("X-Quota-Organisation-Limit","b=per_hour;q=10;r=9;t=774,b=per_day;q=100;r=99;t=22374"); + responseMessage.Headers.Add("Retry-After","22374"); return responseMessage; } } diff --git a/tests/Auth0.Core.UnitTests/RateLimitDeserializationTests.cs b/tests/Auth0.Core.UnitTests/RateLimitDeserializationTests.cs index 429c2cdeb..7fe1278e1 100644 --- a/tests/Auth0.Core.UnitTests/RateLimitDeserializationTests.cs +++ b/tests/Auth0.Core.UnitTests/RateLimitDeserializationTests.cs @@ -23,7 +23,8 @@ public void Should_deserialize_all_rate_limit_headers_correctly(HttpHeaders cont public class RateLimitDeserializationData : IEnumerable { - private static HttpHeaders CreateHeaders(int? limit, int? remaining, long? reset) + private static HttpHeaders CreateHeaders( + int? limit, int? remaining, long? reset,long? retryAfter = null, string clientQuota = null, string orgQuota = null) { var client = new HttpRequestMessage(HttpMethod.Get, "https://fake"); if (limit != null) @@ -32,34 +33,103 @@ private static HttpHeaders CreateHeaders(int? limit, int? remaining, long? reset client.Headers.Add("x-ratelimit-remaining", remaining.ToString()); if (reset != null) client.Headers.Add("x-ratelimit-reset", reset.ToString()); + if (retryAfter != null) + client.Headers.Add("Retry-After", retryAfter.ToString()); + if (clientQuota != null) + client.Headers.Add("Auth0-Client-Quota-Limit", clientQuota); + if (orgQuota != null) + client.Headers.Add("Auth0-Organization-Quota-Limit", orgQuota); return client.Headers; } public IEnumerator GetEnumerator() { - yield return new object[] { CreateHeaders(100, 10, 1585694338), + yield return new object[] + { + CreateHeaders(100, 10, 1585694338), new RateLimit { Limit = 100, Remaining = 10, Reset = new DateTimeOffset(2020, 3, 31, 22, 38, 58, TimeSpan.Zero) - } }; + } + }; - yield return new object[] { CreateHeaders(5, 100, 1585694338), + yield return new object[] + { + CreateHeaders(5, 100, 1585694338), new RateLimit { Limit = 5, Remaining = 100, Reset = new DateTimeOffset(2020, 3, 31, 22, 38, 58, TimeSpan.Zero) - } }; + } + }; - yield return new object[] { CreateHeaders(null, 10, null), + yield return new object[] + { + CreateHeaders(null, 10, null), new RateLimit { Limit = 0, Remaining = 10, Reset = null - } }; + } + }; + yield return new object[] + { + CreateHeaders(null, 10, null, 34567, + "b=per_hour;q=10;r=9;t=774,b=per_day;q=100;r=99;t=22374", null), + new RateLimit + { + Limit = 0, + Remaining = 10, + Reset = null, + RetryAfter = 34567, + ClientQuotaLimit = new ClientQuotaLimit() + { + PerHour = new QuotaLimit + { + Quota = 10, + Remaining = 9, + ResetAfter = 774 + }, + PerDay = new QuotaLimit + { + Quota = 100, + Remaining = 99, + ResetAfter = 22374 + } + } + } + }; + yield return new object[] + { + CreateHeaders(null, 10, null, + 45678,null, "b=per_hour;q=10;r=9;t=774,b=per_day;q=100;r=99;t=22374"), + new RateLimit + { + Limit = 0, + Remaining = 10, + Reset = null, + RetryAfter = 45678, + OrganizationQuotaLimit = new OrganizationQuotaLimit() + { + PerHour = new QuotaLimit + { + Quota = 10, + Remaining = 9, + ResetAfter = 774 + }, + PerDay = new QuotaLimit + { + Quota = 100, + Remaining = 99, + ResetAfter = 22374 + } + } + } + }; } IEnumerator IEnumerable.GetEnumerator() @@ -67,4 +137,4 @@ IEnumerator IEnumerable.GetEnumerator() return GetEnumerator(); } } -} +} \ No newline at end of file