Files
smartacme/ts_server/server.handlers.order.ts

178 lines
5.3 KiB
TypeScript

import type * as http from 'node:http';
import * as crypto from 'node:crypto';
import type { JwsVerifier } from './server.classes.jws.verifier.js';
import { AcmeServerError } from './server.classes.jws.verifier.js';
import type { IServerOrderStore, IServerAccountStore } from './server.interfaces.js';
import type { IAcmeIdentifier } from '../ts/acme/acme.interfaces.js';
/**
* POST /new-order — Create a new ACME order.
*/
export function createNewOrderHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
_params: Record<string, string>,
body: any,
): Promise<void> => {
const requestUrl = `${baseUrl}/new-order`;
const verified = await jwsVerifier.verify(body, requestUrl);
if (!verified.kid) {
throw new AcmeServerError(400, 'urn:ietf:params:acme:error:malformed', 'newOrder requires kid');
}
const { payload } = verified;
const identifiers: IAcmeIdentifier[] = payload?.identifiers;
if (!identifiers || !Array.isArray(identifiers) || identifiers.length === 0) {
throw new AcmeServerError(
400,
'urn:ietf:params:acme:error:malformed',
'Order must include at least one identifier',
);
}
const orderId = crypto.randomBytes(16).toString('hex');
const expires = new Date();
expires.setDate(expires.getDate() + 7);
// Create authorizations and challenges for each identifier
const authorizationIds: string[] = [];
for (const identifier of identifiers) {
const authzId = crypto.randomBytes(16).toString('hex');
const isWildcard = identifier.value.startsWith('*.');
const domain = isWildcard ? identifier.value.slice(2) : identifier.value;
// Create challenges for this authorization
const challengeIds: string[] = [];
// HTTP-01 challenge (not for wildcards)
if (!isWildcard) {
const http01Id = crypto.randomBytes(16).toString('hex');
const http01Token = crypto.randomBytes(32).toString('base64url');
await orderStore.createChallenge({
id: http01Id,
authorizationId: authzId,
type: 'http-01',
token: http01Token,
status: 'pending',
});
challengeIds.push(http01Id);
}
// DNS-01 challenge (always)
const dns01Id = crypto.randomBytes(16).toString('hex');
const dns01Token = crypto.randomBytes(32).toString('base64url');
await orderStore.createChallenge({
id: dns01Id,
authorizationId: authzId,
type: 'dns-01',
token: dns01Token,
status: 'pending',
});
challengeIds.push(dns01Id);
await orderStore.createAuthorization({
id: authzId,
orderId,
identifier: { type: 'dns', value: domain },
status: 'pending',
expires: expires.toISOString(),
challengeIds,
wildcard: isWildcard || undefined,
});
authorizationIds.push(authzId);
}
const order = await orderStore.createOrder({
id: orderId,
accountUrl: verified.kid,
status: 'pending',
identifiers,
authorizationIds,
expires: expires.toISOString(),
finalize: `${baseUrl}/finalize/${orderId}`,
});
const responseBody = {
status: order.status,
expires: order.expires,
identifiers: order.identifiers,
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
finalize: order.finalize,
};
res.writeHead(201, {
'Content-Type': 'application/json',
'Location': `${baseUrl}/order/${orderId}`,
});
res.end(JSON.stringify(responseBody));
};
}
/**
* POST /order/:id — Poll order status (POST-as-GET).
*/
export function createOrderPollHandler(
baseUrl: string,
jwsVerifier: JwsVerifier,
orderStore: IServerOrderStore,
) {
return async (
req: http.IncomingMessage,
res: http.ServerResponse,
params: Record<string, string>,
body: any,
): Promise<void> => {
const orderId = params.id;
const requestUrl = `${baseUrl}/order/${orderId}`;
await jwsVerifier.verify(body, requestUrl);
const order = await orderStore.getOrder(orderId);
if (!order) {
throw new AcmeServerError(404, 'urn:ietf:params:acme:error:malformed', 'Order not found');
}
// Check if all authorizations are valid → transition to ready
if (order.status === 'pending') {
let allValid = true;
for (const authzId of order.authorizationIds) {
const authz = await orderStore.getAuthorization(authzId);
if (!authz || authz.status !== 'valid') {
allValid = false;
break;
}
}
if (allValid) {
await orderStore.updateOrder(orderId, { status: 'ready' });
order.status = 'ready';
}
}
const responseBody: Record<string, any> = {
status: order.status,
expires: order.expires,
identifiers: order.identifiers,
authorizations: order.authorizationIds.map((id) => `${baseUrl}/authz/${id}`),
finalize: order.finalize,
};
if (order.certificate) {
responseBody.certificate = order.certificate;
}
res.writeHead(200, {
'Content-Type': 'application/json',
'Location': requestUrl,
});
res.end(JSON.stringify(responseBody));
};
}