mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-17 04:33:01 +00:00
feat(secret-sync): Add Heroku Secret Sync
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export enum HerokuConnectionMethod {
|
||||
AuthToken = "auth-token",
|
||||
OAuth = "oauth"
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection-dal";
|
||||
import { HerokuConnectionMethod } from "./heroku-connection-enums";
|
||||
import { THerokuApp, THerokuConnection, THerokuConnectionConfig } from "./heroku-connection-types";
|
||||
|
||||
interface HerokuOAuthTokenResponse {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
user_id: string;
|
||||
session_nonce: string;
|
||||
}
|
||||
|
||||
const encryptAppConnectionCredentials = async ({
|
||||
orgId,
|
||||
credentials,
|
||||
kmsService
|
||||
}: {
|
||||
orgId: string;
|
||||
credentials: {
|
||||
refreshToken: string;
|
||||
authToken: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(credentials))
|
||||
});
|
||||
|
||||
return encryptedCredentialsBlob;
|
||||
};
|
||||
|
||||
export const getHerokuConnectionListItem = () => {
|
||||
const { CLIENT_ID_HEROKU } = getConfig();
|
||||
|
||||
return {
|
||||
name: "Heroku" as const,
|
||||
app: AppConnection.Heroku as const,
|
||||
methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth],
|
||||
oauthClientId: CLIENT_ID_HEROKU
|
||||
};
|
||||
};
|
||||
|
||||
export const refreshHerokuToken = async (
|
||||
refreshToken: string,
|
||||
appId: string,
|
||||
orgId: string,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<string> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
|
||||
const payload = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
};
|
||||
|
||||
const { data } = await request.post<{ access_token: string; expires_in: number }>(
|
||||
IntegrationUrls.HEROKU_TOKEN_URL,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const encryptedCredentials = await encryptAppConnectionCredentials({
|
||||
credentials: {
|
||||
refreshToken,
|
||||
authToken: data.access_token,
|
||||
expiresAt: new Date(Date.now() + data.expires_in)
|
||||
},
|
||||
orgId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
await appConnectionDAL.updateById(appId, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuthTokenResponse> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
|
||||
try {
|
||||
const response = await request.post<HerokuOAuthTokenResponse>(
|
||||
IntegrationUrls.HEROKU_TOKEN_URL,
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to exchange OAuth code: Empty response"
|
||||
});
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
message: `Failed to exchange OAuth code: ${error.response?.data?.message || error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to exchange OAuth code"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const validateHerokuConnectionCredentials = async (config: THerokuConnectionConfig) => {
|
||||
const { credentials: inputCredentials, method } = config;
|
||||
|
||||
let authToken: string;
|
||||
let oauthData: HerokuOAuthTokenResponse | null = null;
|
||||
|
||||
if (method === HerokuConnectionMethod.OAuth && "code" in inputCredentials) {
|
||||
oauthData = await exchangeHerokuOAuthCode(inputCredentials.code);
|
||||
authToken = oauthData.access_token;
|
||||
} else if (method === HerokuConnectionMethod.AuthToken && "authToken" in inputCredentials) {
|
||||
authToken = inputCredentials.authToken;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid credentials for the selected connection method"
|
||||
});
|
||||
}
|
||||
|
||||
let response: AxiosResponse<THerokuApp[]> | null = null;
|
||||
|
||||
try {
|
||||
response = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
Accept: "application/vnd.heroku+json; version=3"
|
||||
}
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
|
||||
if (!response?.data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get apps: Response was empty"
|
||||
});
|
||||
}
|
||||
|
||||
if (method === HerokuConnectionMethod.OAuth && oauthData) {
|
||||
return {
|
||||
authToken,
|
||||
refreshToken: oauthData.refresh_token,
|
||||
expiresIn: oauthData.expires_in,
|
||||
tokenType: oauthData.token_type,
|
||||
userId: oauthData.user_id,
|
||||
sessionNonce: oauthData.session_nonce
|
||||
};
|
||||
}
|
||||
|
||||
return inputCredentials;
|
||||
};
|
||||
|
||||
export const listHerokuApps = async ({
|
||||
appConnection,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
}: {
|
||||
appConnection: THerokuConnection;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}): Promise<THerokuApp[]> => {
|
||||
let authCredential = appConnection.credentials.authToken;
|
||||
if (
|
||||
appConnection.method === HerokuConnectionMethod.OAuth &&
|
||||
appConnection.credentials.refreshToken &&
|
||||
appConnection.credentials.expiresAt < new Date()
|
||||
) {
|
||||
authCredential = await refreshHerokuToken(
|
||||
appConnection.credentials.refreshToken,
|
||||
appConnection.id,
|
||||
appConnection.orgId,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await request.get<THerokuApp[]>(`${IntegrationUrls.HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authCredential}`,
|
||||
Accept: "application/vnd.heroku+json; version=3"
|
||||
}
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new InternalServerError({
|
||||
message: "Failed to get apps: Response was empty"
|
||||
});
|
||||
}
|
||||
|
||||
return data.map((res) => ({ name: res.name, id: res.id }));
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AppConnections } from "@app/lib/api-docs";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
BaseAppConnectionSchema,
|
||||
GenericCreateAppConnectionFieldsSchema,
|
||||
GenericUpdateAppConnectionFieldsSchema
|
||||
} from "@app/services/app-connection/app-connection-schemas";
|
||||
|
||||
import { HerokuConnectionMethod } from "./heroku-connection-enums";
|
||||
|
||||
export const HerokuConnectionAccessTokenCredentialsSchema = z.object({
|
||||
authToken: z.string().trim().min(1, "Auth Token required")
|
||||
});
|
||||
|
||||
export const HerokuConnectionOAuthCredentialsSchema = z.object({
|
||||
code: z.string().trim().min(1, "OAuth code required")
|
||||
});
|
||||
|
||||
export const HerokuConnectionOAuthOutputCredentialsSchema = z.object({
|
||||
authToken: z.string(),
|
||||
refreshToken: z.string(),
|
||||
expiresAt: z.date()
|
||||
});
|
||||
|
||||
// Schema for refresh token input during initial setup
|
||||
export const HerokuConnectionRefreshTokenCredentialsSchema = z.object({
|
||||
refreshToken: z.string().trim().min(1, "Refresh token required")
|
||||
});
|
||||
|
||||
const BaseHerokuConnectionSchema = BaseAppConnectionSchema.extend({
|
||||
app: z.literal(AppConnection.Heroku)
|
||||
});
|
||||
|
||||
export const HerokuConnectionSchema = z.intersection(
|
||||
BaseHerokuConnectionSchema,
|
||||
z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken),
|
||||
credentials: HerokuConnectionAccessTokenCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth),
|
||||
credentials: HerokuConnectionOAuthOutputCredentialsSchema
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
export const SanitizedHerokuConnectionSchema = z.discriminatedUnion("method", [
|
||||
BaseHerokuConnectionSchema.extend({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken),
|
||||
credentials: HerokuConnectionAccessTokenCredentialsSchema.pick({})
|
||||
}),
|
||||
BaseHerokuConnectionSchema.extend({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth),
|
||||
credentials: HerokuConnectionOAuthOutputCredentialsSchema.pick({})
|
||||
})
|
||||
]);
|
||||
|
||||
export const ValidateHerokuConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.AuthToken).describe(AppConnections.CREATE(AppConnection.Heroku).method),
|
||||
credentials: HerokuConnectionAccessTokenCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.Heroku).credentials
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(HerokuConnectionMethod.OAuth).describe(AppConnections.CREATE(AppConnection.Heroku).method),
|
||||
credentials: z
|
||||
.union([
|
||||
HerokuConnectionOAuthCredentialsSchema,
|
||||
HerokuConnectionRefreshTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthOutputCredentialsSchema
|
||||
])
|
||||
.describe(AppConnections.CREATE(AppConnection.Heroku).credentials)
|
||||
})
|
||||
]);
|
||||
|
||||
export const CreateHerokuConnectionSchema = ValidateHerokuConnectionCredentialsSchema.and(
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.Heroku)
|
||||
);
|
||||
|
||||
export const UpdateHerokuConnectionSchema = z
|
||||
.object({
|
||||
credentials: z
|
||||
.union([
|
||||
HerokuConnectionAccessTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthOutputCredentialsSchema,
|
||||
HerokuConnectionRefreshTokenCredentialsSchema,
|
||||
HerokuConnectionOAuthCredentialsSchema
|
||||
])
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.Heroku).credentials)
|
||||
})
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Heroku));
|
||||
|
||||
export const HerokuConnectionListItemSchema = z.object({
|
||||
name: z.literal("Heroku"),
|
||||
app: z.literal(AppConnection.Heroku),
|
||||
methods: z.nativeEnum(HerokuConnectionMethod).array(),
|
||||
oauthClientId: z.string().optional()
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection-dal";
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { listHerokuApps as getHerokuApps } from "./heroku-connection-fns";
|
||||
import { THerokuConnection } from "./heroku-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
app: AppConnection,
|
||||
connectionId: string,
|
||||
actor: OrgServiceActor
|
||||
) => Promise<THerokuConnection>;
|
||||
|
||||
export const herokuConnectionService = (
|
||||
getAppConnection: TGetAppConnectionFunc,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
const listApps = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Heroku, connectionId, actor);
|
||||
try {
|
||||
const apps = await getHerokuApps({ appConnection, appConnectionDAL, kmsService });
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return apps;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to establish connection with Heroku");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listApps
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import z from "zod";
|
||||
|
||||
import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
CreateHerokuConnectionSchema,
|
||||
HerokuConnectionSchema,
|
||||
ValidateHerokuConnectionCredentialsSchema
|
||||
} from "./heroku-connection-schemas";
|
||||
|
||||
export type THerokuConnection = z.infer<typeof HerokuConnectionSchema>;
|
||||
|
||||
export type THerokuConnectionInput = z.infer<typeof CreateHerokuConnectionSchema> & {
|
||||
app: AppConnection.Heroku;
|
||||
};
|
||||
|
||||
export type TValidateHerokuConnectionCredentialsSchema = typeof ValidateHerokuConnectionCredentialsSchema;
|
||||
|
||||
export type THerokuConnectionConfig = DiscriminativePick<THerokuConnectionInput, "method" | "app" | "credentials"> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type THerokuApp = {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./heroku-connection-enums";
|
||||
export * from "./heroku-connection-fns";
|
||||
export * from "./heroku-connection-schemas";
|
||||
export * from "./heroku-connection-types";
|
||||
Reference in New Issue
Block a user