feat(server): add an embedded ACME directory server and certificate authority with challenge, order, and certificate endpoints
This commit is contained in:
177
ts_server/server.handlers.order.ts
Normal file
177
ts_server/server.handlers.order.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user