Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/Certify.ACME.Anvil/Acme/EntityContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ internal class EntityContext<T>
/// </summary>
public int RetryAfter { get; protected set; }

/// <summary>
/// Gets the next retry time if available from the server.
/// </summary>
public DateTime? NextRetry { get; protected set; }

/// <summary>
/// Initializes a new instance of the <see cref="EntityContext{T}"/> class.
Expand All @@ -51,6 +55,7 @@ public EntityContext(
public virtual async Task<T> Resource()
{
var resp = await Context.HttpClient.Post<T>(Context, Location, null, true);
NextRetry = (resp.RetryAfter <= 0) ? null : DateTime.UtcNow.AddSeconds(resp.RetryAfter);
return resp.Resource;
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/Certify.ACME.Anvil/Acme/IOrderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,13 @@ public interface IOrderContext : IResourceContext<Order>
/// <param name="preferredChain">The preferred Root Certificate.</param>
/// <returns>The certificate chain in PEM.</returns>
Task<CertificateChain> Download(string preferredChain = null);

/// <summary>
/// Downloads the certificate chain in PEM for the specified order.
/// </summary>
/// <param name="order">The finalized order returned by <see cref="Certify.ACME.Anvil.Acme.IOrderContext.Finalize" /> or <see cref="Certify.ACME.Anvil.IOrderContextExtensions.WaitForCompletionAsync" />.</param>
/// <param name="preferredChain">The preferred Root Certificate.</param>
/// <returns>The certificate chain in PEM.</returns>
Task<CertificateChain> Download(Order order, string preferredChain = null);
}
}
5 changes: 5 additions & 0 deletions src/Certify.ACME.Anvil/Acme/IResourceContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public interface IResourceContext<T>
/// </summary>
int RetryAfter { get; }

/// <summary>
/// Gets the next retry time if available from the server.
/// </summary>
DateTime? NextRetry { get; }

/// <summary>
/// Gets the ACME resource.
/// </summary>
Expand Down
20 changes: 10 additions & 10 deletions src/Certify.ACME.Anvil/Acme/OrderContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public async Task<Order> Finalize(byte[] csr)
var order = await Resource();
var payload = new Order.Payload { Csr = JwsConvert.ToBase64String(csr) };
var resp = await Context.HttpClient.Post<Order>(Context, order.Finalize, payload, true);
NextRetry = (resp.RetryAfter <= 0) ? null : DateTime.UtcNow.AddSeconds(resp.RetryAfter);
return resp.Resource;
}

Expand All @@ -57,19 +58,18 @@ public async Task<Order> Finalize(byte[] csr)
/// <returns>The certificate chain in PEM.</returns>
public async Task<CertificateChain> Download(string preferredChain = null)
{
var order = await Resource();

var retryCount = 5;
var order = await IOrderContextExtensions.WaitForCompletionAsync(this, TimeSpan.FromSeconds(15));

while (order?.Certificate == null && retryCount > 0)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Max(RetryAfter, 3)));
order = await Resource();
return await Download(order, preferredChain);
}

retryCount--;
}
/// <inheritdoc />
public async Task<CertificateChain> Download(Order order, string preferredChain = null)
{
if (order == null)
throw new ArgumentNullException(nameof(order));

if (order?.Certificate == null)
if (order.Certificate == null)
{
throw new AcmeException($"The order [status: {order.Status}] does not have a certificate URL. The CA has failed to complete the ACME Order process.");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System.Linq;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Certify.ACME.Anvil.Acme;
using Certify.ACME.Anvil.Acme.Resource;
Expand Down Expand Up @@ -45,5 +47,31 @@ public static async Task<IChallengeContext> Challenge(this IAuthorizationContext
var challenges = await authorizationContext.Challenges();
return challenges.FirstOrDefault(c => c.Type == type);
}

/// <summary>
/// Waits for the authorization to reach a terminal state (valid).
/// </summary>
/// <param name="context">The authorization context.</param>
/// <param name="timeout">The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying.</param>
/// <param name="cancellationToken">Cancellation token to allow external cancellation of the polling operation.</param>
/// <returns>The authorization validated.</returns>
/// <exception cref="AcmeException">Thrown when the authorization status is not Valid after completion.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeout is negative.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
public static Task<Authorization> WaitForCompletionAsync(this IAuthorizationContext context, TimeSpan timeout, CancellationToken cancellationToken = default)
{
return ResourceContextPollingHelper.WaitForCompletionAsync(
context,
timeout,
cancellationToken,
isTerminalState: authorization => authorization?.Status != AuthorizationStatus.Pending,
validateFinalState: authorization =>
{
if (authorization.Status != AuthorizationStatus.Valid)
{
throw new AcmeException(Properties.Strings.ErrorChallengeValidationFailed);
}
});
}
}
}
59 changes: 49 additions & 10 deletions src/Certify.ACME.Anvil/Extensions/IOrderContextExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Certify.ACME.Anvil.Acme;
using Certify.ACME.Anvil.Acme.Resource;
Expand Down Expand Up @@ -59,18 +60,62 @@ public static async Task<CertificationRequestBuilder> CreateCsr(this IOrderConte
return builder;
}

/// <summary>
/// Waits for the order to reach a terminal state (valid).
/// </summary>
/// <param name="context">The order context.</param>
/// <param name="timeout">The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying.</param>
/// <param name="cancellationToken">Cancellation token to allow external cancellation of the polling operation.</param>
/// <returns>The order validated.</returns>
/// <exception cref="AcmeException">Thrown when the order status is not Valid after completion.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeout is negative.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled.</exception>
public static Task<Order> WaitForCompletionAsync(this IOrderContext context, TimeSpan timeout, CancellationToken cancellationToken = default)
{
return ResourceContextPollingHelper.WaitForCompletionAsync(
context,
timeout,
cancellationToken,
isTerminalState: order => (order?.Status == OrderStatus.Valid && order?.Certificate != null) || order?.Status == OrderStatus.Invalid,
validateFinalState: order =>
{
if (order.Status != OrderStatus.Valid)
throw new AcmeException(Strings.ErrorFinalizeFailed);
});
}

/// <summary>
/// Finalizes and download the certificate for the order.
/// </summary>
/// <param name="context">The order context.</param>
/// <param name="csr">The CSR.</param>
/// <param name="key">The private key for the certificate.</param>
/// <param name="retryCount">Number of retries when the Order is in 'processing' state. (default = 1)</param>
/// <param name="preferredChain">The preferred Root Certificate.</param>
/// <param name="retryCount">Number of retries when the Order is in 'processing' state. (default = 3)</param>
/// <returns>
/// The certificate generated.
/// </returns>
public static async Task<CertificateChain> Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain = null, int retryCount = 3)
[Obsolete("Use Generate(context, csr, key, preferredChain, timeout, cancellationToken) instead.")]
public static Task<CertificateChain> Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain = null, int retryCount = 3)
{
// Estimate total timeout based on inputs. Actual retries may be fewer if the order reaches a terminal state sooner.
var estimatedTimeout = TimeSpan.FromSeconds(Math.Max(context.RetryAfter, 2) * retryCount);
return Generate(context, csr, key, preferredChain, estimatedTimeout, CancellationToken.None);
}

/// <summary>
/// Finalizes and download the certificate for the order.
/// </summary>
/// <param name="context">The order context.</param>
/// <param name="csr">The CSR.</param>
/// <param name="key">The private key for the certificate.</param>
/// <param name="preferredChain">The preferred Root Certificate.</param>
/// <param name="timeout">Maximum time to wait for the Order to reach 'valid' state.</param>
/// <param name="cancellationToken">Cancellation token to allow external cancellation of the polling operation.</param>
/// <returns>
/// The certificate generated.
/// </returns>
public static async Task<CertificateChain> Generate(this IOrderContext context, CsrInfo csr, IKey key, string preferredChain, TimeSpan timeout, CancellationToken cancellationToken = default)
{
var order = await context.Resource();
if (order.Status != OrderStatus.Ready && // draft-11
Expand All @@ -81,18 +126,12 @@ public static async Task<CertificateChain> Generate(this IOrderContext context,

order = await context.Finalize(csr, key);

while ((order?.Status == OrderStatus.Processing || order.Certificate == null) && retryCount-- > 0)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Max(context.RetryAfter, 2)));
order = await context.Resource();
}

if (order.Status != OrderStatus.Valid)
{
throw new AcmeException(Strings.ErrorFinalizeFailed);
order = await context.WaitForCompletionAsync(timeout, cancellationToken);
}

return await context.Download(preferredChain);
return await context.Download(order, preferredChain);
}

/// <summary>
Expand Down
77 changes: 77 additions & 0 deletions src/Certify.ACME.Anvil/Extensions/ResourceContextPollingHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Certify.ACME.Anvil.Acme;

namespace Certify.ACME.Anvil
{
/// <summary>
/// Internal helper for polling resource contexts until reaching a terminal state.
/// </summary>
internal static class ResourceContextPollingHelper
{
/// <summary>
/// Waits for a resource to reach a terminal state with timeout-based polling.
/// </summary>
/// <typeparam name="T">The resource type.</typeparam>
/// <param name="context">The resource context.</param>
/// <param name="timeout">The maximum time to wait for completion. Use TimeSpan.Zero to check once without retrying.</param>
/// <param name="cancellationToken">Cancellation token to allow external cancellation of the polling operation.</param>
/// <param name="isTerminalState">Function to determine if the resource is in a terminal state.</param>
/// <param name="validateFinalState">Function to validate the final state and throw if invalid.</param>
/// <returns>The resource in terminal state.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when timeout is negative.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is cancelled via the cancellation token.</exception>
public static async Task<T> WaitForCompletionAsync<T>(IResourceContext<T> context, TimeSpan timeout, CancellationToken cancellationToken, Func<T, bool> isTerminalState, Action<T> validateFinalState)
{
if (timeout < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(timeout), Properties.Strings.ErrorRetryLessThanZero);
}

var resource = await context.Resource();
var startTime = DateTime.UtcNow;
var endTime = startTime.Add(timeout);

while (!isTerminalState(resource) && DateTime.UtcNow < endTime)
{
// Check cancellation before starting new work
cancellationToken.ThrowIfCancellationRequested();

// Determine how long to wait
TimeSpan delayDuration;

if (context.NextRetry.HasValue && context.NextRetry > DateTime.UtcNow)
{
// Use NextRetry if available and in the future
delayDuration = context.NextRetry.Value - DateTime.UtcNow;
}
else
{
// Fall back to RetryAfter
delayDuration = TimeSpan.FromSeconds(context.RetryAfter > 0 ? context.RetryAfter : 3);
}

// Ensure we don't wait past the timeout
var remainingTime = endTime - DateTime.UtcNow;
if (delayDuration > remainingTime)
{
delayDuration = remainingTime;
}

if (delayDuration > TimeSpan.Zero)
{
// Cancellation during delay will throw OperationCanceledException
await Task.Delay(delayDuration, cancellationToken);
}

// Fetch the resource status
resource = await context.Resource();
}

// If we're here, we either succeeded or timed out (cancellation would have thrown)
validateFinalState(resource);
return resource;
}
}
}
18 changes: 18 additions & 0 deletions src/Certify.ACME.Anvil/Properties/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/Certify.ACME.Anvil/Properties/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@
<data name="ErrorFinalizeFailed" xml:space="preserve">
<value>Fail to finalize order.</value>
</data>
<data name="ErrorChallengeValidationFailed" xml:space="preserve">
<value>Challenge validation failed or timed out.</value>
</data>
<data name="ErrorInvalidBase64String" xml:space="preserve">
<value>Illegal base64url string.</value>
</data>
Expand All @@ -144,4 +147,7 @@
<data name="ErrorUnsupportedResourceType" xml:space="preserve">
<value>Unsupported resource type '{0}'.</value>
</data>
<data name="ErrorRetryLessThanZero" xml:space="preserve">
<value>Retry cannot be less than zero</value>
</data>
</root>
Loading