Files
smartacme/ts/acme/acme.classes.order.ts

126 lines
4.4 KiB
TypeScript
Raw Normal View History

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`);
}
}