diff --git a/components/backend/handlers/github_auth.go b/components/backend/handlers/github_auth.go index 7cba15fd1..7a72c3e88 100644 --- a/components/backend/handlers/github_auth.go +++ b/components/backend/handlers/github_auth.go @@ -358,15 +358,18 @@ func GetGitHubInstallation(ctx context.Context, userID string) (*GitHubAppInstal cm, err := K8sClient.CoreV1().ConfigMaps(Namespace).Get(ctx, cmName, v1.GetOptions{}) if err != nil { if errors.IsNotFound(err) { + log.Printf("GetGitHubInstallation: ConfigMap %s not found for user=%s", cmName, userID) return nil, fmt.Errorf("installation not found") } return nil, fmt.Errorf("failed to read ConfigMap: %w", err) } if cm.Data == nil { + log.Printf("GetGitHubInstallation: no data in ConfigMap for user=%s", userID) return nil, fmt.Errorf("installation not found") } raw, ok := cm.Data[userID] if !ok || raw == "" { + log.Printf("GetGitHubInstallation: no entry for user=%s in ConfigMap", userID) return nil, fmt.Errorf("installation not found") } var inst GitHubAppInstallation @@ -395,27 +398,55 @@ func deleteGitHubInstallation(ctx context.Context, userID string) error { // LinkGitHubInstallationGlobal handles POST /auth/github/install // Links the current SSO user to a GitHub App installation ID. +// Accepts optional OAuth `code` for verified ownership via code exchange. func LinkGitHubInstallationGlobal(c *gin.Context) { userID, _ := c.Get("userID") if userID == nil || strings.TrimSpace(userID.(string)) == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "missing user identity"}) return } + userIDStr := userID.(string) var req struct { - InstallationID int64 `json:"installationId" binding:"required"` + InstallationID int64 `json:"installationId" binding:"required"` + Code string `json:"code"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + log.Printf("LinkGitHubInstallationGlobal: user=%s installationId=%d codePresent=%v", userIDStr, req.InstallationID, req.Code != "") installation := GitHubAppInstallation{ - UserID: userID.(string), + UserID: userIDStr, InstallationID: req.InstallationID, Host: "github.com", UpdatedAt: time.Now(), } - // Best-effort: enrich with GitHub account login for the installation - if GithubTokenManager != nil { + + // If OAuth code is provided and OAuth env vars are configured, do verified ownership + clientID := os.Getenv("GITHUB_CLIENT_ID") + clientSecret := os.Getenv("GITHUB_CLIENT_SECRET") + if req.Code != "" && clientID != "" && clientSecret != "" { + token, err := exchangeOAuthCodeForUserToken(clientID, clientSecret, req.Code) + if err != nil { + log.Printf("LinkGitHubInstallationGlobal: OAuth code exchange failed for user=%s: %v", userIDStr, err) + // Fall through to best-effort enrichment below + } else { + owns, login, err := userOwnsInstallation(token, req.InstallationID) + if err != nil { + log.Printf("LinkGitHubInstallationGlobal: ownership verification failed for user=%s: %v", userIDStr, err) + } else if !owns { + log.Printf("LinkGitHubInstallationGlobal: user=%s does not own installation %d", userIDStr, req.InstallationID) + c.JSON(http.StatusForbidden, gin.H{"error": "installation not owned by user"}) + return + } else { + log.Printf("LinkGitHubInstallationGlobal: verified ownership via OAuth for user=%s login=%s", userIDStr, login) + installation.GitHubUserID = login + } + } + } + + // Best-effort: enrich with GitHub account login via App JWT if not already set + if installation.GitHubUserID == "" && GithubTokenManager != nil { if jwt, err := GithubTokenManager.GenerateJWT(); err == nil { api := githubAPIBaseURL(installation.Host) url := fmt.Sprintf("%s/app/installations/%d", api, req.InstallationID) diff --git a/components/backend/handlers/integrations_status.go b/components/backend/handlers/integrations_status.go index 649c30845..8a8fb7f00 100644 --- a/components/backend/handlers/integrations_status.go +++ b/components/backend/handlers/integrations_status.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "log" "net/http" "github.com/gin-gonic/gin" @@ -44,6 +45,7 @@ func GetIntegrationsStatus(c *gin.Context) { // Helper functions to get individual integration statuses func getGitHubStatusForUser(ctx context.Context, userID string) gin.H { + log.Printf("getGitHubStatusForUser: querying status for user=%s", userID) status := gin.H{ "installed": false, "pat": gin.H{"configured": false}, @@ -52,11 +54,14 @@ func getGitHubStatusForUser(ctx context.Context, userID string) gin.H { // Check GitHub App inst, err := GetGitHubInstallation(ctx, userID) if err == nil && inst != nil { + log.Printf("getGitHubStatusForUser: found installation for user=%s installationId=%d githubUser=%s", userID, inst.InstallationID, inst.GitHubUserID) status["installed"] = true status["installationId"] = inst.InstallationID status["host"] = inst.Host status["githubUserId"] = inst.GitHubUserID status["updatedAt"] = inst.UpdatedAt.Format("2006-01-02T15:04:05Z07:00") + } else { + log.Printf("getGitHubStatusForUser: no installation found for user=%s", userID) } // Check GitHub PAT diff --git a/components/frontend/src/app/integrations/github/setup/page.tsx b/components/frontend/src/app/integrations/github/setup/page.tsx index bf5f68146..7a11d343d 100644 --- a/components/frontend/src/app/integrations/github/setup/page.tsx +++ b/components/frontend/src/app/integrations/github/setup/page.tsx @@ -13,26 +13,31 @@ export default function GitHubSetupPage() { useEffect(() => { const url = new URL(window.location.href) const installationId = url.searchParams.get('installation_id') + const code = url.searchParams.get('code') if (!installationId) { setMessage('No installation was detected.') return } - connectMutation.mutate( - { installationId: Number(installationId) }, - { - onSuccess: () => { - setMessage('GitHub connected. Redirecting...') - setTimeout(() => { - window.location.replace('/integrations') - }, 800) - }, - onError: (err) => { - setError(err instanceof Error ? err.message : 'Failed to complete setup') - }, - } - ) + const request: { installationId: number; code?: string } = { + installationId: Number(installationId), + } + if (code) { + request.code = code + } + + connectMutation.mutate(request, { + onSuccess: () => { + setMessage('GitHub connected. Redirecting...') + setTimeout(() => { + window.location.replace('/integrations') + }, 800) + }, + onError: (err) => { + setError(err instanceof Error ? err.message : 'Failed to complete setup') + }, + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/components/frontend/src/components/github-connection-card.tsx b/components/frontend/src/components/github-connection-card.tsx index 42b366f43..291aeb997 100644 --- a/components/frontend/src/components/github-connection-card.tsx +++ b/components/frontend/src/components/github-connection-card.tsx @@ -41,9 +41,10 @@ export function GitHubConnectionCard({ appSlug, showManageButton = true, status, const handleConnect = () => { if (!appSlug) return - const setupUrl = new URL('/integrations/github/setup', window.location.origin) - const redirectUri = encodeURIComponent(setupUrl.toString()) - const url = `https://github.com/apps/${appSlug}/installations/new?redirect_uri=${redirectUri}` + // Let GitHub use the Callback URL configured in the App settings + // rather than overriding with redirect_uri (which GitHub ignores + // unless it matches a configured callback URL) + const url = `https://github.com/apps/${appSlug}/installations/new` window.location.href = url } diff --git a/components/frontend/src/services/api/github.ts b/components/frontend/src/services/api/github.ts index 60f81c047..52a474ead 100644 --- a/components/frontend/src/services/api/github.ts +++ b/components/frontend/src/services/api/github.ts @@ -34,7 +34,7 @@ export async function connectGitHub(data: GitHubConnectRequest): Promise '/auth/github/install', data ); - return response.username; + return response.message; } /** diff --git a/components/frontend/src/services/queries/use-github.ts b/components/frontend/src/services/queries/use-github.ts index c0dcb160a..c190cff08 100644 --- a/components/frontend/src/services/queries/use-github.ts +++ b/components/frontend/src/services/queries/use-github.ts @@ -76,8 +76,9 @@ export function useConnectGitHub() { return useMutation({ mutationFn: (data: GitHubConnectRequest) => githubApi.connectGitHub(data), onSuccess: () => { - // Invalidate status to show connected state + // Invalidate both GitHub-specific and unified integrations status queryClient.invalidateQueries({ queryKey: githubKeys.status() }); + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }); }, }); } @@ -91,8 +92,9 @@ export function useDisconnectGitHub() { return useMutation({ mutationFn: githubApi.disconnectGitHub, onSuccess: () => { - // Invalidate status to show disconnected state + // Invalidate both GitHub-specific and unified integrations status queryClient.invalidateQueries({ queryKey: githubKeys.status() }); + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }); // Clear forks cache queryClient.invalidateQueries({ queryKey: githubKeys.forks() }); }, @@ -163,6 +165,7 @@ export function useSaveGitHubPAT() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [...githubKeys.all, 'pat', 'status'] }); queryClient.invalidateQueries({ queryKey: githubKeys.status() }); + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }); }, }); } @@ -178,6 +181,7 @@ export function useDeleteGitHubPAT() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: [...githubKeys.all, 'pat', 'status'] }); queryClient.invalidateQueries({ queryKey: githubKeys.status() }); + queryClient.invalidateQueries({ queryKey: ['integrations', 'status'] }); }, }); } diff --git a/components/frontend/src/types/api/github.ts b/components/frontend/src/types/api/github.ts index 383b12799..027078191 100644 --- a/components/frontend/src/types/api/github.ts +++ b/components/frontend/src/types/api/github.ts @@ -85,14 +85,12 @@ export type CreatePRResponse = { export type GitHubConnectRequest = { installationId: number; - // Legacy OAuth fields (deprecated) code?: string; - state?: string; }; export type GitHubConnectResponse = { message: string; - username: string; + installationId: number; }; export type GitHubDisconnectResponse = {