package github import ( "context" "crypto/sha256" "encoding/base64" "errors" "io" "net/http" "net/url" "strconv" "strings" "github.com/gin-gonic/gin" "go.uber.org/zap" "github.com/kandev/kandev/internal/auth/authn" ) const defaultGitHubUserID = DefaultUserID const githubNotConfiguredCode = "github_not_configured" func currentGitHubUserID(ctx *gin.Context) string { return githubUserID(ctx.Request.Context()) } func githubUserID(ctx context.Context) string { if identity, ok := authn.IdentityFromContext(ctx); ok && strings.TrimSpace(identity.UserID) != "" { return identity.UserID } return defaultGitHubUserID } func (c *Controller) httpGetWorkspaceConnection(ctx *gin.Context) { status, err := c.service.GetWorkspaceAuthStatus( ctx.Request.Context(), ctx.Query("workspace_id"), currentGitHubUserID(ctx), ) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, status) } func (c *Controller) httpListCLIAccounts(ctx *gin.Context) { if err := requireGHCLIOperator(ctx.Request.Context()); err != nil { writeGitHubAuthError(ctx, err) return } accounts, err := c.service.ListGHAccounts(ctx.Request.Context()) if err != nil { writeGitHubAuthError(ctx, err) return } workspaceID := strings.TrimSpace(ctx.Query("workspace_id")) if workspaceID != "" { status, statusErr := c.service.GetWorkspaceAuthStatus( ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), ) if statusErr != nil { writeGitHubAuthError(ctx, statusErr) return } if status.Automation != nil && status.Automation.Source == ConnectionSourceGHCLI { for index := range accounts { accounts[index].Selected = strings.EqualFold(accounts[index].Host, status.Automation.GitHubHost) && strings.EqualFold(accounts[index].Login, status.Automation.Login) } } } ctx.JSON(http.StatusOK, gin.H{"accounts": accounts}) } func (c *Controller) httpSetWorkspaceConnection(ctx *gin.Context) { var request SetWorkspaceConnectionRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_request", "error": "invalid payload"}) return } connection, err := c.service.SetWorkspaceConnection( ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), request, ) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, connection) } func (c *Controller) httpDeleteWorkspaceConnection(ctx *gin.Context) { if err := c.service.DeleteWorkspaceConnection( ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), ); err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"disconnected": true}) } func (c *Controller) httpStartAppInstallation(ctx *gin.Context) { var request struct { WorkspaceID string `json:"workspace_id"` AppRegistrationID string `json:"app_registration_id"` } if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.WorkspaceID) == "" || strings.TrimSpace(request.AppRegistrationID) == "" { ctx.JSON(http.StatusBadRequest, gin.H{ "code": "github_app_invalid_request", "error": "workspace and App registration are required", }) return } result, err := c.service.StartAppInstallation( ctx.Request.Context(), strings.TrimSpace(request.WorkspaceID), currentGitHubUserID(ctx), strings.TrimSpace(request.AppRegistrationID), ) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, result) } func (c *Controller) httpCompleteAppInstallation(ctx *gin.Context) { if !validGitHubCallbackState(ctx.Query("state")) { redirectGitHubCallback(ctx, "", "github_invalid_callback") return } workspaceID := c.authFlowWorkspace(ctx, ctx.Query("state")) installationID, err := strconv.ParseInt(ctx.Query("installation_id"), 10, 64) if err != nil { redirectGitHubCallback(ctx, workspaceID, "github_invalid_callback") return } result, err := c.service.CompleteAppInstallation( ctx.Request.Context(), ctx.Param("registrationId"), AppInstallationCallback{ State: ctx.Query("state"), Code: ctx.Query("code"), SetupAction: ctx.Query("setup_action"), InstallationID: installationID, }) if err != nil { resultCode := githubAuthErrorCode(err) if c.logger != nil { c.logger.Warn("GitHub App installation callback verification failed", zap.String("registration_id", ctx.Param("registrationId")), zap.String("workspace_id", workspaceID), zap.String("result_code", resultCode), zap.Error(err), ) } redirectGitHubCallback(ctx, workspaceID, resultCode) return } redirectGitHubCallback(ctx, result.WorkspaceID, "app_connected") } func (c *Controller) httpStartPersonalAuth(ctx *gin.Context) { workspaceID, ok := setupWorkspaceID(ctx) if !ok { return } result, err := c.service.StartPersonalAuth( ctx.Request.Context(), workspaceID, currentGitHubUserID(ctx), ) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, result) } func (c *Controller) httpCompletePersonalAuth(ctx *gin.Context) { if !validGitHubCallbackState(ctx.Query("state")) { redirectGitHubCallback(ctx, "", "github_invalid_callback") return } workspaceID := c.authFlowWorkspace(ctx, ctx.Query("state")) result, err := c.service.CompletePersonalAuth( ctx.Request.Context(), ctx.Param("registrationId"), PersonalAuthCallback{ State: ctx.Query("state"), Code: ctx.Query("code"), }) if err != nil { redirectGitHubCallback(ctx, workspaceID, githubAuthErrorCode(err)) return } redirectGitHubCallback(ctx, result.WorkspaceID, "personal_connected") } func (c *Controller) authFlowWorkspace(ctx *gin.Context, state string) string { if c == nil || c.service == nil || c.service.store == nil { return "" } digest := sha256.Sum256([]byte(strings.TrimSpace(state))) flow, err := c.service.store.GetAuthFlow(ctx.Request.Context(), stateDigestString(digest)) if err != nil || flow == nil { return "" } return flow.WorkspaceID } func validGitHubCallbackState(state string) bool { trimmed := strings.TrimSpace(state) if len(trimmed) != base64.RawURLEncoding.EncodedLen(oauthRandomBytes) { return false } decoded, err := base64.RawURLEncoding.DecodeString(trimmed) return err == nil && len(decoded) == oauthRandomBytes } func (c *Controller) httpDisconnectPersonalAuth(ctx *gin.Context) { if err := c.service.DisconnectPersonalAuth( ctx.Request.Context(), workspaceIDFromRouteOrQuery(ctx), currentGitHubUserID(ctx), ); err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"disconnected": true}) } func (c *Controller) httpGitHubAppWebhook(ctx *gin.Context) { payload, err := io.ReadAll(io.LimitReader(ctx.Request.Body, maxGitHubWebhookPayloadSize+1)) if err != nil || len(payload) > maxGitHubWebhookPayloadSize { ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_webhook", "error": "invalid webhook payload"}) return } result, err := c.service.HandleAppRegistrationWebhook( ctx.Request.Context(), ctx.Param("registrationId"), GitHubWebhookRequest{ DeliveryID: ctx.GetHeader("X-GitHub-Delivery"), Event: ctx.GetHeader("X-GitHub-Event"), Signature: ctx.GetHeader("X-Hub-Signature-256"), Payload: payload, }) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.JSON(http.StatusOK, result) } type credentialBrokerHTTPRequest struct { Lease string `json:"lease"` Capability string `json:"capability"` TaskID string `json:"task_id"` SessionID string `json:"session_id"` RepositoryID string `json:"repository_id"` Owner string `json:"owner"` Repo string `json:"repo"` Host string `json:"host"` Path string `json:"path"` ProviderID string `json:"provider_id"` ParentProviderID string `json:"parent_provider_id"` } func decodeCredentialBrokerHTTPRequest(ctx *gin.Context) (credentialBrokerHTTPRequest, bool) { var request credentialBrokerHTTPRequest if err := ctx.ShouldBindJSON(&request); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"code": credentialBrokerInvalidRequestCode(ctx), "error": "invalid payload"}) return credentialBrokerHTTPRequest{}, false } return request, true } func (c *Controller) httpResolveCredentialLease(ctx *gin.Context) { request, ok := decodeCredentialBrokerHTTPRequest(ctx) if !ok { return } credential, err := c.service.ResolveGitHubCredential(ctx.Request.Context(), BrokerCredentialRequest{ Lease: request.Lease, TaskID: request.TaskID, SessionID: request.SessionID, RepositoryID: request.RepositoryID, Owner: request.Owner, Repo: request.Repo, Host: request.Host, Path: request.Path, ProviderID: request.ProviderID, ParentProviderID: request.ParentProviderID, }) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, credential) } func (c *Controller) httpReissueCredentialLease(ctx *gin.Context) { request, ok := decodeCredentialBrokerHTTPRequest(ctx) if !ok { return } lease, err := c.service.ReissueGitHubCredentialLease(ctx.Request.Context(), CredentialLeaseReissueRequest{ Capability: request.Capability, TaskID: request.TaskID, SessionID: request.SessionID, RepositoryID: request.RepositoryID, Owner: request.Owner, Repo: request.Repo, Host: request.Host, Path: request.Path, ProviderID: request.ProviderID, ParentProviderID: request.ParentProviderID, }) if err != nil { writeGitHubAuthError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, lease) } func (c *Controller) httpCredentialBrokerReady(ctx *gin.Context) { if c == nil || c.service == nil || !c.service.CredentialBrokerReady() { ctx.Status(http.StatusServiceUnavailable) return } ctx.Header("Cache-Control", "no-store") ctx.Status(http.StatusNoContent) } func writeGitHubAuthError(ctx *gin.Context, err error) { status, code := githubAuthErrorResponse(err) if isGenericCredentialBrokerPath(ctx) { code = genericCredentialBrokerErrorCode(err) } message := strings.TrimSpace(err.Error()) payload := gin.H{"code": code, "error": message} var importError *AppRegistrationImportError if errors.As(err, &importError) { if importError.ExistingRegistrationID != "" { payload["existing_registration_id"] = importError.ExistingRegistrationID } if len(importError.Problems) > 0 { payload["problems"] = importError.Problems } } ctx.JSON(status, payload) } func isGenericCredentialBrokerPath(ctx *gin.Context) bool { return ctx != nil && strings.HasPrefix(ctx.Request.URL.Path, "/api/v1/git/credentials/") } func credentialBrokerInvalidRequestCode(ctx *gin.Context) string { if isGenericCredentialBrokerPath(ctx) { return "git_credential_invalid_request" } return "github_invalid_request" } func genericCredentialBrokerErrorCode(err error) string { switch { case errors.Is(err, ErrCredentialLeaseInvalid): return "git_credential_lease_invalid" case errors.Is(err, ErrCredentialLeaseExpired): return "git_credential_lease_expired" case errors.Is(err, ErrCredentialLeaseRevoked): return "git_credential_lease_revoked" case errors.Is(err, ErrCredentialReissueCapabilityInvalid), errors.Is(err, ErrCredentialReissueCapabilityExpired): return "git_credential_reissue_denied" case errors.Is(err, ErrCredentialScopeDenied): return "git_credential_denied" default: return "git_credential_internal_error" } } func githubAuthErrorCode(err error) string { _, code := githubAuthErrorResponse(err) return code } func githubAuthErrorResponse(err error) (int, string) { if status, code, ok := deploymentAppHTTPError(err); ok { return status, code } status, code := http.StatusInternalServerError, "github_internal_error" switch { case errors.Is(err, ErrGitHubWorkspaceRequired): status, code = http.StatusBadRequest, "github_workspace_required" case errors.Is(err, ErrGitHubNotConfigured): status, code = http.StatusConflict, githubNotConfiguredCode case errors.Is(err, ErrWorkspaceConnectionStale): status, code = http.StatusConflict, "github_connection_changed" case errors.Is(err, ErrGitHubPersonalRequired): status, code = http.StatusConflict, "github_personal_required" case errors.Is(err, ErrGitHubCapabilityDenied): status, code = http.StatusForbidden, "github_capability_denied" case errors.Is(err, ErrGHCLIOperatorRequired): status, code = http.StatusForbidden, "github_cli_operator_required" case errors.Is(err, ErrGitHubConnectionInvalid), errors.Is(err, ErrPersonalTokenInvalid): status, code = http.StatusUnauthorized, "github_reconnect_required" case errors.Is(err, ErrOAuthStateInvalid), errors.Is(err, ErrOAuthStateMismatch), errors.Is(err, ErrInstallationAssociationUnverified): status, code = http.StatusBadRequest, "github_invalid_callback" case errors.Is(err, ErrInvalidWebhookSignature): status, code = http.StatusUnauthorized, "github_invalid_webhook_signature" case errors.Is(err, ErrCredentialLeaseInvalid): status, code = http.StatusUnauthorized, "github_credential_lease_invalid" case errors.Is(err, ErrCredentialLeaseExpired): status, code = http.StatusUnauthorized, "github_credential_lease_expired" case errors.Is(err, ErrCredentialLeaseRevoked): status, code = http.StatusUnauthorized, "github_credential_lease_revoked" case errors.Is(err, ErrCredentialReissueCapabilityInvalid), errors.Is(err, ErrCredentialReissueCapabilityExpired): status, code = http.StatusUnauthorized, "github_credential_reissue_denied" case errors.Is(err, ErrCredentialScopeDenied): status, code = http.StatusUnauthorized, "github_credential_denied" case errors.Is(err, ErrInvalidToken): status, code = http.StatusBadRequest, "github_invalid_token" } return status, code } func isGitHubNotConfiguredError(err error) bool { return errors.Is(err, ErrNoClient) || errors.Is(err, ErrGitHubNotConfigured) } func writeGitHubOperationalAuthError(ctx *gin.Context, err error) bool { if isGitHubNotConfiguredError(err) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "code": githubNotConfiguredCode, "error": "GitHub is not configured. Connect GitHub in workspace settings.", }) return true } if errors.Is(err, ErrRepoNotResolvable) { // A repository outside the workspace scope is indistinguishable from a // missing repository. Do not leak the workspace's authorization details. ctx.JSON(http.StatusNotFound, gin.H{ "code": "github_repository_not_found", "error": "GitHub repository is not available in this workspace.", }) return true } for _, target := range []error{ ErrGitHubWorkspaceRequired, ErrGitHubPersonalRequired, ErrGitHubConnectionInvalid, ErrPersonalTokenInvalid, ErrGitHubCapabilityDenied, } { if errors.Is(err, target) { writeGitHubAuthError(ctx, err) return true } } return false } func workspaceIDFromRouteOrQuery(ctx *gin.Context) string { if workspaceID := strings.TrimSpace(ctx.Param("workspaceId")); workspaceID != "" { return workspaceID } return strings.TrimSpace(ctx.Query("workspace_id")) } func setupWorkspaceID(ctx *gin.Context) (string, bool) { if workspaceID := workspaceIDFromRouteOrQuery(ctx); workspaceID != "" { return workspaceID, true } var request struct { WorkspaceID string `json:"workspace_id"` } if err := ctx.ShouldBindJSON(&request); err != nil || strings.TrimSpace(request.WorkspaceID) == "" { ctx.JSON(http.StatusBadRequest, gin.H{ "code": "github_workspace_required", "error": ErrGitHubWorkspaceRequired.Error(), }) return "", false } return strings.TrimSpace(request.WorkspaceID), true } func redirectGitHubCallback(ctx *gin.Context, workspaceID, result string) { query := url.Values{"github_result": []string{result}} if workspaceID != "" { query.Set("workspace_id", workspaceID) } ctx.Header("Cache-Control", "no-store") ctx.Header("Referrer-Policy", "no-referrer") ctx.Redirect(http.StatusSeeOther, "/settings/integrations/github?"+query.Encode()) }