From cd6bc6fbadd24bbf45180ca3e1793a2fe09127fa Mon Sep 17 00:00:00 2001 From: Alec Cunningham Date: Fri, 25 Oct 2024 15:10:10 -0500 Subject: [PATCH 1/2] feat: add server-side oauth for frontend --- .env | 10 +++--- go.mod | 1 + go.sum | 2 ++ pkg/api/auth.go | 79 ++++++++++++++++++++++++++++++++++++++++ pkg/api/middleware.go | 84 +++++++++++++++++++++++++++++++++++++++++++ pkg/api/router.go | 12 ++++++- pkg/config/config.go | 4 +++ pkg/db/models/user.go | 17 +-------- 8 files changed, 187 insertions(+), 22 deletions(-) create mode 100644 pkg/api/auth.go diff --git a/.env b/.env index 3f7bf37..87527ef 100644 --- a/.env +++ b/.env @@ -1,5 +1,5 @@ -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GITHUB_WEBHOOK_SECRET= -GITHUB_POLLING_INTERVAL= -GITHUB_POLLING_TIMEOUT= +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +JWT_SECRET=your_jwt_secret +FRONTEND_URL=http://localhost:3000 +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,https://your-prod-domain.com \ No newline at end of file diff --git a/go.mod b/go.mod index 002b6e0..e1fa21a 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 6efc068..c943440 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/pkg/api/auth.go b/pkg/api/auth.go new file mode 100644 index 0000000..6427cf7 --- /dev/null +++ b/pkg/api/auth.go @@ -0,0 +1,79 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/moosh3/github-actions-aggregator/pkg/config" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +func githubOAuthConfig(config *config.Config) *oauth2.Config { + return &oauth2.Config{ + ClientID: config.GitHub.ClientID, + ClientSecret: config.GitHub.ClientSecret, + Scopes: []string{"user:email"}, + Endpoint: github.Endpoint, + } +} + +func handleGithubLogin(config *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + oauthConfig := githubOAuthConfig(config) + url := oauthConfig.AuthCodeURL("state") + c.Redirect(http.StatusTemporaryRedirect, url) + } +} + +func handleGithubCallback(config *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + code := c.Query("code") + + // Exchange code for token + oauthConfig := githubOAuthConfig(config) + token, err := oauthConfig.Exchange(c, code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to exchange token"}) + return + } + + // Get user info from GitHub + client := oauthConfig.Client(c, token) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get user info"}) + return + } + defer resp.Body.Close() + + var githubUser struct { + ID int `json:"id"` + Email string `json:"email"` + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decode user info"}) + return + } + + // Create JWT + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "userId": githubUser.ID, + "email": githubUser.Email, + "username": githubUser.Login, + }) + + tokenString, err := jwtToken.SignedString([]byte(config.GitHub.JWTSecret)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"}) + return + } + + // Redirect to frontend with token + c.Redirect(http.StatusTemporaryRedirect, + config.FrontendURL+"/auth/callback?token="+tokenString) + } +} diff --git a/pkg/api/middleware.go b/pkg/api/middleware.go index 02aef7b..493b576 100644 --- a/pkg/api/middleware.go +++ b/pkg/api/middleware.go @@ -1,3 +1,87 @@ package api +import ( + "net/http" + "os" + "strings" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/moosh3/github-actions-aggregator/pkg/config" +) + // Middleware functions for request logging, authentication checks, etc. +func corsMiddleware() gin.HandlerFunc { + allowedOrigins := strings.Split(os.Getenv("ALLOWED_ORIGINS"), ",") + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + for _, allowedOrigin := range allowedOrigins { + if origin == allowedOrigin { + c.Writer.Header().Set("Access-Control-Allow-Origin", origin) + break + } + } + c.Writer.Header().Set("Access-Control-Allow-Origin", os.Getenv("FRONTEND_URL")) + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +func authMiddleware(config *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "No authorization header"}) + c.Abort() + return + } + + // Remove "Bearer " prefix if present + if len(tokenString) > 7 && tokenString[:7] == "Bearer " { + tokenString = tokenString[7:] + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + return []byte(config.GitHub.JWTSecret), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token claims"}) + c.Abort() + return + } + + c.Set("userId", claims["userId"]) + c.Set("email", claims["email"]) + c.Set("username", claims["username"]) + + c.Next() + } +} + +func getCurrentUser(c *gin.Context) { + userId, _ := c.Get("userId") + email, _ := c.Get("email") + username, _ := c.Get("username") + + c.JSON(http.StatusOK, gin.H{ + "id": userId, + "email": email, + "username": username, + }) +} diff --git a/pkg/api/router.go b/pkg/api/router.go index 517dcdd..1a1975c 100644 --- a/pkg/api/router.go +++ b/pkg/api/router.go @@ -12,6 +12,9 @@ import ( func StartServer(cfg *config.Config, db *db.Database, githubClient *github.Client, worker *worker.WorkerPool) { r := gin.Default() + // Enable CORS + r.Use(corsMiddleware()) + // Public routes for Github OAuth r.GET("/login", auth.GitHubLogin) r.GET("/callback", auth.GitHubCallback) @@ -20,8 +23,15 @@ func StartServer(cfg *config.Config, db *db.Database, githubClient *github.Clien webhookHandler := github.NewWebhookHandler(db, githubClient, cfg.GitHub.WebhookSecret, worker) r.POST("/webhook", webhookHandler.HandleWebhook) + auth := r.Group("/auth") + { + auth.GET("/github/login", handleGithubLogin(cfg)) + auth.GET("/github/callback", handleGithubCallback(cfg)) + auth.GET("/user", authMiddleware(cfg), getCurrentUser) + } + // Require authentication for all repository routes - protected := r.Group("/repositories", auth.AuthMiddleware()) + protected := r.Group("/repositories", authMiddleware(cfg)) { protected.GET("", GetRepositories) protected.GET("/:repoId", GetRepository) diff --git a/pkg/config/config.go b/pkg/config/config.go index f000695..4b11b05 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,6 +9,7 @@ import ( type GitHubConfig struct { ClientID string ClientSecret string + JWTSecret string AccessToken string WebhookSecret string } @@ -23,6 +24,7 @@ type DatabaseConfig struct { type Config struct { ServerPort string LogLevel string + FrontendURL string GitHub GitHubConfig Database DatabaseConfig PollingWorkerPoolSize int @@ -42,11 +44,13 @@ func LoadConfig() *Config { return &Config{ ServerPort: viper.GetString("server.port"), LogLevel: viper.GetString("log.level"), + FrontendURL: viper.GetString("frontend.url"), PollingWorkerPoolSize: viper.GetInt("polling_worker_pool_size"), WebhookWorkerPoolSize: viper.GetInt("webhook_worker_pool_size"), GitHub: GitHubConfig{ ClientID: viper.GetString("github.client_id"), ClientSecret: viper.GetString("github.client_secret"), + JWTSecret: viper.GetString("github.jwt_secret"), AccessToken: viper.GetString("github.access_token"), WebhookSecret: viper.GetString("github.webhook_secret"), }, diff --git a/pkg/db/models/user.go b/pkg/db/models/user.go index 95a7f82..e0f78c9 100644 --- a/pkg/db/models/user.go +++ b/pkg/db/models/user.go @@ -13,13 +13,9 @@ type GitHubUser struct { ID int64 `gorm:"uniqueIndex;not null"` NodeID string `gorm:"not null"` AvatarURL string - GravatarID string + TenantID string // The tenant ID for the user URL string HTMLURL string - FollowersURL string - FollowingURL string - GistsURL string - StarredURL string SubscriptionsURL string OrganizationsURL string ReposURL string @@ -28,17 +24,6 @@ type GitHubUser struct { Type string SiteAdmin bool Name string - Company string - Blog string - Location string - Email string - Hireable bool - Bio string - TwitterUsername string - PublicRepos int - PublicGists int - Followers int - Following int CreatedAt time.Time UpdatedAt time.Time } From 24d782df1dcd41dbb728c8ff1ab1edef690205e0 Mon Sep 17 00:00:00 2001 From: Alec Cunningham Date: Fri, 25 Oct 2024 15:40:19 -0500 Subject: [PATCH 2/2] remove tenant --- go.mod | 3 + go.sum | 6 ++ pkg/api/auth.go | 206 ++++++++++++++++++++++++++++++------------ pkg/config/config.go | 2 + pkg/db/db.go | 13 +++ pkg/db/models/user.go | 2 + 6 files changed, 176 insertions(+), 56 deletions(-) diff --git a/go.mod b/go.mod index e1fa21a..ce7acef 100644 --- a/go.mod +++ b/go.mod @@ -17,10 +17,12 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cloudflare/circl v1.1.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -47,6 +49,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index c943440..7ab3fef 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -15,6 +17,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= @@ -87,6 +91,8 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= diff --git a/pkg/api/auth.go b/pkg/api/auth.go index 6427cf7..d53f418 100644 --- a/pkg/api/auth.go +++ b/pkg/api/auth.go @@ -1,79 +1,173 @@ package api import ( + "context" + "crypto/rand" + "encoding/base64" "encoding/json" + "fmt" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/moosh3/github-actions-aggregator/pkg/config" + "github.com/moosh3/github-actions-aggregator/pkg/db" + "github.com/moosh3/github-actions-aggregator/pkg/db/models" + "github.com/redis/go-redis/v9" "golang.org/x/oauth2" "golang.org/x/oauth2/github" ) -func githubOAuthConfig(config *config.Config) *oauth2.Config { +type OAuthState struct { + State string + TenantID string + ReturnTo string +} + +// Redis client for storing state +var redisClient = redis.NewClient(&redis.Options{ + Addr: "localhost:6379", +}) + +func generateState() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +func getGithubOAuthConfig(cfg *config.Config) *oauth2.Config { return &oauth2.Config{ - ClientID: config.GitHub.ClientID, - ClientSecret: config.GitHub.ClientSecret, - Scopes: []string{"user:email"}, + ClientID: cfg.GitHub.ClientID, + ClientSecret: cfg.GitHub.ClientSecret, + Scopes: []string{"user:email", "read:user"}, Endpoint: github.Endpoint, + RedirectURL: fmt.Sprintf("%s/auth/github/callback", cfg.APIURL), } } -func handleGithubLogin(config *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - oauthConfig := githubOAuthConfig(config) - url := oauthConfig.AuthCodeURL("state") - c.Redirect(http.StatusTemporaryRedirect, url) +func handleGithubLogin(c *gin.Context, cfg *config.Config) { + // Generate random state + state, err := generateState() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state"}) + return + } + + // Store state with tenant info in Redis + oauthState := OAuthState{ + State: state, + ReturnTo: c.Query("returnTo"), // Optional return URL } + + stateJSON, err := json.Marshal(oauthState) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal state"}) + return + } + + // Store state in Redis with 15-minute expiration + err = redisClient.Set(context.Background(), + fmt.Sprintf("oauth_state:%s", state), + string(stateJSON), + 15*time.Minute, + ).Err() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store state"}) + return + } + + oauthConfig := getGithubOAuthConfig(cfg) + + // Generate authorization URL + authURL := oauthConfig.AuthCodeURL( + state, + oauth2.AccessTypeOnline, + ) + + // Redirect to GitHub + c.Redirect(http.StatusTemporaryRedirect, authURL) } -func handleGithubCallback(config *config.Config) gin.HandlerFunc { - return func(c *gin.Context) { - code := c.Query("code") - - // Exchange code for token - oauthConfig := githubOAuthConfig(config) - token, err := oauthConfig.Exchange(c, code) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to exchange token"}) - return - } - - // Get user info from GitHub - client := oauthConfig.Client(c, token) - resp, err := client.Get("https://api.github.com/user") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get user info"}) - return - } - defer resp.Body.Close() - - var githubUser struct { - ID int `json:"id"` - Email string `json:"email"` - Login string `json:"login"` - } - if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decode user info"}) - return - } - - // Create JWT - jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "userId": githubUser.ID, - "email": githubUser.Email, - "username": githubUser.Login, - }) - - tokenString, err := jwtToken.SignedString([]byte(config.GitHub.JWTSecret)) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"}) - return - } - - // Redirect to frontend with token - c.Redirect(http.StatusTemporaryRedirect, - config.FrontendURL+"/auth/callback?token="+tokenString) +func handleGithubCallback(c *gin.Context, cfg *config.Config) { + code := c.Query("code") + state := c.Query("state") + + // Validate state + stateKey := fmt.Sprintf("oauth_state:%s", state) + stateJSON, err := redisClient.Get(context.Background(), stateKey).Result() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired state"}) + return + } + + // Parse stored state + var oauthState OAuthState + if err := json.Unmarshal([]byte(stateJSON), &oauthState); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state data"}) + return + } + + // Delete used state + redisClient.Del(context.Background(), stateKey) + + oauthConfig := getGithubOAuthConfig(cfg) + + token, err := oauthConfig.Exchange(c, code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to exchange token"}) + return + } + + // Get GitHub user info + client := oauthConfig.Client(c, token) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to get user info"}) + return + } + defer resp.Body.Close() + + var githubUser models.GitHubUser + if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decode user info"}) + return } + + // Create or update user in your database + user, err := db.UpdateUser(models.GitHubUser{ + Email: githubUser.Email, + Username: githubUser.Login, + Name: githubUser.Name, + AvatarURL: githubUser.AvatarURL, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create/update user"}) + return + } + + // Generate JWT + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": user.ID, + "email": user.Email, + "username": user.Username, + "exp": time.Now().Add(24 * time.Hour).Unix(), + }) + + tokenString, err := jwtToken.SignedString([]byte(cfg.GitHub.JWTSecret)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create token"}) + return + } + + // Determine redirect URL + redirectURL := fmt.Sprintf("https://%s/auth/callback?token=%s", tenant.Domain, tokenString) + if oauthState.ReturnTo != "" { + // Validate and sanitize ReturnTo URL here + redirectURL = fmt.Sprintf("%s&returnTo=%s", redirectURL, oauthState.ReturnTo) + } + + c.Redirect(http.StatusTemporaryRedirect, redirectURL) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 4b11b05..af12667 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -25,6 +25,7 @@ type Config struct { ServerPort string LogLevel string FrontendURL string + APIURL string GitHub GitHubConfig Database DatabaseConfig PollingWorkerPoolSize int @@ -45,6 +46,7 @@ func LoadConfig() *Config { ServerPort: viper.GetString("server.port"), LogLevel: viper.GetString("log.level"), FrontendURL: viper.GetString("frontend.url"), + APIURL: viper.GetString("api.url"), PollingWorkerPoolSize: viper.GetInt("polling_worker_pool_size"), WebhookWorkerPoolSize: viper.GetInt("webhook_worker_pool_size"), GitHub: GitHubConfig{ diff --git a/pkg/db/db.go b/pkg/db/db.go index fe2cefc..0c1d6be 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -210,3 +210,16 @@ func (db *Database) SaveWorkflowStatistics(stats *models.WorkflowStatistics) err func (db *Database) DeleteWorkflowStatistics(id int) error { return db.Conn.Delete(&models.WorkflowStatistics{}, id).Error } + +func (db *Database) GetUser(id int) (*models.GitHubUser, error) { + var user models.GitHubUser + err := db.Conn.First(&user, id).Error + return &user, err +} + +func (db *Database) UpdateUser(user *models.GitHubUser) error { + return db.Conn.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + UpdateAll: true, + }).Create(user).Error +} diff --git a/pkg/db/models/user.go b/pkg/db/models/user.go index e0f78c9..f6f7639 100644 --- a/pkg/db/models/user.go +++ b/pkg/db/models/user.go @@ -12,6 +12,8 @@ type GitHubUser struct { Login string `gorm:"uniqueIndex;not null"` ID int64 `gorm:"uniqueIndex;not null"` NodeID string `gorm:"not null"` + Email string `gorm:"not null"` + Username string `gorm:"not null"` AvatarURL string TenantID string // The tenant ID for the user URL string