BREAKING CHANGE(acme): Replace external acme-client with a built-in RFC8555-compliant ACME implementation and update public APIs accordingly
This commit is contained in:
125
ts/acme/acme.classes.order.ts
Normal file
125
ts/acme/acme.classes.order.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { AcmeCrypto } from './acme.classes.crypto.js';
|
||||
import { AcmeError } from './acme.classes.error.js';
|
||||
import type { AcmeHttpClient } from './acme.classes.http-client.js';
|
||||
import type {
|
||||
IAcmeAuthorization,
|
||||
IAcmeIdentifier,
|
||||
IAcmeOrder,
|
||||
} from './acme.interfaces.js';
|
||||
|
||||
/**
|
||||
* ACME order lifecycle management.
|
||||
* Handles order creation, authorization retrieval, finalization, and certificate download.
|
||||
*/
|
||||
export class AcmeOrderManager {
|
||||
private httpClient: AcmeHttpClient;
|
||||
|
||||
constructor(httpClient: AcmeHttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ACME order for the given identifiers
|
||||
*/
|
||||
async create(opts: { identifiers: IAcmeIdentifier[] }): Promise<IAcmeOrder> {
|
||||
const dir = await this.httpClient.getDirectory();
|
||||
const response = await this.httpClient.signedRequest(dir.newOrder, {
|
||||
identifiers: opts.identifiers,
|
||||
});
|
||||
|
||||
const order = response.data as IAcmeOrder;
|
||||
// Capture order URL from Location header
|
||||
order.url = response.headers['location'] || '';
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all authorizations for an order (POST-as-GET each authorization URL)
|
||||
*/
|
||||
async getAuthorizations(order: IAcmeOrder): Promise<IAcmeAuthorization[]> {
|
||||
const authorizations: IAcmeAuthorization[] = [];
|
||||
for (const authzUrl of order.authorizations) {
|
||||
const response = await this.httpClient.signedRequest(authzUrl, null);
|
||||
authorizations.push(response.data as IAcmeAuthorization);
|
||||
}
|
||||
return authorizations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize an order by submitting the CSR.
|
||||
* Waits for the order to reach 'valid' status.
|
||||
* Mutates the order object with updated status and certificate URL.
|
||||
*/
|
||||
async finalize(order: IAcmeOrder, csrPem: string): Promise<void> {
|
||||
// Convert PEM CSR to base64url DER for ACME
|
||||
const csrDer = AcmeCrypto.pemToBuffer(csrPem);
|
||||
const csrB64url = csrDer.toString('base64url');
|
||||
|
||||
const response = await this.httpClient.signedRequest(order.finalize, { csr: csrB64url });
|
||||
|
||||
// Update order with response data
|
||||
const updatedOrder = response.data;
|
||||
order.status = updatedOrder.status;
|
||||
if (updatedOrder.certificate) {
|
||||
order.certificate = updatedOrder.certificate;
|
||||
}
|
||||
|
||||
// If not yet valid, poll until it is
|
||||
if (order.status !== 'valid' && order.url) {
|
||||
const finalOrder = await this.waitForValidStatus({ url: order.url });
|
||||
order.status = finalOrder.status;
|
||||
order.certificate = finalOrder.certificate;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the certificate chain (PEM) from the order's certificate URL
|
||||
*/
|
||||
async getCertificate(order: IAcmeOrder): Promise<string> {
|
||||
if (!order.certificate) {
|
||||
throw new Error('Order does not have a certificate URL - finalize first');
|
||||
}
|
||||
const response = await this.httpClient.signedRequest(order.certificate, null);
|
||||
// Certificate chain is returned as PEM text
|
||||
return typeof response.data === 'string' ? response.data : response.data.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll an ACME resource (order or challenge) until it reaches 'valid' or 'ready' status.
|
||||
* Uses exponential backoff with Retry-After header support.
|
||||
*/
|
||||
async waitForValidStatus(
|
||||
item: { url: string },
|
||||
opts?: { maxAttempts?: number; initialDelayMs?: number },
|
||||
): Promise<any> {
|
||||
const maxAttempts = opts?.maxAttempts ?? 30;
|
||||
const initialDelay = opts?.initialDelayMs ?? 1000;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const response = await this.httpClient.signedRequest(item.url, null);
|
||||
const body = response.data;
|
||||
|
||||
if (body.status === 'valid' || body.status === 'ready') {
|
||||
return body;
|
||||
}
|
||||
if (body.status === 'invalid') {
|
||||
const challengeError = body.challenges?.find((c: any) => c.error)?.error;
|
||||
throw new AcmeError({
|
||||
status: 0,
|
||||
type: challengeError?.type || 'urn:ietf:params:acme:error:rejectedIdentifier',
|
||||
detail: challengeError?.detail || JSON.stringify(body),
|
||||
subproblems: challengeError?.subproblems,
|
||||
url: item.url,
|
||||
});
|
||||
}
|
||||
|
||||
// Respect Retry-After header, otherwise exponential backoff
|
||||
const retryAfter = parseInt(response.headers['retry-after'] || '0', 10);
|
||||
const delay =
|
||||
retryAfter > 0 ? retryAfter * 1000 : Math.min(initialDelay * Math.pow(2, i), 30000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
throw new Error(`Timeout waiting for valid status after ${maxAttempts} attempts`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user