Improvements on Azure ADCS PKI feature

This commit is contained in:
Carlos Monastyrski
2025-08-27 21:20:10 -03:00
parent 0762de93d6
commit 13b20806ba
19 changed files with 922 additions and 600 deletions
@@ -1,8 +1,7 @@
/* eslint-disable no-case-declarations, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires, no-await-in-loop, no-continue */
// @ts-expect-error: No types available for httpntlm
import * as httpntlm from "httpntlm";
import { NtlmClient } from "axios-ntlm";
import https from "https";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
@@ -13,23 +12,24 @@ import { AppConnection } from "../app-connection-enums";
import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums";
import { TAzureADCSConnectionConfig } from "./azure-adcs-connection-types";
// Type definitions for httpntlm module
interface HttpNtlmRequestOptions {
// Type definitions for axios-ntlm
interface AxiosNtlmConfig {
ntlm: {
domain: string;
username: string;
password: string;
};
httpsAgent?: https.Agent;
url: string;
username: string;
password: string;
domain: string;
workstation?: string;
method?: string;
body?: string;
data?: string;
headers?: Record<string, string>;
rejectUnauthorized?: boolean;
}
interface HttpNtlmResponse {
statusCode: number;
body: string;
headers: Record<string, string>;
interface AxiosNtlmResponse {
status: number;
data: string;
headers: unknown;
}
// Types for credential parsing
@@ -104,31 +104,65 @@ const normalizeAdcsUrl = (url: string): string => {
};
// NTLM request wrapper
const ntlmRequest = (options: HttpNtlmRequestOptions): Promise<HttpNtlmResponse> => {
return new Promise((resolve, reject) => {
const method = options.method || "GET";
const createHttpsAgent = (sslRejectUnauthorized: boolean, sslCertificate?: string): https.Agent => {
const agentOptions: https.AgentOptions = {
rejectUnauthorized: sslRejectUnauthorized,
keepAlive: true // axios-ntlm needs keepAlive for NTLM handshake
};
if (method.toLowerCase() === "get") {
httpntlm.get(options, (err: Error | null, res: HttpNtlmResponse) => {
if (err) reject(err);
else resolve(res);
});
} else if (method.toLowerCase() === "post") {
httpntlm.post(options, (err: Error | null, res: HttpNtlmResponse) => {
if (err) reject(err);
else resolve(res);
});
} else {
reject(new Error(`Unsupported HTTP method: ${method}`));
}
});
if (sslCertificate && sslCertificate.trim()) {
agentOptions.ca = [sslCertificate.trim()];
}
return new https.Agent(agentOptions);
};
const axiosNtlmRequest = async (config: AxiosNtlmConfig): Promise<AxiosNtlmResponse> => {
const method = config.method || "GET";
const credentials = {
username: config.ntlm.username,
password: config.ntlm.password,
domain: config.ntlm.domain || "",
workstation: ""
};
const axiosConfig = {
httpsAgent: config.httpsAgent,
timeout: 30000
};
const client = NtlmClient(credentials, axiosConfig);
const requestOptions: { url: string; method: string; data?: string; headers?: Record<string, string> } = {
url: config.url,
method
};
if (config.data) {
requestOptions.data = config.data;
}
if (config.headers) {
requestOptions.headers = config.headers;
}
const response = await client(requestOptions);
return {
status: response.status,
data: response.data,
headers: response.headers
};
};
// Test ADCS connectivity and authentication using NTLM
const testAdcsConnection = async (
credentials: ParsedCredentials,
password: string,
baseUrl: string
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
): Promise<boolean> => {
// Test endpoints in order of preference
const testEndpoints = [
@@ -142,18 +176,24 @@ const testAdcsConnection = async (
try {
const testUrl = `${baseUrl}${endpoint}`;
const response = await ntlmRequest({
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
const response = await axiosNtlmRequest({
url: testUrl,
username: credentials.username,
password,
domain: credentials.domain,
workstation: "",
rejectUnauthorized: !getConfig().isDevelopmentMode // Only allow unauthorized SSL in development
method: "GET",
httpsAgent,
ntlm: {
domain: credentials.domain,
username: credentials.username,
password
}
});
// Check if we got a successful response
if (response.statusCode === 200) {
const responseText = response.body;
if (response.status === 200) {
const responseText = response.data;
// Verify this is actually an ADCS server by checking content
const adcsIndicators = [
@@ -175,14 +215,13 @@ const testAdcsConnection = async (
}
}
// Handle authentication failures
if (response.statusCode === 401) {
if (response.status === 401) {
throw new BadRequestError({
message: "Authentication failed. Please verify your username, password, and domain are correct."
message: "Authentication failed. Please verify your credentials are correct."
});
}
if (response.statusCode === 403) {
if (response.status === 403) {
throw new BadRequestError({
message: "Access denied. Your account may not have permission to access ADCS web enrollment."
});
@@ -204,11 +243,27 @@ const testAdcsConnection = async (
message: "Connection refused by ADCS server. Please verify the server is running and accessible."
});
}
if (error.message.includes("ETIMEDOUT")) {
if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) {
throw new BadRequestError({
message: "Connection timeout. Please verify the server is accessible and not blocked by firewall."
});
}
if (error.message.includes("certificate") || error.message.includes("SSL") || error.message.includes("TLS")) {
throw new BadRequestError({
message: `SSL/TLS certificate error: ${error.message}. This may indicate a certificate verification failure.`
});
}
if (error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT")) {
throw new BadRequestError({
message:
"Self-signed certificate detected. Either provide the server's certificate or set 'sslRejectUnauthorized' to false."
});
}
if (error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")) {
throw new BadRequestError({
message: "Unable to verify certificate signature. Please provide the correct CA certificate."
});
}
}
// Continue to next endpoint for other errors
@@ -223,37 +278,53 @@ const testAdcsConnection = async (
};
// Create authenticated NTLM client for ADCS operations
const createNtlmClient = (username: string, password: string, baseUrl: string) => {
const createNtlmClient = (
username: string,
password: string,
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
) => {
const parsedCredentials = parseCredentials(username);
const normalizedUrl = normalizeAdcsUrl(baseUrl);
return {
get: (endpoint: string, additionalOptions: Partial<HttpNtlmRequestOptions> = {}) => {
return ntlmRequest({
get: async (endpoint: string, additionalHeaders: Record<string, string> = {}) => {
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
return axiosNtlmRequest({
url: `${normalizedUrl}${endpoint}`,
username: parsedCredentials.username,
password,
domain: parsedCredentials.domain,
workstation: "",
rejectUnauthorized: !getConfig().isDevelopmentMode, // Only allow unauthorized SSL in development,
...additionalOptions
method: "GET",
httpsAgent,
headers: additionalHeaders,
ntlm: {
domain: parsedCredentials.domain,
username: parsedCredentials.username,
password
}
});
},
post: (endpoint: string, body: string, additionalOptions: Partial<HttpNtlmRequestOptions> = {}) => {
return ntlmRequest({
method: "POST",
post: async (endpoint: string, body: string, additionalHeaders: Record<string, string> = {}) => {
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
return axiosNtlmRequest({
url: `${normalizedUrl}${endpoint}`,
username: parsedCredentials.username,
password,
domain: parsedCredentials.domain,
workstation: "",
rejectUnauthorized: !getConfig().isDevelopmentMode, // Only allow unauthorized SSL in development,
body,
method: "POST",
httpsAgent,
data: body,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...additionalOptions.headers
...additionalHeaders
},
...additionalOptions
ntlm: {
domain: parsedCredentials.domain,
username: parsedCredentials.username,
password
}
});
},
baseUrl: normalizedUrl,
@@ -282,12 +353,20 @@ export const getAzureADCSConnectionCredentials = async (
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as { username: string; password: string; adcsUrl: string };
})) as {
username: string;
password: string;
adcsUrl: string;
sslRejectUnauthorized?: boolean;
sslCertificate?: string;
};
return {
username: credentials.username,
password: credentials.password,
adcsUrl: credentials.adcsUrl
adcsUrl: credentials.adcsUrl,
sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true,
sslCertificate: credentials.sslCertificate
};
default:
@@ -309,13 +388,21 @@ export const validateAzureADCSConnectionCredentials = async (appConnection: TAzu
await blockLocalAndPrivateIpAddresses(normalizedUrl);
// Test the connection using NTLM
await testAdcsConnection(parsedCredentials, credentials.password, normalizedUrl);
await testAdcsConnection(
parsedCredentials,
credentials.password,
normalizedUrl,
credentials.sslRejectUnauthorized ?? true,
credentials.sslCertificate
);
// If we get here, authentication was successful
return {
username: credentials.username,
password: credentials.password,
adcsUrl: credentials.adcsUrl
adcsUrl: credentials.adcsUrl,
sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true,
sslCertificate: credentials.sslCertificate
};
} catch (error) {
if (error instanceof BadRequestError) {
@@ -334,9 +421,11 @@ export const validateAzureADCSConnectionCredentials = async (appConnection: TAzu
} else if (
error.message.includes("certificate") ||
error.message.includes("SSL") ||
error.message.includes("TLS")
error.message.includes("TLS") ||
error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT") ||
error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")
) {
errorMessage = "SSL/TLS certificate error. The server certificate may be self-signed or invalid.";
errorMessage = `SSL/TLS certificate error: ${error.message}. The server certificate may be self-signed or the CA certificate may be incorrect.`;
}
}
@@ -355,6 +444,12 @@ export const getAzureADCSConnectionListItem = () => ({
});
// Export helper functions for use in certificate ordering
export const createAdcsHttpClient = (username: string, password: string, baseUrl: string) => {
return createNtlmClient(username, password, baseUrl);
export const createAdcsHttpClient = (
username: string,
password: string,
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
) => {
return createNtlmClient(username, password, baseUrl, sslRejectUnauthorized, sslCertificate);
};
@@ -10,12 +10,13 @@ import {
import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums";
export const AzureADCSConnectionAccessTokenCredentialsSchema = z.object({
export const AzureADCSUsernamePasswordCredentialsSchema = z.object({
adcsUrl: z
.string()
.trim()
.min(1, "ADCS URL required")
.max(255)
.refine((value) => value.startsWith("https://"), "ADCS URL must use HTTPS")
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl),
username: z
.string()
@@ -28,21 +29,31 @@ export const AzureADCSConnectionAccessTokenCredentialsSchema = z.object({
.trim()
.min(1, "Password required")
.max(255)
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.password)
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.password),
sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslRejectUnauthorized),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslCertificate)
});
const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) });
export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({
method: z.literal(AzureADCSConnectionMethod.UsernamePassword),
credentials: AzureADCSConnectionAccessTokenCredentialsSchema
credentials: AzureADCSUsernamePasswordCredentialsSchema
});
export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [
BaseAzureADCSConnectionSchema.extend({
method: z.literal(AzureADCSConnectionMethod.UsernamePassword),
credentials: AzureADCSConnectionAccessTokenCredentialsSchema.pick({
username: true
credentials: AzureADCSUsernamePasswordCredentialsSchema.pick({
username: true,
adcsUrl: true,
sslRejectUnauthorized: true,
sslCertificate: true
})
})
]);
@@ -52,7 +63,7 @@ export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion
method: z
.literal(AzureADCSConnectionMethod.UsernamePassword)
.describe(AppConnections.CREATE(AppConnection.AzureADCS).method),
credentials: AzureADCSConnectionAccessTokenCredentialsSchema.describe(
credentials: AzureADCSUsernamePasswordCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureADCS).credentials
)
})
@@ -64,7 +75,7 @@ export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCreden
export const UpdateAzureADCSConnectionSchema = z
.object({
credentials: AzureADCSConnectionAccessTokenCredentialsSchema.optional().describe(
credentials: AzureADCSUsernamePasswordCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.AzureADCS).credentials
)
})