package middleware import ( "fmt" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) const ( HeaderUserID = "X-User-ID" HeaderTenantID = "X-Tenant-ID" HeaderIsAdmin = "X-Is-Admin" HeaderRole = "X-Role" CtxUserID = "user_id" CtxTenantID = "tenant_id" CtxIsAdmin = "is_admin" CtxRole = "role" ) type ErrorResponse struct { Code string `json:"code"` Message string `json:"message"` } // JWTAuth validates the bearer token and injects claims into both gin context and upstream request headers. func JWTAuth(secret string) gin.HandlerFunc { return func(c *gin.Context) { if validateAndInject(c, secret) { c.Next() } } } // JWTAuthSkip behaves like JWTAuth but skips authentication entirely when skip(c) // returns true. Used for catch-all routes that mix public and protected sub-paths // (e.g. /payments/* where /callback/* and /webhook/* must stay public) — gin forbids // registering a catch-all alongside static child segments, so the branch lives here. func JWTAuthSkip(secret string, skip func(*gin.Context) bool) gin.HandlerFunc { return func(c *gin.Context) { if skip(c) { c.Next() return } if validateAndInject(c, secret) { c.Next() } } } // validateAndInject validates the bearer token and injects claims into the gin context // and upstream request headers. On failure it aborts with 401 and returns false. // It does NOT call c.Next() — the caller decides whether to continue the chain. func validateAndInject(c *gin.Context, secret string) bool { hdr := c.GetHeader("Authorization") if !strings.HasPrefix(hdr, "Bearer ") { c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Code: "unauthorized", Message: "missing bearer token"}) return false } tokenStr := hdr[7:] token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(secret), nil }) if err != nil || !token.Valid { c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Code: "unauthorized", Message: "invalid or expired token"}) return false } claims, _ := token.Claims.(jwt.MapClaims) userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) isAdmin := false switch v := claims["is_admin"].(type) { case bool: isAdmin = v case string: isAdmin = v == "true" } role, _ := claims["role"].(string) c.Set(CtxUserID, userID) c.Set(CtxTenantID, tenantID) c.Set(CtxIsAdmin, isAdmin) c.Set(CtxRole, role) // Inject for upstream services c.Request.Header.Set(HeaderUserID, userID.String()) c.Request.Header.Set(HeaderTenantID, tenantID.String()) if isAdmin { c.Request.Header.Set(HeaderIsAdmin, "true") } if role != "" { c.Request.Header.Set(HeaderRole, role) } return true } // OptionalJWTAuth parses the token if present but does not abort on missing/invalid token. func OptionalJWTAuth(secret string) gin.HandlerFunc { return func(c *gin.Context) { hdr := c.GetHeader("Authorization") if !strings.HasPrefix(hdr, "Bearer ") { c.Next() return } token, err := jwt.Parse(hdr[7:], func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(secret), nil }) if err != nil || !token.Valid { c.Next() return } claims, _ := token.Claims.(jwt.MapClaims) userID, _ := uuid.Parse(fmt.Sprintf("%v", claims["sub"])) tenantID, _ := uuid.Parse(fmt.Sprintf("%v", claims["tenant_id"])) isAdmin := false switch v := claims["is_admin"].(type) { case bool: isAdmin = v case string: isAdmin = v == "true" } c.Request.Header.Set(HeaderUserID, userID.String()) c.Request.Header.Set(HeaderTenantID, tenantID.String()) if isAdmin { c.Request.Header.Set(HeaderIsAdmin, "true") } c.Next() } } func GetUserID(c *gin.Context) (uuid.UUID, bool) { v, ok := c.Get(CtxUserID) if !ok { return uuid.Nil, false } id, ok := v.(uuid.UUID) return id, ok && id != uuid.Nil }