From 096bdec439df881798c8aabf7992666e5aff1aa2 Mon Sep 17 00:00:00 2001 From: Piyush Gupta Date: Fri, 24 Oct 2025 19:52:05 +0530 Subject: [PATCH 1/4] feat: adds PAT support to github integration --- .../app-connection/app-connection-fns.ts | 2 + .../github/github-connection-enums.ts | 3 +- .../github/github-connection-fns.ts | 45 +++++++++- .../github/github-connection-schemas.ts | 49 ++++++++++- .../secret-sync/github/github-sync-fns.ts | 32 +++++-- frontend/src/helpers/appConnections.ts | 2 + .../appConnections/types/github-connection.ts | 11 ++- .../AppConnectionForm/AppConnectionForm.tsx | 8 +- .../GitHubConnectionForm.tsx | 88 +++++++++++++++---- 9 files changed, 205 insertions(+), 35 deletions(-) diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 464f718ce..21989c6ae 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -345,6 +345,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case GitHubConnectionMethod.App: case GitHubRadarConnectionMethod.App: return "GitHub App"; + case GitHubConnectionMethod.Pat: + return "Personal Access Token"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: diff --git a/backend/src/services/app-connection/github/github-connection-enums.ts b/backend/src/services/app-connection/github/github-connection-enums.ts index 77a4eebac..18dac354f 100644 --- a/backend/src/services/app-connection/github/github-connection-enums.ts +++ b/backend/src/services/app-connection/github/github-connection-enums.ts @@ -1,4 +1,5 @@ export enum GitHubConnectionMethod { OAuth = "oauth", - App = "github-app" + App = "github-app", + Pat = "pat" } diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 5cbf5c60d..806df039f 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -248,10 +248,18 @@ export const makePaginatedGitHubRequest = async ( ): Promise => { const { credentials, method } = appConnection; - const token = - method === GitHubConnectionMethod.OAuth - ? credentials.accessToken - : await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + let token: string; + + switch (method) { + case GitHubConnectionMethod.OAuth: + token = credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + } const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); @@ -460,6 +468,35 @@ export const validateGitHubConnectionCredentials = async ( gatewayV2Service: Pick ) => { const { credentials, method } = config; + + // PAT validation + if (method === GitHubConnectionMethod.Pat) { + try { + const apiUrl = await getGitHubInstanceApiUrl(config); + await requestWithGitHubGateway(config, gatewayService, gatewayV2Service, { + url: `https://${apiUrl}/user`, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${credentials.personalAccessToken}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + return { + personalAccessToken: credentials.personalAccessToken, + instanceType: credentials.instanceType, + host: credentials.host + }; + } catch (e: unknown) { + logger.error(e, "Unable to verify GitHub PAT connection"); + + throw new BadRequestError({ + message: "Unable to validate Personal Access Token: verify token has proper permissions" + }); + } + } + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index 1b8aa9c3f..20ef2c0e0 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -38,6 +38,19 @@ export const GitHubConnectionAppInputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatInputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([ z.object({ accessToken: z.string(), @@ -64,6 +77,19 @@ export const GitHubConnectionAppOutputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatOutputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), @@ -76,6 +102,12 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.GitHub).credentials ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionPatInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) }) ]); @@ -88,7 +120,11 @@ export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsS export const UpdateGitHubConnectionSchema = z .object({ credentials: z - .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .union([ + GitHubConnectionAppInputCredentialsSchema, + GitHubConnectionOAuthInputCredentialsSchema, + GitHubConnectionPatInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) }) @@ -110,6 +146,10 @@ export const GitHubConnectionSchema = z.intersection( z.object({ method: z.literal(GitHubConnectionMethod.OAuth), credentials: GitHubConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: GitHubConnectionPatOutputCredentialsSchema }) ]) ); @@ -128,6 +168,13 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }) ]); diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 4b174ca2a..e7116d604 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -211,10 +211,18 @@ export const GithubSyncFns = { } const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token); @@ -269,10 +277,18 @@ export const GithubSyncFns = { const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index bdb26c796..9ee03c3c0 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -135,6 +135,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case GitHubConnectionMethod.App: case GitHubRadarConnectionMethod.App: return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.Pat: + return { name: "Personal Access Token", icon: faKey }; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts index 27bed2dcb..595e5adbb 100644 --- a/frontend/src/hooks/api/appConnections/types/github-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con export enum GitHubConnectionMethod { App = "github-app", - OAuth = "oauth" + OAuth = "oauth", + Pat = "pat" } export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( @@ -24,4 +25,12 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub host?: string; }; } + | { + method: GitHubConnectionMethod.Pat; + credentials: { + personalAccessToken: string; + instanceType?: "cloud" | "server"; + host?: string; + }; + } ); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index a44c4746b..c4115a4b7 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -96,7 +96,7 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { case AppConnection.AWS: return ; case AppConnection.GitHub: - return ; + return ; case AppConnection.GitHubRadar: return ; case AppConnection.GCP: @@ -213,7 +213,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.GitHub: return ( - + ); case AppConnection.GitHubRadar: return ( diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx index 5faa7d5b0..2ec06c6b8 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -16,6 +16,7 @@ import { FormControl, Input, ModalClose, + SecretInput, Select, SelectItem, Tooltip @@ -48,28 +49,43 @@ import { type Props = { appConnection?: TGitHubConnection; projectId: string | undefined | null; + onSubmit: (formData: PatSchemaForm) => Promise; }; -const formSchema = genericAppConnectionFieldsSchema.extend({ +const rootSchema = genericAppConnectionFieldsSchema.extend({ app: z.literal(AppConnection.GitHub), - method: z.nativeEnum(GitHubConnectionMethod), - credentials: z - .union([ - z.object({ - instanceType: z.literal("cloud").optional(), - host: z.string().optional() - }), - z.object({ - instanceType: z.literal("server"), - host: z.string().min(1, "Required") - }) - ]) - .optional() + method: z.nativeEnum(GitHubConnectionMethod) }); +const baseCredentialsSchema = z.object({ + instanceType: z.union([z.literal("cloud"), z.literal("server")]).optional(), + host: z.string().optional() +}); + +const appSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.App), + credentials: baseCredentialsSchema +}); + +const oauthSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: baseCredentialsSchema +}); + +const patSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: baseCredentialsSchema.extend({ + personalAccessToken: z.string().min(1, "Personal Access Token is required") + }) +}); + +type PatSchemaForm = z.infer; + +const formSchema = z.discriminatedUnion("method", [appSchema, oauthSchema, patSchema]); + type FormData = z.infer; -export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { +export const GitHubConnectionForm = ({ appConnection, projectId, onSubmit }: Props) => { const isUpdate = Boolean(appConnection); const [isRedirecting, setIsRedirecting] = useState(false); @@ -106,7 +122,12 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { const returnUrl = useGetAppConnectionOauthReturnUrl(); - const onSubmit = (formData: FormData) => { + const submitHandler = async (formData: FormData) => { + if (formData.method === GitHubConnectionMethod.Pat) { + await onSubmit(formData); + return; + } + setIsRedirecting(true); const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); @@ -151,15 +172,26 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { case GitHubConnectionMethod.App: isMissingConfig = !appClientSlug; break; + case GitHubConnectionMethod.Pat: + isMissingConfig = false; + break; default: throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`); } const methodDetails = getAppConnectionMethodDetails(selectedMethod); + const getButtonText = () => { + if (selectedMethod === GitHubConnectionMethod.Pat) { + return isUpdate ? "Update Connection" : "Create Connection"; + } + + return isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"; + }; + return ( -
+ {!isUpdate && } { )} /> + {selectedMethod === GitHubConnectionMethod.Pat && ( + ( + + onChange(e.target.value)} + /> + + )} + /> + )} @@ -310,7 +362,7 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { isLoading={isSubmitting || isRedirecting} isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting} > - {isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"} + {getButtonText()}