/* eslint-disable @typescript-eslint/no-floating-promises */ import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { AcmeOrderResourceSchema, CreateAcmeAccountResponseSchema, CreateAcmeOrderBodySchema, DeactivateAcmeAccountBodySchema, DeactivateAcmeAccountResponseSchema, FinalizeAcmeOrderBodySchema, GetAcmeAuthorizationResponseSchema, GetAcmeDirectoryResponseSchema, ListAcmeOrdersPayloadSchema, ListAcmeOrdersResponseSchema, RawJwsPayloadSchema, RespondToAcmeChallengeBodySchema, RespondToAcmeChallengeResponseSchema } from "@app/ee/services/pki-acme/pki-acme-schemas"; import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; const SharedParamsSchema = z.object({ profileId: z.string().uuid() }); export interface MyRequestInterface { Params: { profileId: string; accountId?: string }; Body: TRawJwsPayload; } export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { const validateExistingAccount = async < R extends FastifyRequest, TSchema extends z.ZodSchema | undefined = undefined, T = TSchema extends z.ZodSchema ? R : string >({ req, schema }: { req: R; schema?: TSchema; }): Promise> => { return await server.services.pkiAcme.validateExistingAccountJwsPayload({ url: new URL(req.url, `${req.protocol}://${req.hostname}`), profileId: (req.params as { profileId: string }).profileId, rawJwsPayload: req.body as TRawJwsPayload, schema, expectedAccountId: (req.params as { accountId?: string }).accountId }); }; const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { res.code(response.status); for (const [key, value] of Object.entries(response.headers)) { res.header(key, value); } const nonce = await server.services.pkiAcme.getAcmeNewNonce(profileId); res.header("Replay-Nonce", nonce); res.header("Cache-Control", "no-store"); return response.body; }; server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { try { const strBody = body instanceof Buffer ? body.toString() : body; if (!strBody) { done(null, undefined); } const json: unknown = JSON.parse(strBody as string); // TODO: deal with JWS payload here done(null, json); } catch (err) { const error = err as Error; done(error, undefined); } }); // GET /api/v1/pki/acme/profiles//directory // Directory (RFC 8555 Section 7.1.1) server.route({ method: "GET", url: "/profiles/:profileId/directory", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Directory - provides URLs for the client to make API calls to", params: z.object({ profileId: z.string().uuid() }), response: { 200: GetAcmeDirectoryResponseSchema } }, handler: async (req) => { const directory = await server.services.pkiAcme.getAcmeDirectory(req.params.profileId); return directory; } }); // HEAD /api/v1/pki/acme/profiles//new-nonce // New Nonce (RFC 8555 Section 7.2) server.route({ method: "HEAD", url: "/profiles/:profileId/new-nonce", config: { // TODO: probably a different rate limit for nonce creation rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", params: z.object({ profileId: z.string().uuid() }), response: { 200: z.string().length(0) } }, handler: async (req, res) => { const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); res.header("Replay-Nonce", nonce); return ""; } }); // POST /api/v1/pki/acme/profiles//new-account // New Account (RFC 8555 Section 7.3) server.route({ method: "POST", url: "/profiles/:profileId/new-account", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Account - register a new account or find existing one", params: SharedParamsSchema, body: RawJwsPayloadSchema, response: { 201: CreateAcmeAccountResponseSchema } }, handler: async (req, res) => { const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({ url: new URL(req.url, `${req.protocol}://${req.hostname}`), rawJwsPayload: req.body }); const { alg, jwk } = protectedHeader; return sendAcmeResponse( res, req.params.profileId, await server.services.pkiAcme.createAcmeAccount({ profileId: req.params.profileId, alg, jwk: jwk!, payload }) ); } }); // POST /api/v1/pki/acme/profiles//accounts/ // Account Deactivation (RFC 8555 Section 7.3.6) server.route({ method: "POST", url: "/profiles/:profileId/accounts/:accountId", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Account Deactivation", params: SharedParamsSchema.extend({ accountId: z.string() }), body: RawJwsPayloadSchema, response: { 200: DeactivateAcmeAccountResponseSchema } }, handler: async (req, res) => { const { payload, profileId, accountId } = await validateExistingAccount({ req, schema: DeactivateAcmeAccountBodySchema }); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.deactivateAcmeAccount({ profileId, accountId, payload }) ); } }); // POST /api/v1/pki/acme/profiles//new-order // New Certificate Order (RFC 8555 Section 7.4) server.route({ method: "POST", url: "/profiles/:profileId/new-order", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME New Order - apply for a new certificate", params: SharedParamsSchema, body: RawJwsPayloadSchema, response: { 201: AcmeOrderResourceSchema } }, handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req, schema: CreateAcmeOrderBodySchema }); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.createAcmeOrder({ profileId, accountId, payload }) ); } }); // POST /api/v1/pki/acme/profiles//orders/ // Get Order (RFC 8555 Section 7.1.3) server.route({ method: "POST", url: "/profiles/:profileId/orders/:orderId", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Get Order - return status and details of the order", params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, response: { 200: AcmeOrderResourceSchema } }, handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); } return sendAcmeResponse( res, profileId, await server.services.pkiAcme.getAcmeOrder({ profileId, accountId, orderId: req.params.orderId }) ); } }); // POST /api/v1/pki/acme/profiles//orders//finalize // Applying for Certificate Issuance (RFC 8555 Section 7.4) server.route({ method: "POST", url: "/profiles/:profileId/orders/:orderId/finalize", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Finalize Order - finalize cert order by providing CSR", params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, response: { 200: AcmeOrderResourceSchema } }, handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req, schema: FinalizeAcmeOrderBodySchema }); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.finalizeAcmeOrder({ profileId, accountId, orderId: req.params.orderId, payload }) ); } }); // POST /api/v1/pki/acme/profiles//accounts//orders // List Orders (RFC 8555 Section 7.1.2.1) server.route({ method: "POST", url: "/profiles/:profileId/accounts/:accountId/orders", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME List Orders - get existing orders from current account", params: SharedParamsSchema.extend({ accountId: z.string() }), body: RawJwsPayloadSchema, response: { 200: ListAcmeOrdersResponseSchema } }, // TODO: replace with verify ACME signature here instead // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req, res) => { const { profileId, accountId } = await validateExistingAccount({ req, schema: ListAcmeOrdersPayloadSchema }); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.listAcmeOrders({ profileId, accountId }) ); } }); // POST /api/v1/pki/acme/profiles//orders//certificate // Download Certificate (RFC 8555 Section 7.4.2) server.route({ method: "POST", url: "/profiles/:profileId/orders/:orderId/certificate", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Download Certificate - download certificate when ready", params: SharedParamsSchema.extend({ orderId: z.string().uuid() }), body: RawJwsPayloadSchema, response: { 200: z.string() } }, handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); } res.type("application/pem-certificate-chain"); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.downloadAcmeCertificate({ profileId, accountId, orderId: req.params.orderId }) ); } }); // POST /api/v1/pki/acme/profiles//authorizations/ // Identifier Authorization (RFC 8555 Section 7.5) server.route({ method: "POST", url: "/profiles/:profileId/authorizations/:authzId", config: { rateLimit: readLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Identifier Authorization - get authorization info (challenges)", params: SharedParamsSchema.extend({ authzId: z.string().uuid() }), body: RawJwsPayloadSchema, response: { 200: GetAcmeAuthorizationResponseSchema } }, handler: async (req, res) => { const { profileId, accountId, payload } = await validateExistingAccount({ req }); if (payload !== "") { throw new AcmeMalformedError({ detail: "Payload should be empty" }); } return sendAcmeResponse( res, profileId, await server.services.pkiAcme.getAcmeAuthorization({ profileId, accountId, authzId: req.params.authzId }) ); } }); // POST /api/v1/pki/acme/profiles//authorizations//challenges/ // Respond to Challenge (RFC 8555 Section 7.5.1) server.route({ method: "POST", url: "/profiles/:profileId/authorizations/:authzId/challenges/:challengeId", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiAcme], description: "ACME Respond to Challenge - let ACME server know challenge is ready", params: SharedParamsSchema.extend({ authzId: z.string().uuid(), challengeId: z.string().uuid() }), response: { 200: RespondToAcmeChallengeResponseSchema } }, handler: async (req, res) => { const { profileId, accountId } = await validateExistingAccount({ req, schema: RespondToAcmeChallengeBodySchema }); return sendAcmeResponse( res, profileId, await server.services.pkiAcme.respondToAcmeChallenge({ profileId, accountId, authzId: req.params.authzId, challengeId: req.params.challengeId }) ); } }); };