Add Azure Client Secrets Auth to Azure App Connections

This commit is contained in:
Carlos Monastyrski
2025-07-29 22:05:28 -03:00
parent 9e76fa8230
commit 359e19f804
28 changed files with 1116 additions and 308 deletions
+11 -3
View File
@@ -2253,7 +2253,9 @@ export const AppConnections = {
AZURE_DEVOPS: { AZURE_DEVOPS: {
code: "The OAuth code to use to connect with Azure DevOps.", code: "The OAuth code to use to connect with Azure DevOps.",
tenantId: "The Tenant ID to use to connect with Azure DevOps.", tenantId: "The Tenant ID to use to connect with Azure DevOps.",
orgName: "The Organization name to use to connect with Azure DevOps." orgName: "The Organization name to use to connect with Azure DevOps.",
clientId: "The Client ID to use to connect with Azure Client Secrets.",
clientSecret: "The Client Secret to use to connect with Azure Client Secrets."
}, },
OCI: { OCI: {
userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.", userOcid: "The OCID (Oracle Cloud Identifier) of the user making the request.",
@@ -2400,12 +2402,18 @@ export const SecretSyncs = {
env: "The name of the GitHub environment." env: "The name of the GitHub environment."
}, },
AZURE_KEY_VAULT: { AZURE_KEY_VAULT: {
vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/" vaultBaseUrl: "The base URL of the Azure Key Vault to sync secrets to. Example: https://example.vault.azure.net/",
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."
}, },
AZURE_APP_CONFIGURATION: { AZURE_APP_CONFIGURATION: {
configurationUrl: configurationUrl:
"The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/", "The URL of the Azure App Configuration to sync secrets to. Example: https://example.azconfig.io/",
label: "An optional label to assign to secrets created in Azure App Configuration." label: "An optional label to assign to secrets created in Azure App Configuration.",
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."
}, },
AZURE_DEVOPS: { AZURE_DEVOPS: {
devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.", devopsProjectId: "The ID of the Azure DevOps project to sync secrets to.",
+4 -4
View File
@@ -496,7 +496,7 @@ export const overwriteSchema: {
] ]
}, },
azureAppConfiguration: { azureAppConfiguration: {
name: "Azure App Configuration", name: "Azure App Connection: App Configuration",
fields: [ fields: [
{ {
key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID", key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID",
@@ -509,7 +509,7 @@ export const overwriteSchema: {
] ]
}, },
azureKeyVault: { azureKeyVault: {
name: "Azure Key Vault", name: "Azure App Connection: Key Vault",
fields: [ fields: [
{ {
key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID", key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID",
@@ -522,7 +522,7 @@ export const overwriteSchema: {
] ]
}, },
azureClientSecrets: { azureClientSecrets: {
name: "Azure Client Secrets", name: "Azure App Connection: Client Secrets",
fields: [ fields: [
{ {
key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID", key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID",
@@ -535,7 +535,7 @@ export const overwriteSchema: {
] ]
}, },
azureDevOps: { azureDevOps: {
name: "Azure DevOps", name: "Azure App Connection: DevOps",
fields: [ fields: [
{ {
key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID", key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID",
@@ -1,3 +1,4 @@
export enum AzureAppConfigurationConnectionMethod { export enum AzureAppConfigurationConnectionMethod {
OAuth = "oauth" OAuth = "oauth",
ClientSecret = "client-secret"
} }
@@ -1,3 +1,4 @@
/* eslint-disable no-case-declarations */
import { AxiosError, AxiosResponse } from "axios"; import { AxiosError, AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
@@ -19,7 +20,10 @@ export const getAzureAppConfigurationConnectionListItem = () => {
return { return {
name: "Azure App Configuration" as const, name: "Azure App Configuration" as const,
app: AppConnection.AzureAppConfiguration as const, app: AppConnection.AzureAppConfiguration as const,
methods: Object.values(AzureAppConfigurationConnectionMethod) as [AzureAppConfigurationConnectionMethod.OAuth], methods: Object.values(AzureAppConfigurationConnectionMethod) as [
AzureAppConfigurationConnectionMethod.OAuth,
AzureAppConfigurationConnectionMethod.ClientSecret
],
oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID
}; };
}; };
@@ -35,71 +39,111 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
SITE_URL SITE_URL
} = getConfig(); } = getConfig();
if (
!INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID ||
!INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET
) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
try {
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: inputCredentials.code,
scope: `openid offline_access https://azconfig.io/.default`,
client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID,
client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
switch (method) { switch (method) {
case AzureAppConfigurationConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth:
if (
!INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID ||
!INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET
) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
const oauthCredentials = inputCredentials as { code: string; tenantId?: string };
try {
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: oauthCredentials.code,
scope: `openid offline_access https://azconfig.io/.default`,
client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID,
client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
return { return {
tenantId: inputCredentials.tenantId, tenantId: oauthCredentials.tenantId,
accessToken: tokenResp.data.access_token, accessToken: tokenResp.data.access_token,
refreshToken: tokenResp.data.refresh_token, refreshToken: tokenResp.data.refresh_token,
expiresAt: Date.now() + tokenResp.data.expires_in * 1000 expiresAt: Date.now() + tokenResp.data.expires_in * 1000
}; };
case AzureAppConfigurationConnectionMethod.ClientSecret:
const { tenantId, clientId, clientSecret } = inputCredentials as {
tenantId: string;
clientId: string;
clientSecret: string;
};
try {
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
new URLSearchParams({
grant_type: "client_credentials",
scope: `https://azconfig.io/.default`,
client_id: clientId,
client_secret: clientSecret
})
);
return {
tenantId,
accessToken: clientData.access_token,
expiresAt: Date.now() + clientData.expires_in * 1000,
clientId,
clientSecret
};
} 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 {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
default: default:
throw new InternalServerError({ throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureAppConfigurationConnectionMethod}` message: `Unhandled Azure App Configuration connection method: ${method as AzureAppConfigurationConnectionMethod}`
}); });
} }
}; };
@@ -22,6 +22,29 @@ export const AzureAppConfigurationConnectionOAuthOutputCredentialsSchema = z.obj
expiresAt: z.number() expiresAt: z.number()
}); });
export const AzureAppConfigurationConnectionClientSecretInputCredentialsSchema = z.object({
clientId: z
.string()
.uuid()
.trim()
.min(1, "Client ID required")
.max(50, "Client ID must be at most 50 characters long"),
clientSecret: z
.string()
.trim()
.min(1, "Client Secret required")
.max(50, "Client Secret must be at most 50 characters long"),
tenantId: z.string().uuid().trim().min(1, "Tenant ID required")
});
export const AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
tenantId: z.string(),
accessToken: z.string(),
expiresAt: z.number()
});
export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discriminatedUnion("method", [ export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({ z.object({
method: z method: z
@@ -30,6 +53,14 @@ export const ValidateAzureAppConfigurationConnectionCredentialsSchema = z.discri
credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.describe( credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials
) )
}),
z.object({
method: z
.literal(AzureAppConfigurationConnectionMethod.ClientSecret)
.describe(AppConnections.CREATE(AppConnection.AzureAppConfiguration).method),
credentials: AzureAppConfigurationConnectionClientSecretInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureAppConfiguration).credentials
)
}) })
]); ]);
@@ -39,9 +70,13 @@ export const CreateAzureAppConfigurationConnectionSchema = ValidateAzureAppConfi
export const UpdateAzureAppConfigurationConnectionSchema = z export const UpdateAzureAppConfigurationConnectionSchema = z
.object({ .object({
credentials: AzureAppConfigurationConnectionOAuthInputCredentialsSchema.optional().describe( credentials: z
AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials .union([
) AzureAppConfigurationConnectionOAuthInputCredentialsSchema,
AzureAppConfigurationConnectionClientSecretInputCredentialsSchema
])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AzureAppConfiguration).credentials)
}) })
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureAppConfiguration)); .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureAppConfiguration));
@@ -55,6 +90,10 @@ export const AzureAppConfigurationConnectionSchema = z.intersection(
z.object({ z.object({
method: z.literal(AzureAppConfigurationConnectionMethod.OAuth), method: z.literal(AzureAppConfigurationConnectionMethod.OAuth),
credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema
}),
z.object({
method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret),
credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema
}) })
]) ])
); );
@@ -65,6 +104,13 @@ export const SanitizedAzureAppConfigurationConnectionSchema = z.discriminatedUni
credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({ credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({
tenantId: true tenantId: true
}) })
}),
BaseAzureAppConfigurationConnectionSchema.extend({
method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret),
credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema.pick({
clientId: true,
tenantId: true
})
}) })
]); ]);
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { import {
AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema,
AzureAppConfigurationConnectionOAuthOutputCredentialsSchema, AzureAppConfigurationConnectionOAuthOutputCredentialsSchema,
AzureAppConfigurationConnectionSchema, AzureAppConfigurationConnectionSchema,
CreateAzureAppConfigurationConnectionSchema, CreateAzureAppConfigurationConnectionSchema,
@@ -39,3 +40,7 @@ export type ExchangeCodeAzureResponse = {
export type TAzureAppConfigurationConnectionCredentials = z.infer< export type TAzureAppConfigurationConnectionCredentials = z.infer<
typeof AzureAppConfigurationConnectionOAuthOutputCredentialsSchema typeof AzureAppConfigurationConnectionOAuthOutputCredentialsSchema
>; >;
export type TAzureAppConfigurationConnectionClientSecretCredentials = z.infer<
typeof AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema
>;
@@ -1,4 +1,5 @@
export enum AzureDevOpsConnectionMethod { export enum AzureDevOpsConnectionMethod {
OAuth = "oauth", OAuth = "oauth",
AccessToken = "access-token" AccessToken = "access-token",
ClientSecret = "client-secret"
} }
@@ -18,6 +18,7 @@ import { AppConnection } from "../app-connection-enums";
import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; import { AzureDevOpsConnectionMethod } from "./azure-devops-enums";
import { import {
ExchangeCodeAzureResponse, ExchangeCodeAzureResponse,
TAzureDevOpsConnectionClientSecretCredentials,
TAzureDevOpsConnectionConfig, TAzureDevOpsConnectionConfig,
TAzureDevOpsConnectionCredentials TAzureDevOpsConnectionCredentials
} from "./azure-devops-types"; } from "./azure-devops-types";
@@ -30,7 +31,8 @@ export const getAzureDevopsConnectionListItem = () => {
app: AppConnection.AzureDevOps as const, app: AppConnection.AzureDevOps as const,
methods: Object.values(AzureDevOpsConnectionMethod) as [ methods: Object.values(AzureDevOpsConnectionMethod) as [
AzureDevOpsConnectionMethod.OAuth, AzureDevOpsConnectionMethod.OAuth,
AzureDevOpsConnectionMethod.AccessToken AzureDevOpsConnectionMethod.AccessToken,
AzureDevOpsConnectionMethod.ClientSecret
], ],
oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID
}; };
@@ -53,11 +55,7 @@ export const getAzureDevopsConnection = async (
}); });
} }
const credentials = (await decryptAppConnectionCredentials({ const currentTime = Date.now();
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureDevOpsConnectionCredentials;
// Handle different connection methods // Handle different connection methods
switch (appConnection.method) { switch (appConnection.method) {
@@ -69,12 +67,17 @@ export const getAzureDevopsConnection = async (
}); });
} }
if (!("refreshToken" in credentials)) { const oauthCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureDevOpsConnectionCredentials;
if (!("refreshToken" in oauthCredentials)) {
throw new BadRequestError({ message: "Invalid OAuth credentials" }); throw new BadRequestError({ message: "Invalid OAuth credentials" });
} }
const { refreshToken, tenantId } = credentials; const { refreshToken, tenantId } = oauthCredentials;
const currentTime = Date.now();
const { data } = await request.post<ExchangeCodeAzureResponse>( const { data } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
@@ -87,29 +90,75 @@ export const getAzureDevopsConnection = async (
}) })
); );
const updatedCredentials = { const updatedOAuthCredentials = {
...credentials, ...oauthCredentials,
accessToken: data.access_token, accessToken: data.access_token,
expiresAt: currentTime + data.expires_in * 1000, expiresAt: currentTime + data.expires_in * 1000,
refreshToken: data.refresh_token refreshToken: data.refresh_token
}; };
const encryptedCredentials = await encryptAppConnectionCredentials({ const encryptedOAuthCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials, credentials: updatedOAuthCredentials,
orgId: appConnection.orgId, orgId: appConnection.orgId,
kmsService kmsService
}); });
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials });
return data.access_token; return data.access_token;
case AzureDevOpsConnectionMethod.AccessToken: case AzureDevOpsConnectionMethod.AccessToken:
if (!("accessToken" in credentials)) { const accessTokenCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as { accessToken: string };
if (!("accessToken" in accessTokenCredentials)) {
throw new BadRequestError({ message: "Invalid API token credentials" }); throw new BadRequestError({ message: "Invalid API token credentials" });
} }
// For access token, return the basic auth token directly // For access token, return the basic auth token directly
return credentials.accessToken; return accessTokenCredentials.accessToken;
case AzureDevOpsConnectionMethod.ClientSecret:
const clientSecretCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureDevOpsConnectionClientSecretCredentials;
const { accessToken, expiresAt, clientId, clientSecret, tenantId: clientTenantId } = clientSecretCredentials;
// Check if token is still valid (with 5 minute buffer)
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
return accessToken;
}
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", clientTenantId || "common"),
new URLSearchParams({
grant_type: "client_credentials",
scope: `https://app.vssps.visualstudio.com/.default`,
client_id: clientId,
client_secret: clientSecret
})
);
const updatedClientCredentials = {
...clientSecretCredentials,
accessToken: clientData.access_token,
expiresAt: currentTime + clientData.expires_in * 1000
};
const encryptedClientCredentials = await encryptAppConnectionCredentials({
credentials: updatedClientCredentials,
orgId: appConnection.orgId,
kmsService
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
return clientData.access_token;
default: default:
throw new BadRequestError({ message: `Unsupported connection method` }); throw new BadRequestError({ message: `Unsupported connection method` });
@@ -138,7 +187,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev
let tokenError: AxiosError | null = null; let tokenError: AxiosError | null = null;
try { try {
const oauthCredentials = inputCredentials as { code: string; tenantId: string }; const oauthCredentials = inputCredentials as { code: string; tenantId: string; orgName: string };
tokenResp = await request.post<ExchangeCodeAzureResponse>( tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"), IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
new URLSearchParams({ new URLSearchParams({
@@ -262,9 +311,67 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev
}); });
} }
case AzureDevOpsConnectionMethod.ClientSecret:
const { tenantId, clientId, clientSecret, orgName } = inputCredentials as {
tenantId: string;
clientId: string;
clientSecret: string;
orgName: string;
};
try {
// First, get the access token using client credentials flow
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
new URLSearchParams({
grant_type: "client_credentials",
scope: `https://app.vssps.visualstudio.com/.default`,
client_id: clientId,
client_secret: clientSecret
})
);
// Validate access to the specific organization
const response = await request.get(
`${IntegrationUrls.AZURE_DEVOPS_API_URL}/${encodeURIComponent(orgName)}/_apis/projects?api-version=7.2-preview.2&$top=1`,
{
headers: {
Authorization: `Bearer ${clientData.access_token}`
}
}
);
if (response.status !== 200) {
throw new BadRequestError({
message: `Failed to validate connection to organization '${orgName}': ${response.status}`
});
}
return {
tenantId,
clientId,
clientSecret,
orgName,
accessToken: clientData.access_token,
expiresAt: Date.now() + clientData.expires_in * 1000
};
} catch (e: unknown) {
if (e instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to authenticate with Azure DevOps using client credentials: ${
(e?.response?.data as { error_description?: string })?.error_description || e.message
}`
});
} else {
throw new InternalServerError({
message: "Failed to validate Azure DevOps client credentials"
});
}
}
default: default:
throw new InternalServerError({ throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureDevOpsConnectionMethod}` message: `Unhandled Azure DevOps connection method: ${method as AzureDevOpsConnectionMethod}`
}); });
} }
}; };
@@ -38,6 +38,42 @@ export const AzureDevOpsConnectionAccessTokenOutputCredentialsSchema = z.object(
orgName: z.string() orgName: z.string()
}); });
export const AzureDevOpsConnectionClientSecretInputCredentialsSchema = z.object({
clientId: z
.string()
.uuid()
.trim()
.min(1, "Client ID required")
.max(50, "Client ID must be at most 50 characters long")
.describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientId),
clientSecret: z
.string()
.trim()
.min(1, "Client Secret required")
.max(50, "Client Secret must be at most 50 characters long")
.describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.clientSecret),
tenantId: z
.string()
.uuid()
.trim()
.min(1, "Tenant ID required")
.describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.tenantId),
orgName: z
.string()
.trim()
.min(1, "Organization name required")
.describe(AppConnections.CREDENTIALS.AZURE_DEVOPS.orgName)
});
export const AzureDevOpsConnectionClientSecretOutputCredentialsSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
tenantId: z.string(),
orgName: z.string(),
accessToken: z.string(),
expiresAt: z.number()
});
export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [ export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({ z.object({
method: z method: z
@@ -54,6 +90,14 @@ export const ValidateAzureDevOpsConnectionCredentialsSchema = z.discriminatedUni
credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe( credentials: AzureDevOpsConnectionAccessTokenInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureDevOps).credentials AppConnections.CREATE(AppConnection.AzureDevOps).credentials
) )
}),
z.object({
method: z
.literal(AzureDevOpsConnectionMethod.ClientSecret)
.describe(AppConnections.CREATE(AppConnection.AzureDevOps).method),
credentials: AzureDevOpsConnectionClientSecretInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureDevOps).credentials
)
}) })
]); ]);
@@ -64,7 +108,11 @@ export const CreateAzureDevOpsConnectionSchema = ValidateAzureDevOpsConnectionCr
export const UpdateAzureDevOpsConnectionSchema = z export const UpdateAzureDevOpsConnectionSchema = z
.object({ .object({
credentials: z credentials: z
.union([AzureDevOpsConnectionOAuthInputCredentialsSchema, AzureDevOpsConnectionAccessTokenInputCredentialsSchema]) .union([
AzureDevOpsConnectionOAuthInputCredentialsSchema,
AzureDevOpsConnectionAccessTokenInputCredentialsSchema,
AzureDevOpsConnectionClientSecretInputCredentialsSchema
])
.optional() .optional()
.describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials) .describe(AppConnections.UPDATE(AppConnection.AzureDevOps).credentials)
}) })
@@ -84,6 +132,10 @@ export const AzureDevOpsConnectionSchema = z.intersection(
z.object({ z.object({
method: z.literal(AzureDevOpsConnectionMethod.AccessToken), method: z.literal(AzureDevOpsConnectionMethod.AccessToken),
credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema
}),
z.object({
method: z.literal(AzureDevOpsConnectionMethod.ClientSecret),
credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema
}) })
]) ])
); );
@@ -101,6 +153,14 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method
credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({
orgName: true orgName: true
}) })
}),
BaseAzureDevOpsConnectionSchema.extend({
method: z.literal(AzureDevOpsConnectionMethod.ClientSecret),
credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema.pick({
clientId: true,
tenantId: true,
orgName: true
})
}) })
]); ]);
@@ -52,6 +52,11 @@ const getAuthHeaders = (appConnection: TAzureDevOpsConnection, accessToken: stri
Authorization: `Basic ${basicAuthToken}`, Authorization: `Basic ${basicAuthToken}`,
Accept: "application/json" Accept: "application/json"
}; };
case AzureDevOpsConnectionMethod.ClientSecret:
return {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json"
};
default: default:
throw new BadRequestError({ message: "Unsupported connection method" }); throw new BadRequestError({ message: "Unsupported connection method" });
} }
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { import {
AzureDevOpsConnectionClientSecretOutputCredentialsSchema,
AzureDevOpsConnectionOAuthOutputCredentialsSchema, AzureDevOpsConnectionOAuthOutputCredentialsSchema,
AzureDevOpsConnectionSchema, AzureDevOpsConnectionSchema,
CreateAzureDevOpsConnectionSchema, CreateAzureDevOpsConnectionSchema,
@@ -27,6 +28,10 @@ export type TAzureDevOpsConnectionConfig = DiscriminativePick<
export type TAzureDevOpsConnectionCredentials = z.infer<typeof AzureDevOpsConnectionOAuthOutputCredentialsSchema>; export type TAzureDevOpsConnectionCredentials = z.infer<typeof AzureDevOpsConnectionOAuthOutputCredentialsSchema>;
export type TAzureDevOpsConnectionClientSecretCredentials = z.infer<
typeof AzureDevOpsConnectionClientSecretOutputCredentialsSchema
>;
export interface ExchangeCodeAzureResponse { export interface ExchangeCodeAzureResponse {
token_type: string; token_type: string;
scope: string; scope: string;
@@ -1,3 +1,4 @@
export enum AzureKeyVaultConnectionMethod { export enum AzureKeyVaultConnectionMethod {
OAuth = "oauth" OAuth = "oauth",
ClientSecret = "client-secret"
} }
@@ -1,3 +1,4 @@
/* eslint-disable no-case-declarations */
import { AxiosError, AxiosResponse } from "axios"; import { AxiosError, AxiosResponse } from "axios";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
@@ -16,25 +17,16 @@ import { AppConnection } from "../app-connection-enums";
import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums"; import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums";
import { import {
ExchangeCodeAzureResponse, ExchangeCodeAzureResponse,
TAzureKeyVaultConnectionClientSecretCredentials,
TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionConfig,
TAzureKeyVaultConnectionCredentials TAzureKeyVaultConnectionCredentials
} from "./azure-key-vault-connection-types"; } from "./azure-key-vault-connection-types";
export const getAzureConnectionAccessToken = async ( export const getAzureConnectionAccessToken = async (
connectionId: string, connectionId: string,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">, appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey"> kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => { ) => {
const appCfg = getConfig();
if (
!appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID ||
!appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET
) {
throw new BadRequestError({
message: `Azure environment variables have not been configured`
});
}
const appConnection = await appConnectionDAL.findById(connectionId); const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) { if (!appConnection) {
@@ -49,49 +41,101 @@ export const getAzureConnectionAccessToken = async (
throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` }); throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not a valid Azure connection` });
} }
const credentials = (await decryptAppConnectionCredentials({ const currentTime = Date.now();
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureKeyVaultConnectionCredentials;
const { data } = await request.post<ExchangeCodeAzureResponse>( switch (appConnection.method) {
IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), case AzureKeyVaultConnectionMethod.OAuth:
new URLSearchParams({ const appCfg = getConfig();
grant_type: "refresh_token", if (
scope: `openid offline_access`, !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID ||
client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET
client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, ) {
refresh_token: credentials.refreshToken throw new BadRequestError({
}) message: `Azure environment variables have not been configured`
); });
}
const accessExpiresAt = new Date(); const oauthCredentials = (await decryptAppConnectionCredentials({
accessExpiresAt.setSeconds(accessExpiresAt.getSeconds() + data.expires_in); orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureKeyVaultConnectionCredentials;
const updatedCredentials = { const { data } = await request.post<ExchangeCodeAzureResponse>(
...credentials, IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
accessToken: data.access_token, new URLSearchParams({
expiresAt: accessExpiresAt.getTime(), grant_type: "refresh_token",
refreshToken: data.refresh_token scope: `openid offline_access https://vault.azure.net/.default`,
}; client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID,
client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET,
refresh_token: oauthCredentials.refreshToken
})
);
const encryptedCredentials = await encryptAppConnectionCredentials({ const updatedOAuthCredentials = {
credentials: updatedCredentials, ...oauthCredentials,
orgId: appConnection.orgId, accessToken: data.access_token,
kmsService expiresAt: currentTime + data.expires_in * 1000,
}); refreshToken: data.refresh_token
};
await appConnectionDAL.update( const encryptedOAuthCredentials = await encryptAppConnectionCredentials({
{ id: connectionId }, credentials: updatedOAuthCredentials,
{ orgId: appConnection.orgId,
encryptedCredentials kmsService
} });
);
return { await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedOAuthCredentials });
accessToken: data.access_token
}; return {
accessToken: data.access_token
};
case AzureKeyVaultConnectionMethod.ClientSecret:
const clientSecretCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as TAzureKeyVaultConnectionClientSecretCredentials;
const { accessToken, expiresAt, clientId, clientSecret, tenantId } = clientSecretCredentials;
// Check if token is still valid (with 5 minute buffer)
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
return { accessToken };
}
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
new URLSearchParams({
grant_type: "client_credentials",
scope: `https://vault.azure.net/.default`,
client_id: clientId,
client_secret: clientSecret
})
);
const updatedClientCredentials = {
...clientSecretCredentials,
accessToken: clientData.access_token,
expiresAt: currentTime + clientData.expires_in * 1000
};
const encryptedClientCredentials = await encryptAppConnectionCredentials({
credentials: updatedClientCredentials,
orgId: appConnection.orgId,
kmsService
});
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
return { accessToken: clientData.access_token };
default:
throw new InternalServerError({
message: `Unhandled Azure Key Vault connection method: ${appConnection.method as AzureKeyVaultConnectionMethod}`
});
}
}; };
export const getAzureKeyVaultConnectionListItem = () => { export const getAzureKeyVaultConnectionListItem = () => {
@@ -100,7 +144,10 @@ export const getAzureKeyVaultConnectionListItem = () => {
return { return {
name: "Azure Key Vault" as const, name: "Azure Key Vault" as const,
app: AppConnection.AzureKeyVault as const, app: AppConnection.AzureKeyVault as const,
methods: Object.values(AzureKeyVaultConnectionMethod) as [AzureKeyVaultConnectionMethod.OAuth], methods: Object.values(AzureKeyVaultConnectionMethod) as [
AzureKeyVaultConnectionMethod.OAuth,
AzureKeyVaultConnectionMethod.ClientSecret
],
oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID
}; };
}; };
@@ -111,68 +158,108 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } = const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } =
getConfig(); getConfig();
if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
try {
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: inputCredentials.code,
scope: `openid offline_access https://vault.azure.net/.default`,
client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID,
client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
switch (method) { switch (method) {
case AzureKeyVaultConnectionMethod.OAuth: case AzureKeyVaultConnectionMethod.OAuth:
if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) {
throw new InternalServerError({
message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured`
});
}
let tokenResp: AxiosResponse<ExchangeCodeAzureResponse> | null = null;
let tokenError: AxiosError | null = null;
const oauthCredentials = inputCredentials as { code: string; tenantId?: string };
try {
tokenResp = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", oauthCredentials.tenantId || "common"),
new URLSearchParams({
grant_type: "authorization_code",
code: oauthCredentials.code,
scope: `openid offline_access https://vault.azure.net/.default`,
client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID,
client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET,
redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback`
})
);
} catch (e: unknown) {
if (e instanceof AxiosError) {
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
});
}
}
if (tokenError) {
if (tokenError instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get access token: ${
(tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error"
}`
});
} else {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
if (!tokenResp) {
throw new InternalServerError({
message: `Failed to get access token: Token was empty with no error`
});
}
return { return {
tenantId: inputCredentials.tenantId, tenantId: oauthCredentials.tenantId,
accessToken: tokenResp.data.access_token, accessToken: tokenResp.data.access_token,
refreshToken: tokenResp.data.refresh_token, refreshToken: tokenResp.data.refresh_token,
expiresAt: Date.now() + tokenResp.data.expires_in * 1000 expiresAt: Date.now() + tokenResp.data.expires_in * 1000
}; };
case AzureKeyVaultConnectionMethod.ClientSecret:
const { tenantId, clientId, clientSecret } = inputCredentials as {
tenantId: string;
clientId: string;
clientSecret: string;
};
try {
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"),
new URLSearchParams({
grant_type: "client_credentials",
scope: `https://vault.azure.net/.default`,
client_id: clientId,
client_secret: clientSecret
})
);
return {
tenantId,
accessToken: clientData.access_token,
expiresAt: Date.now() + clientData.expires_in * 1000,
clientId,
clientSecret
};
} 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 {
throw new InternalServerError({
message: "Failed to get access token"
});
}
}
default: default:
throw new InternalServerError({ throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureKeyVaultConnectionMethod}` message: `Unhandled Azure Key Vault connection method: ${method as AzureKeyVaultConnectionMethod}`
}); });
} }
}; };
@@ -22,6 +22,29 @@ export const AzureKeyVaultConnectionOAuthOutputCredentialsSchema = z.object({
expiresAt: z.number() expiresAt: z.number()
}); });
export const AzureKeyVaultConnectionClientSecretInputCredentialsSchema = z.object({
clientId: z
.string()
.uuid()
.trim()
.min(1, "Client ID required")
.max(50, "Client ID must be at most 50 characters long"),
clientSecret: z
.string()
.trim()
.min(1, "Client Secret required")
.max(50, "Client Secret must be at most 50 characters long"),
tenantId: z.string().uuid().trim().min(1, "Tenant ID required")
});
export const AzureKeyVaultConnectionClientSecretOutputCredentialsSchema = z.object({
clientId: z.string(),
clientSecret: z.string(),
tenantId: z.string(),
accessToken: z.string(),
expiresAt: z.number()
});
export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({ z.object({
method: z method: z
@@ -30,6 +53,14 @@ export const ValidateAzureKeyVaultConnectionCredentialsSchema = z.discriminatedU
credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.describe( credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureKeyVault).credentials AppConnections.CREATE(AppConnection.AzureKeyVault).credentials
) )
}),
z.object({
method: z
.literal(AzureKeyVaultConnectionMethod.ClientSecret)
.describe(AppConnections.CREATE(AppConnection.AzureKeyVault).method),
credentials: AzureKeyVaultConnectionClientSecretInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureKeyVault).credentials
)
}) })
]); ]);
@@ -39,9 +70,13 @@ export const CreateAzureKeyVaultConnectionSchema = ValidateAzureKeyVaultConnecti
export const UpdateAzureKeyVaultConnectionSchema = z export const UpdateAzureKeyVaultConnectionSchema = z
.object({ .object({
credentials: AzureKeyVaultConnectionOAuthInputCredentialsSchema.optional().describe( credentials: z
AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials .union([
) AzureKeyVaultConnectionOAuthInputCredentialsSchema,
AzureKeyVaultConnectionClientSecretInputCredentialsSchema
])
.optional()
.describe(AppConnections.UPDATE(AppConnection.AzureKeyVault).credentials)
}) })
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureKeyVault)); .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureKeyVault));
@@ -55,6 +90,10 @@ export const AzureKeyVaultConnectionSchema = z.intersection(
z.object({ z.object({
method: z.literal(AzureKeyVaultConnectionMethod.OAuth), method: z.literal(AzureKeyVaultConnectionMethod.OAuth),
credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema
}),
z.object({
method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret),
credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema
}) })
]) ])
); );
@@ -65,6 +104,13 @@ export const SanitizedAzureKeyVaultConnectionSchema = z.discriminatedUnion("meth
credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({ credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({
tenantId: true tenantId: true
}) })
}),
BaseAzureKeyVaultConnectionSchema.extend({
method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret),
credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema.pick({
clientId: true,
tenantId: true
})
}) })
]); ]);
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { import {
AzureKeyVaultConnectionClientSecretOutputCredentialsSchema,
AzureKeyVaultConnectionOAuthOutputCredentialsSchema, AzureKeyVaultConnectionOAuthOutputCredentialsSchema,
AzureKeyVaultConnectionSchema, AzureKeyVaultConnectionSchema,
CreateAzureKeyVaultConnectionSchema, CreateAzureKeyVaultConnectionSchema,
@@ -36,3 +37,7 @@ export type ExchangeCodeAzureResponse = {
}; };
export type TAzureKeyVaultConnectionCredentials = z.infer<typeof AzureKeyVaultConnectionOAuthOutputCredentialsSchema>; export type TAzureKeyVaultConnectionCredentials = z.infer<typeof AzureKeyVaultConnectionOAuthOutputCredentialsSchema>;
export type TAzureKeyVaultConnectionClientSecretCredentials = z.infer<
typeof AzureKeyVaultConnectionClientSecretOutputCredentialsSchema
>;
@@ -13,7 +13,7 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types"; import { TAzureAppConfigurationSyncWithCredentials } from "./azure-app-configuration-sync-types";
type TAzureAppConfigurationSyncFactoryDeps = { type TAzureAppConfigurationSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}; };
@@ -12,7 +12,7 @@ import { SecretSyncError } from "../secret-sync-errors";
import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types"; import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types";
type TAzureKeyVaultSyncFactoryDeps = { type TAzureKeyVaultSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}; };
@@ -58,6 +58,26 @@ Infisical currently only supports one method for connecting to Azure, which is O
</Steps> </Steps>
</Accordion> </Accordion>
<Accordion title="Client Secret Authentication">
To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure App Configuration resources you want to use.
**Prerequisites:**
- Set up Azure and have an existing App Configuration instance.
- The service principal must be connected to your target Azure App Configuration resource(s)
<Steps>
<Step title="Assign API permissions to the service principal">
Configure the required API permissions for your App Registration to interact with Azure App Configuration:
#### Azure App Configuration permissions
Set the API permissions of your Azure service principal to include the following Azure App Configuration permissions: `KeyValue.Delete`, `KeyValue.Read`, and `KeyValue.Write`.
![Azure app config](../../images/integrations/azure-app-configuration/app-api-permissions.png)
</Step>
</Steps>
</Accordion>
## Setup Azure Connection in Infisical ## Setup Azure Connection in Infisical
@@ -87,6 +87,32 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a
</Steps> </Steps>
</Accordion> </Accordion>
<Accordion title="Client Secret Authentication">
To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure DevOps organization and projects you want to use.
**Prerequisites:**
- Set up Azure and have an existing Azure DevOps organization.
- The service principal must be connected to your target Azure DevOps organization and project(s)
<Steps>
<Step title="Assign API permissions to the service principal">
Configure the required API permissions for your App Registration to interact with Azure DevOps:
#### Azure DevOps permissions
Set the API permissions of your Azure service principal to include the following Azure DevOps permissions:
- Azure DevOps
- `user_impersonation`
- `vso.project_write`
- `vso.variablegroups_manage`
- `vso.variablegroups_write`
![Azure devops](/images/integrations/azure-devops/app-api-permissions.png)
</Step>
</Steps>
</Accordion>
## Setup Azure Connection in Infisical ## Setup Azure Connection in Infisical
<Steps> <Steps>
@@ -58,6 +58,27 @@ Infisical currently only supports one method for connecting to Azure, which is O
</Accordion> </Accordion>
<Accordion title="Client Secret Authentication">
To use client secret authentication, ensure your Azure Service Principal has the required permissions and is connected to the Azure Key Vault instances you want to use.
**Prerequisites:**
- Set up Azure and have an existing Key Vault instance.
- The service principal must be connected to your target Azure Key Vault instance(s)
<Steps>
<Step title="Assign API permissions to the service principal">
Configure the required API permissions for your App Registration to interact with Azure Key Vault:
#### Azure Key Vault permissions
Set the API permissions of your Azure service principal to include `user_impersonation` for the Key Vault API.
![Azure key vault](/images/app-connections/azure/keyvault-azure-permissions.png)
</Step>
</Steps>
</Accordion>
## Setup Azure Connection in Infisical ## Setup Azure Connection in Infisical
<Steps> <Steps>
+3
View File
@@ -172,6 +172,9 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case ChecklyConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey:
return { name: "API Key", icon: faKey }; return { name: "API Key", icon: faKey };
case AzureClientSecretsConnectionMethod.ClientSecret: case AzureClientSecretsConnectionMethod.ClientSecret:
case AzureAppConfigurationConnectionMethod.ClientSecret:
case AzureKeyVaultConnectionMethod.ClientSecret:
case AzureDevOpsConnectionMethod.ClientSecret:
return { name: "Client Secret", icon: faKey }; return { name: "Client Secret", icon: faKey };
default: default:
throw new Error(`Unhandled App Connection Method: ${method}`); throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum AzureAppConfigurationConnectionMethod { export enum AzureAppConfigurationConnectionMethod {
OAuth = "oauth" OAuth = "oauth",
ClientSecret = "client-secret"
} }
export type TAzureAppConfigurationConnection = TRootAppConnection & { export type TAzureAppConfigurationConnection = TRootAppConnection & {
app: AppConnection.AzureAppConfiguration; app: AppConnection.AzureAppConfiguration;
} & { } & (
method: AzureAppConfigurationConnectionMethod.OAuth; | {
credentials: { method: AzureAppConfigurationConnectionMethod.OAuth;
code: string; credentials: {
tenantId?: string; code: string;
}; tenantId?: string;
}; };
}
| {
method: AzureAppConfigurationConnectionMethod.ClientSecret;
credentials: {
clientId: string;
clientSecret: string;
tenantId: string;
};
}
);
@@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con
export enum AzureDevOpsConnectionMethod { export enum AzureDevOpsConnectionMethod {
OAuth = "oauth", OAuth = "oauth",
AccessToken = "access-token" AccessToken = "access-token",
ClientSecret = "client-secret"
} }
export type TAzureDevOpsConnection = TRootAppConnection & { export type TAzureDevOpsConnection = TRootAppConnection & {
@@ -24,4 +25,13 @@ export type TAzureDevOpsConnection = TRootAppConnection & {
orgName: string; orgName: string;
}; };
} }
| {
method: AzureDevOpsConnectionMethod.ClientSecret;
credentials: {
clientSecret: string;
tenantId: string;
clientId: string;
orgName: string;
};
}
); );
@@ -2,13 +2,24 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum AzureKeyVaultConnectionMethod { export enum AzureKeyVaultConnectionMethod {
OAuth = "oauth" OAuth = "oauth",
ClientSecret = "client-secret"
} }
export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & { export type TAzureKeyVaultConnection = TRootAppConnection & { app: AppConnection.AzureKeyVault } & (
method: AzureKeyVaultConnectionMethod.OAuth; | {
credentials: { method: AzureKeyVaultConnectionMethod.OAuth;
code: string; credentials: {
tenantId?: string; code: string;
}; tenantId?: string;
}; };
}
| {
method: AzureKeyVaultConnectionMethod.ClientSecret;
credentials: {
clientId: string;
clientSecret: string;
tenantId: string;
};
}
);
@@ -92,9 +92,9 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
case AppConnection.GCP: case AppConnection.GCP:
return <GcpConnectionForm onSubmit={onSubmit} />; return <GcpConnectionForm onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault: case AppConnection.AzureKeyVault:
return <AzureKeyVaultConnectionForm />; return <AzureKeyVaultConnectionForm onSubmit={onSubmit} />;
case AppConnection.AzureAppConfiguration: case AppConnection.AzureAppConfiguration:
return <AzureAppConfigurationConnectionForm />; return <AzureAppConfigurationConnectionForm onSubmit={onSubmit} />;
case AppConnection.Databricks: case AppConnection.Databricks:
return <DatabricksConnectionForm onSubmit={onSubmit} />; return <DatabricksConnectionForm onSubmit={onSubmit} />;
case AppConnection.Humanitec: case AppConnection.Humanitec:
@@ -200,9 +200,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
case AppConnection.GCP: case AppConnection.GCP:
return <GcpConnectionForm appConnection={appConnection} onSubmit={onSubmit} />; return <GcpConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.AzureKeyVault: case AppConnection.AzureKeyVault:
return <AzureKeyVaultConnectionForm appConnection={appConnection} />; return <AzureKeyVaultConnectionForm appConnection={appConnection} onSubmit={onSubmit} />;
case AppConnection.AzureAppConfiguration: case AppConnection.AzureAppConfiguration:
return <AzureAppConfigurationConnectionForm appConnection={appConnection} />; return (
<AzureAppConfigurationConnectionForm appConnection={appConnection} onSubmit={onSubmit} />
);
case AppConnection.Databricks: case AppConnection.Databricks:
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />; return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Humanitec: case AppConnection.Humanitec:
@@ -20,19 +20,83 @@ import {
GenericAppConnectionsFields GenericAppConnectionsFields
} from "./GenericAppConnectionFields"; } from "./GenericAppConnectionFields";
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
type Props = { type Props = {
appConnection?: TAzureAppConfigurationConnection; appConnection?: TAzureAppConfigurationConnection;
onSubmit: (formData: ClientSecretForm) => Promise<void>;
}; };
const formSchema = genericAppConnectionFieldsSchema.extend({ const baseSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AzureAppConfiguration), app: z.literal(AppConnection.AzureAppConfiguration),
method: z.nativeEnum(AzureAppConfigurationConnectionMethod), method: z.nativeEnum(AzureAppConfigurationConnectionMethod)
tenantId: z.string().trim().optional()
}); });
const oauthSchema = baseSchema.extend({
tenantId: z.string().trim().min(1, "Tenant ID is required"),
method: z.literal(AzureAppConfigurationConnectionMethod.OAuth)
});
const clientSecretSchema = baseSchema.extend({
method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret),
credentials: z.object({
clientSecret: z.string().trim().min(1, "Client Secret is required"),
clientId: z.string().trim().min(1, "Client ID is required"),
tenantId: z.string().trim().min(1, "Tenant ID is required")
})
});
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) => { const getDefaultValues = (appConnection?: TAzureAppConfigurationConnection): Partial<FormData> => {
if (!appConnection) {
return {
app: AppConnection.AzureAppConfiguration,
method: AzureAppConfigurationConnectionMethod.OAuth
};
}
const base = {
name: appConnection.name,
description: appConnection.description,
app: appConnection.app,
method: appConnection.method
};
const { credentials } = appConnection;
switch (appConnection.method) {
case AzureAppConfigurationConnectionMethod.OAuth:
if ("tenantId" in credentials) {
return {
...base,
method: AzureAppConfigurationConnectionMethod.OAuth,
tenantId: credentials.tenantId
};
}
break;
case AzureAppConfigurationConnectionMethod.ClientSecret:
if ("clientSecret" in credentials && "clientId" in credentials) {
return {
...base,
method: AzureAppConfigurationConnectionMethod.ClientSecret,
credentials: {
clientSecret: credentials.clientSecret,
clientId: credentials.clientId,
tenantId: credentials.tenantId
}
};
}
break;
default:
return base;
}
return base;
};
export const AzureAppConfigurationConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection); const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false);
@@ -43,41 +107,36 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: appConnection defaultValues: getDefaultValues(appConnection)
? {
...appConnection,
tenantId: appConnection.credentials.tenantId
}
: {
app: AppConnection.AzureAppConfiguration,
method: AzureAppConfigurationConnectionMethod.OAuth
}
}); });
const { const {
handleSubmit, handleSubmit,
control, control,
watch, watch,
setValue,
formState: { isSubmitting, isDirty } formState: { isSubmitting, isDirty }
} = form; } = form;
const selectedMethod = watch("method"); const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => { const onSubmitHandler = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex"); const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureAppConfigurationConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) { switch (formData.method) {
case AzureAppConfigurationConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth:
setIsRedirecting(true);
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureAppConfigurationConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
window.location.assign( window.location.assign(
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-app-configuration` `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-app-configuration`
); );
break; break;
case AzureAppConfigurationConnectionMethod.ClientSecret:
onSubmit(formData);
break;
default: default:
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
} }
@@ -89,6 +148,9 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
case AzureAppConfigurationConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth:
isMissingConfig = !oauthClientId; isMissingConfig = !oauthClientId;
break; break;
case AzureAppConfigurationConnectionMethod.ClientSecret:
isMissingConfig = false;
break;
default: default:
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`); throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
} }
@@ -97,25 +159,8 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmitHandler)}>
{!isUpdate && <GenericAppConnectionsFields />} {!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
</FormControl>
)}
/>
<Controller <Controller
name="method" name="method"
control={control} control={control}
@@ -155,6 +200,61 @@ export const AzureAppConfigurationConnectionForm = ({ appConnection }: Props) =>
</FormControl> </FormControl>
)} )}
/> />
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input
{...field}
placeholder="00000000-0000-0000-0000-000000000000"
onChange={(e) => {
field.onChange(e.target.value);
setValue("credentials.tenantId", e.target.value);
}}
/>
</FormControl>
)}
/>
{/* Client Secret-specific fields */}
{selectedMethod === AzureAppConfigurationConnectionMethod.ClientSecret && (
<>
<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.clientSecret"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
label="Client Secret"
errorText={error?.message}
>
<Input {...field} type="password" placeholder="Enter your Client Secret" />
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -21,13 +21,6 @@ import {
GenericAppConnectionsFields GenericAppConnectionsFields
} from "./GenericAppConnectionFields"; } from "./GenericAppConnectionFields";
type AccessTokenForm = z.infer<typeof accessTokenSchema>;
type Props = {
appConnection?: TAzureDevOpsConnection;
onSubmit: (formData: AccessTokenForm) => Promise<void>;
};
// Base schema with common fields // Base schema with common fields
const baseSchema = genericAppConnectionFieldsSchema.extend({ const baseSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AzureDevOps), app: z.literal(AppConnection.AzureDevOps),
@@ -49,10 +42,30 @@ const accessTokenSchema = baseSchema.extend({
}) })
}); });
const clientSecretSchema = baseSchema.extend({
method: z.literal(AzureDevOpsConnectionMethod.ClientSecret),
credentials: z.object({
clientSecret: z.string().trim().min(1, "Client Secret is required"),
tenantId: z.string().trim().min(1, "Tenant ID is required"),
clientId: z.string().trim().min(1, "Client ID is required"),
orgName: z.string().trim().min(1, "Organization name is required")
})
});
// Union schema // Union schema
const formSchema = z.discriminatedUnion("method", [oauthSchema, accessTokenSchema]); const formSchema = z.discriminatedUnion("method", [
oauthSchema,
accessTokenSchema,
clientSecretSchema
]);
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
type OnSubmitForm = z.infer<typeof accessTokenSchema | typeof clientSecretSchema>;
type Props = {
appConnection?: TAzureDevOpsConnection;
onSubmit: (formData: OnSubmitForm) => Promise<void>;
};
const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormData> => { const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormData> => {
if (!appConnection) { if (!appConnection) {
@@ -93,6 +106,25 @@ const getDefaultValues = (appConnection?: TAzureDevOpsConnection): Partial<FormD
}; };
} }
break; break;
case AzureDevOpsConnectionMethod.ClientSecret:
if (
"clientSecret" in credentials &&
"tenantId" in credentials &&
"clientId" in credentials &&
"orgName" in credentials
) {
return {
...base,
method: AzureDevOpsConnectionMethod.ClientSecret,
credentials: {
clientSecret: credentials.clientSecret,
tenantId: credentials.tenantId,
clientId: credentials.clientId,
orgName: credentials.orgName
}
};
}
break;
default: default:
return base; return base;
} }
@@ -118,7 +150,8 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
handleSubmit, handleSubmit,
control, control,
watch, watch,
formState: { isSubmitting, isDirty } formState: { isSubmitting, isDirty },
setValue
} = form; } = form;
const selectedMethod = watch("method"); const selectedMethod = watch("method");
@@ -138,11 +171,12 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-devops` `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-devops`
); );
break; break;
case AzureDevOpsConnectionMethod.AccessToken: case AzureDevOpsConnectionMethod.AccessToken:
onSubmit(formData); onSubmit(formData);
break; break;
case AzureDevOpsConnectionMethod.ClientSecret:
onSubmit(formData);
break;
default: default:
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
} }
@@ -196,8 +230,8 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
)} )}
/> />
{/* OAuth-specific fields */} {(selectedMethod === AzureDevOpsConnectionMethod.OAuth ||
{selectedMethod === AzureDevOpsConnectionMethod.OAuth && ( selectedMethod === AzureDevOpsConnectionMethod.ClientSecret) && (
<> <>
<Controller <Controller
name="tenantId" name="tenantId"
@@ -209,7 +243,14 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
label="Tenant ID" label="Tenant ID"
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" /> <Input
{...field}
placeholder="00000000-0000-0000-0000-000000000000"
onChange={(e) => {
field.onChange(e.target.value);
setValue("credentials.tenantId", e.target.value);
}}
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -223,7 +264,46 @@ export const AzureDevOpsConnectionForm = ({ appConnection, onSubmit }: Props) =>
label="Organization Name" label="Organization Name"
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} placeholder="myorganization" /> <Input
{...field}
placeholder="myorganization"
onChange={(e) => {
field.onChange(e.target.value);
setValue("credentials.orgName", e.target.value);
}}
/>
</FormControl>
)}
/>
</>
)}
{/* Client Secret-specific fields */}
{selectedMethod === AzureDevOpsConnectionMethod.ClientSecret && (
<>
<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.clientSecret"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
label="Client Secret"
errorText={error?.message}
>
<Input {...field} type="password" placeholder="Enter your Client Secret" />
</FormControl> </FormControl>
)} )}
/> />
@@ -20,19 +20,83 @@ import {
GenericAppConnectionsFields GenericAppConnectionsFields
} from "./GenericAppConnectionFields"; } from "./GenericAppConnectionFields";
type ClientSecretForm = z.infer<typeof clientSecretSchema>;
type Props = { type Props = {
appConnection?: TAzureKeyVaultConnection; appConnection?: TAzureKeyVaultConnection;
onSubmit: (formData: ClientSecretForm) => Promise<void>;
}; };
const formSchema = genericAppConnectionFieldsSchema.extend({ const baseSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.AzureKeyVault), app: z.literal(AppConnection.AzureKeyVault),
method: z.nativeEnum(AzureKeyVaultConnectionMethod), method: z.nativeEnum(AzureKeyVaultConnectionMethod)
tenantId: z.string().trim().optional()
}); });
const oauthSchema = baseSchema.extend({
tenantId: z.string().trim().min(1, "Tenant ID is required"),
method: z.literal(AzureKeyVaultConnectionMethod.OAuth)
});
const clientSecretSchema = baseSchema.extend({
method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret),
credentials: z.object({
clientSecret: z.string().trim().min(1, "Client Secret is required"),
clientId: z.string().trim().min(1, "Client ID is required"),
tenantId: z.string().trim().min(1, "Tenant ID is required")
})
});
const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]);
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => { const getDefaultValues = (appConnection?: TAzureKeyVaultConnection): Partial<FormData> => {
if (!appConnection) {
return {
app: AppConnection.AzureKeyVault,
method: AzureKeyVaultConnectionMethod.OAuth
};
}
const base = {
name: appConnection.name,
description: appConnection.description,
app: appConnection.app,
method: appConnection.method
};
const { credentials } = appConnection;
switch (appConnection.method) {
case AzureKeyVaultConnectionMethod.OAuth:
if ("tenantId" in credentials) {
return {
...base,
method: AzureKeyVaultConnectionMethod.OAuth,
tenantId: credentials.tenantId
};
}
break;
case AzureKeyVaultConnectionMethod.ClientSecret:
if ("clientSecret" in credentials && "clientId" in credentials) {
return {
...base,
method: AzureKeyVaultConnectionMethod.ClientSecret,
credentials: {
clientSecret: credentials.clientSecret,
clientId: credentials.clientId,
tenantId: credentials.tenantId
}
};
}
break;
default:
return base;
}
return base;
};
export const AzureKeyVaultConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection); const isUpdate = Boolean(appConnection);
const [isRedirecting, setIsRedirecting] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false);
@@ -43,41 +107,37 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: appConnection defaultValues: getDefaultValues(appConnection)
? {
...appConnection,
tenantId: appConnection.credentials.tenantId
}
: {
app: AppConnection.AzureKeyVault,
method: AzureKeyVaultConnectionMethod.OAuth
}
}); });
const { const {
handleSubmit, handleSubmit,
control, control,
watch, watch,
setValue,
formState: { isSubmitting, isDirty } formState: { isSubmitting, isDirty }
} = form; } = form;
const selectedMethod = watch("method"); const selectedMethod = watch("method");
const onSubmit = (formData: FormData) => { const onSubmitHandler = (formData: FormData) => {
setIsRedirecting(true);
const state = crypto.randomBytes(16).toString("hex"); const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureKeyVaultConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
switch (formData.method) { switch (formData.method) {
case AzureKeyVaultConnectionMethod.OAuth: case AzureKeyVaultConnectionMethod.OAuth:
setIsRedirecting(true);
localStorage.setItem("latestCSRFToken", state);
localStorage.setItem(
"azureKeyVaultConnectionFormData",
JSON.stringify({ ...formData, connectionId: appConnection?.id })
);
window.location.assign( window.location.assign(
`https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://vault.azure.net/.default%20openid%20offline_access&state=${state}<:>azure-key-vault` `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://vault.azure.net/.default%20openid%20offline_access&state=${state}<:>azure-key-vault`
); );
break; break;
case AzureKeyVaultConnectionMethod.ClientSecret:
onSubmit(formData);
break;
default: default:
throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`);
} }
@@ -89,6 +149,9 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
case AzureKeyVaultConnectionMethod.OAuth: case AzureKeyVaultConnectionMethod.OAuth:
isMissingConfig = !oauthClientId; isMissingConfig = !oauthClientId;
break; break;
case AzureKeyVaultConnectionMethod.ClientSecret:
isMissingConfig = false;
break;
default: default:
throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`); throw new Error(`Unhandled Azure Connection method: ${selectedMethod}`);
} }
@@ -97,25 +160,9 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmitHandler)}>
{!isUpdate && <GenericAppConnectionsFields />} {!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input {...field} placeholder="e4f34ea5-ad23-4291-8585-66d20d603cc8" />
</FormControl>
)}
/>
<Controller <Controller
name="method" name="method"
control={control} control={control}
@@ -155,6 +202,62 @@ export const AzureKeyVaultConnectionForm = ({ appConnection }: Props) => {
</FormControl> </FormControl>
)} )}
/> />
<Controller
name="tenantId"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="The Azure Active Directory (Entra ID) Tenant ID."
isError={Boolean(error?.message)}
label="Tenant ID"
isOptional
errorText={error?.message}
>
<Input
{...field}
placeholder="00000000-0000-0000-0000-000000000000"
onChange={(e) => {
field.onChange(e.target.value);
setValue("credentials.tenantId", e.target.value);
}}
/>
</FormControl>
)}
/>
{/* Client Secret-specific fields */}
{selectedMethod === AzureKeyVaultConnectionMethod.ClientSecret && (
<>
<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.clientSecret"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
label="Client Secret"
errorText={error?.message}
>
<Input {...field} type="password" placeholder="Enter your Client Secret" />
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"