Files
kandev/apps/backend/internal/github/controller_auth.go
T

402 lines
13 KiB
Go

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)
}
func (c *Controller) httpResolveCredentialLease(ctx *gin.Context) {
var request struct {
Lease string `json:"lease"`
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"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"code": "github_invalid_request", "error": "invalid payload"})
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,
})
if err != nil {
writeGitHubAuthError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, credential)
}
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)
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 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), errors.Is(err, ErrCredentialLeaseExpired),
errors.Is(err, ErrCredentialLeaseRevoked), 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())
}