126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
|
|
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`);
|
||
|
|
}
|
||
|
|
}
|