package middleware import ( "net/http" "strings" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/store" ) // AuthOrSession returns middleware that accepts either a normal JWT or a // session cookie. Authenticated users get the standard context values // (user_id, email, role, auth_type="user"). Session visitors get // session_id, channel_id, and auth_type="session". // // Session auth is only valid for workflow channels with allow_anonymous=true. func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc { return func(c *gin.Context) { // Skip auth when running without a database if !database.IsConnected() { c.Next() return } // ── Try normal JWT auth first ── tokenString := extractBearerToken(c) if tokenString != "" { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(cfg.JWTSecret), nil }) if err == nil && token.Valid { c.Set("user_id", claims.UserID) c.Set("email", claims.Email) c.Set("role", claims.Role) c.Set("auth_type", "user") c.Next() return } } // ── Try sb_token cookie (page auth pattern) ── if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" { claims := &Claims{} token, err := jwt.ParseWithClaims(cookie, claims, func(t *jwt.Token) (interface{}, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { return nil, jwt.ErrSignatureInvalid } return []byte(cfg.JWTSecret), nil }) if err == nil && token.Valid { c.Set("user_id", claims.UserID) c.Set("email", claims.Email) c.Set("role", claims.Role) c.Set("auth_type", "user") c.Next() return } } // ── Fall back to session auth ── channelID := c.Param("id") // channel routes use :id if channelID == "" { channelID = c.Param("channelId") } if channelID == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } // Verify channel exists, is workflow type, and allows anonymous var chType string var allowAnon bool err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`SELECT type, allow_anonymous FROM channels WHERE id = $1`), channelID, ).Scan(&chType, &allowAnon) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } if chType != "workflow" || !allowAnon { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "anonymous access not permitted on this channel"}) return } session, err := auth.CreateOrResumeSession(c, stores, channelID, cfg) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session creation failed"}) return } c.Set("session_id", session.ID) c.Set("session_token", session.SessionToken) c.Set("channel_id", channelID) c.Set("auth_type", "session") c.Next() } } // extractBearerToken gets the JWT from Authorization header or ?token= query param. func extractBearerToken(c *gin.Context) string { header := c.GetHeader("Authorization") if header == "" { if qToken := c.Query("token"); qToken != "" { return qToken } return "" } if strings.HasPrefix(header, "Bearer ") { return strings.TrimPrefix(header, "Bearer ") } return "" }