Merge pull request #4271 from Infisical/feat/azureAppConnectionsNewAuth

Add Azure Client Secrets Auth to Azure App Connections
This commit is contained in:
Daniel Hougaard
2025-07-30 23:47:15 +04:00
committed by GitHub
33 changed files with 1204 additions and 343 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">;
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

@@ -3,7 +3,7 @@ title: "Azure App Configuration Connection"
description: "Learn how to configure a Azure App Configuration Connection for Infisical." description: "Learn how to configure a Azure App Configuration Connection for Infisical."
--- ---
Infisical currently only supports one method for connecting to Azure, which is OAuth. Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets.
<Accordion title="Self-Hosted Instance"> <Accordion title="Self-Hosted Instance">
Using the Azure App Configuration connection on a self-hosted instance of Infisical requires configuring an application in Azure Using the Azure App Configuration connection on a self-hosted instance of Infisical requires configuring an application in Azure
@@ -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
@@ -66,25 +86,35 @@ Infisical currently only supports one method for connecting to Azure, which is O
Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections Navigate to the **App Connections** tab on the **Organization Settings** page. ![App Connections
Tab](/images/app-connections/general/add-connection.png) Tab](/images/app-connections/general/add-connection.png)
</Step> </Step>
<Step title="Add Connection"> <Step title="Add Connection">
Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png) Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/app-configuration/select-connection.png)
</Step> </Step>
<Step title="Authorize Connection"> <Step title="Create Connection">
You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. <Tabs>
<Tab title="OAuth">
<Step title="Authorize Connection">
You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID.
Now select the **OAuth** method and click **Connect to Azure**. Now select the **OAuth** method and click **Connect to Azure**.
![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png) ![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-oauth-method.png)
</Step>
<Step title="Grant Access">
You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted,
you will redirect you back to Infisical's App Connections page. ![Azure App Configuration
Authorization](/images/app-connections/azure/grant-access.png)
</Step>
</Tab>
<Tab title="Client Secret">
<Step title="Create Connection">
Fill in the **Tenant ID**, **Client ID** and **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID and Client Secret you obtained in the previous step.
![Connect via Azure OAUth](/images/app-connections/azure/app-configuration/create-client-secrets-method.png)
</Step>
</Step> </Tab>
<Step title="Grant Access"> </Tabs>
You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, </Step>
you will redirect you back to Infisical's App Connections page. ![Azure App Configuration <Step title="Connection Created">
Authorization](/images/app-connections/azure/grant-access.png)
</Step>
<Step title="Connection Created">
Your **Azure App Configuration Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/app-configuration/oauth-connection.png) Your **Azure App Configuration Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/app-configuration/oauth-connection.png)
</Step> </Step>
</Steps> </Steps>
@@ -3,7 +3,7 @@ title: "Azure Client Secrets Connection"
description: "Learn how to configure an Azure Client Secrets Connection for Infisical." description: "Learn how to configure an Azure Client Secrets Connection for Infisical."
--- ---
Infisical currently only supports one method for connecting to Azure, which is OAuth. Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets.
<Accordion title="Self-Hosted Instance"> <Accordion title="Self-Hosted Instance">
Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure Using the Azure Client Secrets connection on a self-hosted instance of Infisical requires configuring an application in Azure
@@ -3,7 +3,7 @@ title: "Azure DevOps Connection"
description: "Learn how to configure an Azure DevOps Connection for Infisical." description: "Learn how to configure an Azure DevOps Connection for Infisical."
--- ---
Infisical currently supports two methods for connecting to Azure DevOps, which are OAuth and Azure DevOps Personal Access Token. Infisical currently supports three methods for connecting to Azure DevOps, which are OAuth, Azure DevOps Personal Access Token and Client Secrets.
<Accordion title="Azure OAuth on a Self-Hosted Instance"> <Accordion title="Azure OAuth on a Self-Hosted Instance">
Using the Azure DevOps <b>OAuth connection</b> on a self-hosted instance of Infisical requires configuring an application in Azure Using the Azure DevOps <b>OAuth connection</b> on a self-hosted instance of Infisical requires configuring an application in Azure
@@ -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>
@@ -129,6 +155,17 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a
</Step> </Step>
</Steps> </Steps>
</Tab> </Tab>
<Tab title="Client Secret">
<Steps>
<Step title="Create Connection">
Fill in the **Tenant ID**, **Client ID**, **Client Secret** and **Organization Name** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret and the organization name you obtained in the previous step.
<Tip>
You can find the **Organization Name** on https://dev.azure.com/
</Tip>
![Connect via Azure OAUth](/images/app-connections/azure/devops/create-client-secrets-method.png)
</Step>
</Steps>
</Tab>
</Tabs> </Tabs>
</Step> </Step>
<Step title="Connection Created"> <Step title="Connection Created">
@@ -3,7 +3,7 @@ title: "Azure Key Vault Connection"
description: "Learn how to configure a Azure Key Vault Connection for Infisical." description: "Learn how to configure a Azure Key Vault Connection for Infisical."
--- ---
Infisical currently only supports one method for connecting to Azure, which is OAuth. Infisical currently only supports two methods for connecting to Azure, which are OAuth and Client Secrets.
<Accordion title="Self-Hosted Instance"> <Accordion title="Self-Hosted Instance">
Using the Azure Key Vault connection on a self-hosted instance of Infisical requires configuring an application in Azure Using the Azure Key Vault connection on a self-hosted instance of Infisical requires configuring an application in Azure
@@ -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>
@@ -68,21 +89,37 @@ Infisical currently only supports one method for connecting to Azure, which is O
<Step title="Add Connection"> <Step title="Add Connection">
Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/key-vault/select-connection.png) Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/key-vault/select-connection.png)
</Step> </Step>
<Step title="Authorize Connection"> <Step title="Create Connection">
You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID. <Tabs>
<Tab title="OAuth">
<Steps>
<Step title="Authorize Connection">
You can optionally authenticate against a specific tenant by providing the Azure Tenant or Directory ID.
Now select the **OAuth** method and click **Connect to Azure**. Now select the **OAuth** method and click **Connect to Azure**.
![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png) ![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-oauth-method.png)
</Step> </Step>
<Step title="Grant Access"> <Step title="Grant Access">
You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted,
you will redirect you back to Infisical's App Connections page. ![Azure Key Vault you will redirect you back to Infisical's App Connections page. ![Azure Key Vault
Authorization](/images/app-connections/azure/grant-access.png) Authorization](/images/app-connections/azure/grant-access.png)
</Step> </Step>
</Steps>
</Tab>
<Tab title="Client Secret">
<Steps>
<Step title="Create Connection">
Fill in the **Tenant ID**, **Client ID**, **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID, Client Secret you obtained in the previous step.
![Connect via Azure OAUth](/images/app-connections/azure/key-vault/create-client-secrets-method.png)
</Step>
</Steps>
</Tab>
</Tabs>
</Step>
<Step title="Connection Created"> <Step title="Connection Created">
Your **Azure Key Vault Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/key-vault/oauth-connection.png) Your **Azure Key Vault Connection** is now available for use. ![Assume Role AWS Connection](/images/app-connections/azure/key-vault/oauth-connection.png)
</Step> </Step>
+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 = async (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:
await 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,65 @@ 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={selectedMethod === AzureAppConfigurationConnectionMethod.OAuth}
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="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
/>
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -121,7 +121,7 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
const selectedMethod = watch("method"); const selectedMethod = watch("method");
const onSubmitHandler = (formData: FormData) => { const onSubmitHandler = async (formData: FormData) => {
const state = crypto.randomBytes(16).toString("hex"); const state = crypto.randomBytes(16).toString("hex");
switch (formData.method) { switch (formData.method) {
case AzureClientSecretsConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth:
@@ -137,7 +137,7 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
break; break;
case AzureClientSecretsConnectionMethod.ClientSecret: case AzureClientSecretsConnectionMethod.ClientSecret:
onSubmit(formData); await onSubmit(formData);
break; 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}`);
@@ -240,7 +240,11 @@ export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Pr
label="Client Secret" label="Client Secret"
errorText={error?.message} errorText={error?.message}
> >
<Input {...field} type="password" placeholder="Enter your Client Secret" /> <Input
{...field}
type="password"
placeholder="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
/>
</FormControl> </FormControl>
)} )}
/> />
@@ -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> | z.infer<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); await onSubmit(formData);
break;
case AzureDevOpsConnectionMethod.ClientSecret:
await onSubmit(formData);
break; 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,50 @@ 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="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
/>
</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 = async (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:
await 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,66 @@ 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={selectedMethod === AzureKeyVaultConnectionMethod.OAuth}
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="~JzD8e6S.tH~w8XRaNnKcb7W1fM4rCns7FY"
/>
</FormControl>
)}
/>
</>
)}
<div className="mt-8 flex items-center"> <div className="mt-8 flex items-center">
<Button <Button
className="mr-4" className="mr-4"