package public import ( "crypto/ed25519" "crypto/x509" "encoding/pem" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) type SSOClaims struct { Email string `json:"email"` Name string `json:"name"` jwt.RegisteredClaims } type SSOHandler struct { deps Dependencies } func (h *SSOHandler) HandleSSO(c *gin.Context) { slug := c.Param("slug") token := c.Query("token") if token == "" { c.String(http.StatusBadRequest, "missing token") return } // Look up repo by slug var repo models.Repo if err := h.deps.DB.First(&repo, "slug = ?", slug).Error; err != nil { c.String(http.StatusNotFound, "repo not found") return } if !repo.Active { c.String(http.StatusNotFound, "repo not found") return } if repo.SSOPublicKey == nil || strings.TrimSpace(*repo.SSOPublicKey) == "" { c.String(http.StatusBadRequest, "SSO not configured for this repo") return } // Parse PEM public key pubKey, err := parseEd25519PublicKey(*repo.SSOPublicKey) if err != nil { log.Error().Err(err).Str("repo", slug).Msg("failed to parse SSO public key") c.String(http.StatusInternalServerError, "SSO configuration error") return } // Parse and verify JWT claims := &SSOClaims{} parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { // Enforce EdDSA algorithm to prevent algorithm confusion attacks if t.Method != jwt.SigningMethodEdDSA { return nil, jwt.ErrSignatureInvalid } return pubKey, nil }) if err != nil || !parsed.Valid { c.String(http.StatusUnauthorized, "invalid or expired token") return } // Validate claims email := strings.ToLower(strings.TrimSpace(claims.Email)) name := strings.TrimSpace(claims.Name) if email == "" || name == "" { c.String(http.StatusBadRequest, "missing email or name in token") return } // Find or create user var user models.User result := h.deps.DB.Where("email = ?", email).First(&user) if result.Error != nil { // User doesn't exist — create user = models.User{ Email: email, Name: name, EmailVerified: true, Approved: true, } if err := h.deps.DB.Create(&user).Error; err != nil { // Race condition: another request may have created the user if err2 := h.deps.DB.Where("email = ?", email).First(&user).Error; err2 != nil { log.Error().Err(err).Msg("SSO: failed to create user") c.String(http.StatusInternalServerError, "failed to create user") return } } } // Update existing user if needed updates := map[string]interface{}{} if user.Name != name { updates["name"] = name } if !user.EmailVerified { updates["email_verified"] = true } if !user.Approved { updates["approved"] = true } if len(updates) > 0 { h.deps.DB.Model(&user).Updates(updates) } // Assign user to repo if not already var count int64 h.deps.DB.Model(&models.UserRepo{}).Where("user_id = ? AND repo_id = ?", user.ID, repo.ID).Count(&count) if count == 0 { userRepo := models.UserRepo{UserID: user.ID, RepoID: repo.ID} if err := h.deps.DB.Create(&userRepo).Error; err != nil { log.Warn().Err(err).Msg("SSO: failed to create user-repo association (may already exist)") } } // Create session if err := h.deps.Auth.CreateSession(c.Request, c.Writer, user.ID); err != nil { log.Error().Err(err).Msg("SSO: failed to create session") c.String(http.StatusInternalServerError, "failed to create session") return } c.Redirect(http.StatusSeeOther, "/tickets/new") } func parseEd25519PublicKey(pemStr string) (ed25519.PublicKey, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, jwt.ErrSignatureInvalid } pub, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return nil, err } edKey, ok := pub.(ed25519.PublicKey) if !ok { return nil, jwt.ErrSignatureInvalid } return edKey, nil }