forgejo-tickets/internal/handlers/public/sso.go

157 lines
3.9 KiB
Go

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
}