feat(secret-sync): Add Heroku Secret Sync

This commit is contained in:
carlosmonastyrski
2025-06-17 10:52:55 -03:00
parent 46f9927cf1
commit c2612f242c
86 changed files with 1772 additions and 52 deletions
@@ -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";