mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-10 07:58:15 -05:00
Merge pull request #4820 from Infisical/daniel/cert-auth
feat(app-connections/azure-client-secrets): certificate auth
This commit is contained in:
@@ -2309,7 +2309,10 @@ export const AppConnections = {
|
||||
code: "The OAuth code to use to connect with Azure Client Secrets.",
|
||||
tenantId: "The Tenant ID to use to connect with Azure Client Secrets.",
|
||||
clientId: "The Client ID to use to connect with Azure Client Secrets.",
|
||||
clientSecret: "The Client Secret to use to connect with Azure Client Secrets."
|
||||
clientSecret: "The Client Secret to use to connect with Azure Client Secrets.",
|
||||
certificateBody: "The certificate body in PEM format to use to connect with Azure Client Secrets.",
|
||||
privateKey:
|
||||
"The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with."
|
||||
},
|
||||
AZURE_DEVOPS: {
|
||||
code: "The OAuth code to use to connect with Azure DevOps.",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import type { KeyObject } from "crypto";
|
||||
import RE2 from "re2";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
decryptAppConnectionCredentials,
|
||||
encryptAppConnectionCredentials,
|
||||
@@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums";
|
||||
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
|
||||
import {
|
||||
ExchangeCodeAzureResponse,
|
||||
TAzureClientSecretsConnectionCertificateCredentials,
|
||||
TAzureClientSecretsConnectionClientSecretCredentials,
|
||||
TAzureClientSecretsConnectionConfig,
|
||||
TAzureClientSecretsConnectionCredentials
|
||||
} from "./azure-client-secrets-connection-types";
|
||||
|
||||
const generateClientAssertion = (
|
||||
clientId: string,
|
||||
tenantId: string,
|
||||
privateKey: string,
|
||||
certificate: string
|
||||
): string => {
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const certBuffer = Buffer.from(
|
||||
certificate
|
||||
.replace(new RE2("-----BEGIN CERTIFICATE-----"), "")
|
||||
.replace(new RE2("-----END CERTIFICATE-----"), "")
|
||||
.replace(new RE2("\\s", "g"), ""),
|
||||
"base64"
|
||||
);
|
||||
|
||||
// thumbprint of the certificate is used for the jwt header
|
||||
const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex");
|
||||
const x5t = Buffer.from(thumbprint, "hex").toString("base64url");
|
||||
|
||||
// JWT Header
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
x5t
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
aud: tokenEndpoint,
|
||||
exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself)
|
||||
iss: clientId,
|
||||
jti: uuidv4(), // random ID for the JWT
|
||||
nbf: now, // not before the jwt is valid
|
||||
sub: clientId
|
||||
};
|
||||
|
||||
// encode header and payload
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
let keyObject: KeyObject;
|
||||
|
||||
try {
|
||||
if (privateKey.includes("BEGIN PRIVATE KEY")) {
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey(privateKey);
|
||||
} else {
|
||||
// if user forgot to wrap in begin/end private key, decode and use as der format
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
format: "der",
|
||||
type: "pkcs8"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid private key format provided. Expected PEM format private key."
|
||||
});
|
||||
}
|
||||
|
||||
// sign with private key
|
||||
const signer = crypto.nativeCrypto.createSign("RSA-SHA256");
|
||||
signer.update(signatureInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(keyObject, "base64url");
|
||||
|
||||
return `${signatureInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const getAzureClientSecretsConnectionListItem = () => {
|
||||
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
|
||||
|
||||
@@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => {
|
||||
app: AppConnection.AzureClientSecrets as const,
|
||||
methods: Object.values(AzureClientSecretsConnectionMethod) as [
|
||||
AzureClientSecretsConnectionMethod.OAuth,
|
||||
AzureClientSecretsConnectionMethod.ClientSecret
|
||||
AzureClientSecretsConnectionMethod.ClientSecret,
|
||||
AzureClientSecretsConnectionMethod.Certificate
|
||||
],
|
||||
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
|
||||
};
|
||||
@@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async (
|
||||
const { refreshToken } = credentials;
|
||||
const currentTime = Date.now();
|
||||
switch (appConnection.method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
|
||||
@@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
}
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
@@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||
|
||||
return clientData.access_token;
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.Certificate: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
kmsService,
|
||||
encryptedCredentials: appConnection.encryptedCredentials
|
||||
})) as TAzureClientSecretsConnectionCertificateCredentials;
|
||||
const { accessToken, expiresAt, clientId, tenantId, certificateBody, privateKey } = accessTokenCredentials;
|
||||
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
|
||||
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
|
||||
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
|
||||
new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
scope: `https://graph.microsoft.com/.default`,
|
||||
client_id: clientId,
|
||||
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
client_assertion: clientAssertion
|
||||
})
|
||||
);
|
||||
|
||||
const updatedClientCredentials = {
|
||||
...accessTokenCredentials,
|
||||
accessToken: clientData.access_token,
|
||||
expiresAt: currentTime + clientData.expires_in * 1000
|
||||
};
|
||||
|
||||
const encryptedClientCredentials = await encryptAppConnectionCredentials({
|
||||
credentials: updatedClientCredentials,
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||
|
||||
return clientData.access_token;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
|
||||
@@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
} = getConfig();
|
||||
|
||||
switch (method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (!SITE_URL) {
|
||||
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" });
|
||||
}
|
||||
@@ -221,8 +343,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
refreshToken: tokenResp.data.refresh_token,
|
||||
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
||||
};
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const { tenantId, clientId, clientSecret } = inputCredentials;
|
||||
try {
|
||||
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
|
||||
@@ -255,6 +378,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
case AzureClientSecretsConnectionMethod.Certificate: {
|
||||
const { tenantId, certificateBody, privateKey, clientId } = inputCredentials;
|
||||
try {
|
||||
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
|
||||
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
client_assertion: clientAssertion,
|
||||
scope: "https://graph.microsoft.com/.default",
|
||||
grant_type: "client_credentials"
|
||||
});
|
||||
|
||||
const response = await request.post<ExchangeCodeAzureResponse>(tokenEndpoint, params.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
tenantId,
|
||||
clientId,
|
||||
certificateBody,
|
||||
privateKey,
|
||||
accessToken: response.data.access_token,
|
||||
expiresAt: Date.now() + response.data.expires_in * 1000
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to get access token: ${
|
||||
(e?.response?.data as { error_description?: string })?.error_description || "Unknown error"
|
||||
}`
|
||||
});
|
||||
} else if (e instanceof BadRequestError) {
|
||||
throw e;
|
||||
} else {
|
||||
logger.error(
|
||||
e,
|
||||
"validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication"
|
||||
);
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get access token"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}`
|
||||
|
||||
@@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z.
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({
|
||||
tenantId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Tenant ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId),
|
||||
clientId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Client ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId),
|
||||
certificateBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Certificate body required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Private Key required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
@@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
tenantId: z.string(),
|
||||
certificateBody: z.string(),
|
||||
privateKey: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z
|
||||
@@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin
|
||||
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
method: z
|
||||
.literal(AzureClientSecretsConnectionMethod.Certificate)
|
||||
.describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method),
|
||||
credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z
|
||||
credentials: z
|
||||
.union([
|
||||
AzureClientSecretsConnectionOAuthInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionCertificateInputCredentialsSchema
|
||||
])
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
|
||||
@@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection(
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
|
||||
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
})
|
||||
])
|
||||
);
|
||||
@@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion(
|
||||
clientId: true,
|
||||
tenantId: true
|
||||
})
|
||||
}),
|
||||
BaseAzureClientSecretsConnectionSchema.extend({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({
|
||||
tenantId: true,
|
||||
clientId: true
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
AzureClientSecretsConnectionCertificateOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionSchema,
|
||||
@@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export type TAzureClientSecretsConnectionCertificateCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export interface ExchangeCodeAzureResponse {
|
||||
token_type: string;
|
||||
scope: string;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -66,29 +66,72 @@ Infisical currently only supports two methods for connecting to Azure, which are
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Client Secret Authentication">
|
||||
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
||||
|
||||
**Prerequisites:**
|
||||
- An active Azure setup.
|
||||
<AccordionGroup>
|
||||
|
||||
<Steps>
|
||||
<Step title="Assign API permissions to the application">
|
||||
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
||||
<Accordion title="Client Secret Authentication">
|
||||
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
||||
|
||||
**Prerequisites:**
|
||||
- An active Azure setup.
|
||||
|
||||
<Steps>
|
||||
<Step title="Assign API permissions to the application">
|
||||
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
||||
|
||||
#### Required API Permissions
|
||||
|
||||
**Microsoft Graph**
|
||||
- `Application.ReadWrite.All`
|
||||
- `Application.ReadWrite.OwnedBy`
|
||||
- `Application.ReadWrite.All` (Delegated)
|
||||
- `Directory.ReadWrite.All` (Delegated)
|
||||
- `User.Read` (Delegated)
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
</Accordion>
|
||||
<Accordion title="Certificate Authentication">
|
||||
Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work.
|
||||
|
||||
**Prerequisites:**
|
||||
- An active Azure setup.
|
||||
|
||||
<Steps>
|
||||
<Step title="Assign API permissions to the application">
|
||||
For the Azure Client Secrets connection to work, assign the following permissions to your Azure application:
|
||||
|
||||
#### Required API Permissions
|
||||
|
||||
**Microsoft Graph**
|
||||
- `Application.ReadWrite.All`
|
||||
- `Application.ReadWrite.OwnedBy`
|
||||
- `Application.ReadWrite.All` (Delegated)
|
||||
- `Directory.ReadWrite.All` (Delegated)
|
||||
- `User.Read` (Delegated)
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Upload your certificate to your Azure App Registration">
|
||||
Navigate to the **Certificates & secrets** section of your Azure App Registration, and press the **Upload certificate** button.
|
||||
|
||||
Select the **Upload** button and upload your certificate.
|
||||
|
||||

|
||||
|
||||
<Tip>
|
||||
Keep in mind that both the certificate and its private key are required to configure the Azure Client Secrets connection in Infisical.
|
||||
</Tip>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
#### Required API Permissions
|
||||
|
||||
**Microsoft Graph**
|
||||
- `Application.ReadWrite.All`
|
||||
- `Application.ReadWrite.OwnedBy`
|
||||
- `Application.ReadWrite.All` (Delegated)
|
||||
- `Directory.ReadWrite.All` (Delegated)
|
||||
- `User.Read` (Delegated)
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
</Accordion>
|
||||
|
||||
## Setup Azure Connection in Infisical
|
||||
|
||||
@@ -123,6 +166,17 @@ Infisical currently only supports two methods for connecting to Azure, which are
|
||||

|
||||
</Step>
|
||||
</Tab>
|
||||
<Tab title="Certificate">
|
||||
<Step title="Create Connection">
|
||||
Fill in the **Tenant ID**, **Client ID**, **Certificate (PEM format)**, and **Private Key** fields with the Directory (Tenant) ID, Application (Client) ID, Certificate and Private Key you obtained in the [previous step](#certificate-authentication).
|
||||
|
||||
<Tip>
|
||||
The private key is never transmitted to Azure, and it is only used to sign the client assertion used to authenticate with Azure.
|
||||
</Tip>
|
||||
|
||||

|
||||
</Step>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
<Step title="Connection Created">
|
||||
|
||||
@@ -12,12 +12,14 @@ const syntaxHighlight = (
|
||||
isVisible?: boolean,
|
||||
isImport?: boolean,
|
||||
isLoadingValue?: boolean,
|
||||
isErrorLoadingValue?: boolean
|
||||
isErrorLoadingValue?: boolean,
|
||||
placeholder?: string
|
||||
) => {
|
||||
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
||||
if (isErrorLoadingValue)
|
||||
return <span className="ph-no-capture text-red/75">Error loading secret value.</span>;
|
||||
if (isImport && !content) return "IMPORTED";
|
||||
if (placeholder && (content === "" || !content)) return placeholder;
|
||||
if (content === "") return "EMPTY";
|
||||
if (!content) return "EMPTY";
|
||||
if (!isVisible) return HIDDEN_SECRET_VALUE;
|
||||
@@ -79,6 +81,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
canEditButNotView,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue,
|
||||
placeholder,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -93,18 +96,25 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
<div className="relative overflow-hidden">
|
||||
<pre aria-hidden className="m-0">
|
||||
<code className={`inline-block w-full ${commonClassName}`}>
|
||||
<span style={{ whiteSpace: "break-spaces" }}>
|
||||
<span
|
||||
className={twMerge(
|
||||
"whitespace-break-spaces",
|
||||
placeholder && !value && "text-gray-500/50"
|
||||
)}
|
||||
>
|
||||
{syntaxHighlight(
|
||||
value,
|
||||
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
||||
isImport,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue
|
||||
isErrorLoadingValue,
|
||||
placeholder
|
||||
)}
|
||||
</span>
|
||||
</code>
|
||||
</pre>
|
||||
<textarea
|
||||
placeholder={placeholder}
|
||||
style={{ whiteSpace: "break-spaces" }}
|
||||
aria-label="secret value"
|
||||
ref={ref}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { faGithub, IconDefinition } from "@fortawesome/free-brands-svg-icons";
|
||||
import {
|
||||
faBullseye,
|
||||
faCertificate,
|
||||
faKey,
|
||||
faLink,
|
||||
faLock,
|
||||
@@ -211,6 +212,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
|
||||
case AzureKeyVaultConnectionMethod.ClientSecret:
|
||||
case AzureDevOpsConnectionMethod.ClientSecret:
|
||||
return { name: "Client Secret", icon: faKey };
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
return { name: "Certificate", icon: faCertificate };
|
||||
default:
|
||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
|
||||
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
export type TAzureClientSecretsConnection = TRootAppConnection & {
|
||||
@@ -24,4 +25,13 @@ export type TAzureClientSecretsConnection = TRootAppConnection & {
|
||||
tenantId: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
method: AzureClientSecretsConnectionMethod.Certificate;
|
||||
credentials: {
|
||||
clientId: string;
|
||||
tenantId: string;
|
||||
certificateBody: string;
|
||||
privateKey: string;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -6,7 +6,15 @@ import { Controller, FormProvider, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
ModalClose,
|
||||
SecretInput,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
APP_CONNECTION_MAP,
|
||||
getAppConnectionMethodDetails,
|
||||
@@ -27,10 +35,13 @@ import {
|
||||
} from "./GenericAppConnectionFields";
|
||||
|
||||
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
|
||||
type CertificateForm = z.infer<typeof certificateSchema>;
|
||||
|
||||
type TInputFormData = ClientSecretForm | CertificateForm;
|
||||
|
||||
type Props = {
|
||||
appConnection?: TAzureClientSecretsConnection;
|
||||
onSubmit: (formData: ClientSecretForm) => Promise<void>;
|
||||
onSubmit: (formData: TInputFormData) => Promise<void>;
|
||||
projectId: string | undefined | null;
|
||||
};
|
||||
|
||||
@@ -53,7 +64,21 @@ const clientSecretSchema = baseSchema.extend({
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
|
||||
const certificateSchema = baseSchema.extend({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: z.object({
|
||||
clientId: z.string().trim().min(1, "Client ID is required"),
|
||||
certificateBody: z.string().trim().min(1, "Certificate is required"),
|
||||
privateKey: z.string().trim().min(1, "Private Key is required"),
|
||||
tenantId: z.string().trim().min(1, "Tenant ID is required")
|
||||
})
|
||||
});
|
||||
|
||||
const formSchema = z.discriminatedUnion("method", [
|
||||
oauthSchema,
|
||||
clientSecretSchema,
|
||||
certificateSchema
|
||||
]);
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -96,6 +121,20 @@ const getDefaultValues = (appConnection?: TAzureClientSecretsConnection): Partia
|
||||
};
|
||||
}
|
||||
break;
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
if ("clientId" in credentials && "tenantId" in credentials) {
|
||||
return {
|
||||
...base,
|
||||
method: AzureClientSecretsConnectionMethod.Certificate,
|
||||
credentials: {
|
||||
clientId: credentials.clientId,
|
||||
tenantId: credentials.tenantId,
|
||||
certificateBody: "",
|
||||
privateKey: ""
|
||||
}
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
@@ -152,6 +191,9 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
case AzureClientSecretsConnectionMethod.Certificate:
|
||||
await onSubmit(formData);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
|
||||
}
|
||||
@@ -207,7 +249,11 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="tenantId"
|
||||
name={
|
||||
selectedMethod === AzureClientSecretsConnectionMethod.OAuth
|
||||
? "tenantId"
|
||||
: "credentials.tenantId"
|
||||
}
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
@@ -264,6 +310,59 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit, proj
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedMethod === AzureClientSecretsConnectionMethod.Certificate && (
|
||||
<>
|
||||
<Controller
|
||||
name="credentials.clientId"
|
||||
control={control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Client ID"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="00000000-0000-0000-0000-000000000000" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.certificateBody"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Certificate"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE-----..."
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="credentials.privateKey"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error?.message)}
|
||||
label="Private Key"
|
||||
errorText={error?.message}
|
||||
>
|
||||
<SecretInput
|
||||
placeholder="-----BEGIN PRIVATE KEY-----..."
|
||||
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
Reference in New Issue
Block a user