Implement account creation

This commit is contained in:
Fang-Pen Lin
2025-10-28 16:04:26 -07:00
parent a29029c192
commit 4883c3ead3
4 changed files with 174 additions and 18 deletions

View File

@@ -0,0 +1,105 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TPkiAcmeAccountsInsert, TPkiAcmeAccountsUpdate } from "@app/db/schemas/pki-acme-accounts";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TPkiAcmeAccountDALFactory = ReturnType<typeof pkiAcmeAccountDALFactory>;
export const pkiAcmeAccountDALFactory = (db: TDbClient) => {
const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount);
const create = async (data: TPkiAcmeAccountsInsert, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiAcmeAccount).insert(data).returning("*");
const [account] = result;
if (!account) {
throw new Error("Failed to create PKI ACME account");
}
return account;
} catch (error) {
throw new DatabaseError({ error, name: "Create PKI ACME account" });
}
};
const updateById = async (id: string, data: TPkiAcmeAccountsUpdate, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).update(data).returning("*");
const [account] = result;
if (!account) {
return null;
}
return account;
} catch (error) {
throw new DatabaseError({ error, name: "Update PKI ACME account" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by id" });
}
};
const findByProfileId = async (profileId: string, tx?: Knex) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by profile id" });
}
};
const findByPublicKey = async (publicKey: unknown, tx?: Knex) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ publicKey }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by public key" });
}
};
const findManyByProfileId = async (profileId: string, tx?: Knex) => {
try {
const accounts = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId });
return accounts;
} catch (error) {
throw new DatabaseError({ error, name: "Find many PKI ACME accounts by profile id" });
}
};
const deleteById = async (id: string, tx?: Knex) => {
try {
const result = await (tx || db)(TableName.PkiAcmeAccount).where({ id }).delete().returning("*");
const [account] = result;
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Delete PKI ACME account by id" });
}
};
return {
...pkiAcmeAccountOrm,
create,
updateById,
findById,
findByProfileId,
findByPublicKey,
findManyByProfileId,
deleteById
};
};

View File

@@ -22,7 +22,7 @@ export const RawJwsPayloadSchema = z.object({
export const JwsPayloadSchema = z.object({
protectedHeader: ProtectedHeaderSchema,
payload: z.any()
payload: z.unknown()
});
// Directory endpoint
@@ -71,8 +71,7 @@ export const CreateAcmeAccountSchema = z.object({
export const CreateAcmeAccountResponseSchema = z.object({
status: z.string(),
contact: z.array(z.string()).optional(),
orders: z.string().optional(),
accountUrl: z.string()
orders: z.string().optional()
});
// New Order payload schema

View File

@@ -3,7 +3,7 @@ import { NotFoundError } from "@app/lib/errors";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors";
import { AcmeAccountDoesNotExistError, AcmeBadPublicKeyError, AcmeMalformedError } from "./pki-acme-errors";
import {
EnrollmentType,
@@ -12,6 +12,7 @@ import {
import { flattenedVerify, importJWK, JWK, JWSHeaderParameters } from "jose";
import { ProtectedHeaderSchema } from "./pki-acme-schemas";
import {
TAcmeResponse,
TCreateAcmeAccountPayload,
TCreateAcmeAccountResponse,
TCreateAcmeOrderPayload,
@@ -24,17 +25,23 @@ import {
TGetAcmeDirectoryResponse,
TGetAcmeOrderResponse,
TJwsPayload,
TJwsPayloadWithJwk,
TListAcmeOrdersResponse,
TPkiAcmeServiceFactory,
TRawJwsPayload,
TRespondToAcmeChallengeResponse
} from "./pki-acme-types";
import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal";
type TPkiAcmeServiceFactoryDep = {
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findById">;
pkiAcmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findByPublicKey" | "create">;
};
export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
export const pkiAcmeServiceFactory = ({
certificateProfileDAL,
pkiAcmeAccountDAL
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
@@ -52,7 +59,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
return `${baseUrl}${path}`;
};
const validateCreateAcmeAccountJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise<TJwsPayload> => {
const validateCreateAcmeAccountJwsPayload = async (rawJwsPayload: TRawJwsPayload): Promise<TJwsPayloadWithJwk> => {
const { payload: rawPayload, protectedHeader: rawProtectedHeader } = await flattenedVerify(
rawJwsPayload,
async (protectedHeader: JWSHeaderParameters | undefined) => {
@@ -72,10 +79,11 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
if (!success) {
throw new AcmeMalformedError({ detail: "Invalid protected header" });
}
const decoder = new TextDecoder();
const payload = JSON.parse(decoder.decode(rawPayload)) as TCreateAcmeAccountPayload;
// TODO: also consume the nonce here
return { payload, protectedHeader };
return { payload, protectedHeader, jwk: protectedHeader.jwk as JsonWebKey };
};
const getAcmeDirectory = async (profileId: string): Promise<TGetAcmeDirectoryResponse> => {
@@ -96,18 +104,47 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
const createAcmeAccount = async (
profileId: string,
jwk: JWK,
payload: TCreateAcmeAccountPayload
): Promise<TCreateAcmeAccountResponse> => {
): Promise<TAcmeResponse<TCreateAcmeAccountResponse>> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME new account registration
// Use EAB authentication to find corresponding Infisical machine identity
// Check permissions and return account information
const accountId = "FIXME-account-id";
// TODO: the jwk as json obj may not be the best idea for indexing.
// Maybe we should find a way to serialize the jwk deterministically.
let account = await pkiAcmeAccountDAL.findByPublicKey(jwk);
if (payload.onlyReturnExisting && !account) {
throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" });
}
if (account) {
// With the same public key, we found an existing account, just return it
return {
status: 200,
payload: {
status: "valid",
contact: account.emails,
orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`)
},
headers: {
Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`)
}
};
}
account = await pkiAcmeAccountDAL.create({
profileId,
publicKey: jwk,
emails: payload.contact ?? []
});
// TODO: check EAB authentication here
return {
status: "valid",
accountUrl: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}`),
contact: [],
orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${accountId}/orders`)
status: 201,
payload: {
status: "valid",
contact: account.emails,
orders: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}/orders`)
},
headers: {
Location: buildUrl(`/api/v1/pki/acme/profiles/${profileId}/accounts/${account.id}`)
}
};
};
@@ -116,6 +153,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL }: TPkiAcmeService
payload: TCreateAcmeOrderPayload
): Promise<TCreateAcmeOrderResponse> => {
const profile = await validateAcmeProfile(profileId);
// FIXME: Implement ACME new order creation
const orderId = "FIXME-order-id";
return {

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { JWK } from "jose";
import {
CreateAcmeAccountBodySchema,
CreateAcmeAccountResponseSchema,
@@ -39,11 +40,24 @@ export type TCreateAcmeOrderPayload = z.infer<typeof CreateAcmeOrderBodySchema>;
export type TDeactivateAcmeAccountPayload = z.infer<typeof DeactivateAcmeAccountBodySchema>;
export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySchema>;
export type TJwsPayloadWithJwk = TJwsPayload & {
jwk: JsonWebKey;
};
export type TAcmeResponse<TPayload> = {
status: number;
headers: Record<string, string>;
payload: TPayload;
};
export type TPkiAcmeServiceFactory = {
validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise<TJwsPayload>;
validateCreateAcmeAccountJwsPayload(body: TRawJwsPayload): Promise<TJwsPayloadWithJwk>;
getAcmeDirectory: (profileId: string) => Promise<TGetAcmeDirectoryResponse>;
getAcmeNewNonce: (profileId: string) => Promise<string>;
createAcmeAccount: (profileId: string, body: TCreateAcmeAccountPayload) => Promise<TCreateAcmeAccountResponse>;
createAcmeAccount: (
profileId: string,
jwk: JsonWebKey,
body: TCreateAcmeAccountPayload
) => Promise<TAcmeResponse<TCreateAcmeAccountResponse>>;
createAcmeOrder: (profileId: string, body: TCreateAcmeOrderPayload) => Promise<TCreateAcmeOrderResponse>;
deactivateAcmeAccount: (
profileId: string,