package middleware import ( "net/http" "strings" "github.com/gin-gonic/gin" "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, cache *UserStatusCache) 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 != "" { if claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret); ok { role, valid := verifyUser(c, claims, stores.Users, cache) if !valid { return } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) c.Set("role", 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 != "" { if claims, ok := parseAndValidateJWT(cookie, cfg.JWTSecret); ok { role, valid := verifyUser(c, claims, stores.Users, cache) if !valid { return } c.Set("user_id", claims.UserID) c.Set("email", claims.Email) c.Set("role", 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 chType, allowAnon, err := stores.Channels.GetTypeAndAllowAnonymous(c.Request.Context(), channelID) 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 "" }