package ws import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/pkg/logger" "gorm.io/gorm" ) // WSClaims represents authenticated WebSocket connection claims. // Extends auth.Claims with PubsubToken for contact-based auth // (mirrors Chatwoot's RoomChannel where contacts connect via pubsub_token). type WSClaims struct { UserID uint `json:"user_id"` AccountID uint `json:"account_id"` Role string `json:"role"` Provider string `json:"provider"` ClientID string `json:"client_id,omitempty"` PubsubToken string `json:"pubsub_token,omitempty"` // contact auth token (Chatwoot RoomChannel) IsContact bool `json:"is_contact"` // true when authenticated via pubsub_token ContactID uint `json:"contact_id,omitempty"` // resolved contact ID for contact auth InboxID uint `json:"inbox_id,omitempty"` // resolved inbox ID for contact auth } // WSAuthenticator handles WebSocket authentication and authorization. // Reference: Chatwoot ActionCable RoomChannel — authenticates both // agent users (via JWT) and contacts (via pubsub_token). type WSAuthenticator struct { jwtService *auth.JWTService contactInboxRepo *repository.ContactInboxRepo db *gorm.DB ticketStore *auth.WSTicketStore } // NewWSAuthenticator creates a new WebSocket authenticator. func NewWSAuthenticator(jwtService *auth.JWTService, contactInboxRepo *repository.ContactInboxRepo, dependencies ...any) *WSAuthenticator { authenticator := &WSAuthenticator{ jwtService: jwtService, contactInboxRepo: contactInboxRepo, } for _, dependency := range dependencies { switch value := dependency.(type) { case *gorm.DB: authenticator.db = value case *auth.WSTicketStore: authenticator.ticketStore = value } } return authenticator } // Authenticate validates WebSocket upgrade request parameters and returns WSClaims. // It supports two authentication paths (mirrors Chatwoot's RoomChannel): // // 1. Agent/User auth (primary): JWT token from 'token' query param or Authorization header. // Validates via jwtService.ValidateAccessToken, populates WSClaims from auth.Claims. // // 2. Contact auth (secondary): pubsub_token query param. // Looks up ContactInbox by pubsub_token and resolves the contact and account, // populates WSClaims with contact identity. // // Returns WSClaims on success, or an error suitable for HTTP 401 rejection. func (a *WSAuthenticator) Authenticate(c *gin.Context) (*WSClaims, error) { if a.ticketStore != nil { if ticket := c.Query("ticket"); ticket != "" { claims, err := a.ticketStore.Consume(c.Request.Context(), ticket) if err != nil { return nil, auth.ErrInvalidWSTicket } if _, err := auth.ValidateUserAccess(c.Request.Context(), a.db, claims.UserID, claims.ClientID); err != nil { return nil, auth.ErrInvalidWSTicket } return &WSClaims{ UserID: claims.UserID, AccountID: claims.AccountID, Role: claims.Role, Provider: claims.Provider, ClientID: claims.ClientID, }, nil } } else { // --- Path 1: Agent/User authentication via JWT --- token := extractWSToken(c) if token != "" { claims, _, err := auth.ValidateUserAccessToken(c.Request.Context(), a.jwtService, a.db, token) if err != nil { logger.L().Debugf("ws auth: JWT validation failed: %v", err) return nil, fmt.Errorf("invalid JWT token: %w", err) } wsClaims := &WSClaims{ UserID: claims.UserID, AccountID: claims.AccountID, Role: claims.Role, Provider: claims.Provider, ClientID: claims.ClientID, IsContact: false, } // Also extract pubsub_token and user_id if present (for dual auth context) pubsubToken := c.Query("pubsub_token") if pubsubToken != "" { wsClaims.PubsubToken = pubsubToken } logger.L().Infof("ws auth: agent authenticated (user_id=%d, account_id=%d, role=%s)", wsClaims.UserID, wsClaims.AccountID, wsClaims.Role) return wsClaims, nil } } // --- Path 2: Contact authentication via pubsub_token --- pubsubToken := c.Query("pubsub_token") if pubsubToken == "" { return nil, errors.New("authentication required") } var providedContactID *uint if userIDStr := c.Query("user_id"); userIDStr != "" { contactID, parseErr := strconv.ParseUint(userIDStr, 10, 32) if parseErr != nil { return nil, fmt.Errorf("invalid user_id parameter: %w", parseErr) } value := uint(contactID) providedContactID = &value } // Lookup ContactInbox by pubsub_token contactInbox, err := a.findContactInboxByPubsubToken(c.Request.Context(), pubsubToken) if err != nil { logger.L().Debugf("ws auth: contact inbox lookup failed for pubsub_token: %v", err) return nil, fmt.Errorf("invalid pubsub_token: %w", err) } // Legacy clients may still send user_id. Treat it as an additional // fail-closed check, while Chatwoot widgets authenticate by token alone. if providedContactID != nil { if contactInbox.ContactID != *providedContactID { logger.L().Debugf("ws auth: contact mismatch (expected=%d, found=%d)", *providedContactID, contactInbox.ContactID) return nil, errors.New("pubsub_token does not belong to the specified contact") } } wsClaims := &WSClaims{ UserID: contactInbox.ContactID, // for contacts, UserID maps to contact_id (Chatwoot convention) AccountID: contactInbox.Contact.AccountID, Role: "contact", Provider: "pubsub_token", PubsubToken: pubsubToken, IsContact: true, ContactID: contactInbox.ContactID, InboxID: contactInbox.InboxID, } logger.L().Infof("ws auth: contact authenticated (contact_id=%d, account_id=%d, inbox_id=%d)", wsClaims.ContactID, wsClaims.AccountID, wsClaims.InboxID) return wsClaims, nil } // ValidateAgentAccess rechecks mutable access state for a live agent socket. func (a *WSAuthenticator) ValidateAgentAccess(ctx context.Context, userID uint, clientID string) error { _, err := auth.ValidateUserAccess(ctx, a.db, userID, clientID) return err } // Authorize verifies the authenticated user/contact has access to the requested account. // For agent auth: verifies the account_id param matches the JWT claims' AccountID. // For contact auth: verifies the contact's account matches the requested account_id. // // Call this after Authenticate succeeds, before upgrading the WebSocket connection. // Returns nil on success, or an error suitable for HTTP 403 rejection. func (a *WSAuthenticator) Authorize(claims *WSClaims, c *gin.Context) error { // Extract requested account_id from query params accountIDStr := c.Query("account_id") if accountIDStr == "" { // If no explicit account_id requested, use the one from claims return nil } requestedAccountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { return fmt.Errorf("invalid account_id parameter: %w", err) } if claims.AccountID != uint(requestedAccountID) { logger.L().Debugf("ws auth: account mismatch (claims=%d, requested=%d)", claims.AccountID, uint(requestedAccountID)) return fmt.Errorf("user does not have access to account %d", requestedAccountID) } return nil } // AuthenticateAndServeWS is a combined authentication + upgrade handler. // It authenticates the request, authorizes access, then upgrades to WebSocket. // Rejects with HTTP 401 if authentication fails, 403 if authorization fails. // // Usage: register as a Gin handler for the WebSocket upgrade route. // // router.GET("/ws", wsAuth.AuthenticateAndServeWS(hub, upgrader, onConnect)) func (a *WSAuthenticator) AuthenticateAndServeWS(c *gin.Context) { // Step 1: Authenticate claims, err := a.Authenticate(c) if err != nil { logger.L().Warnf("ws: authentication rejected: %v", err) c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } // Step 2: Authorize if err := a.Authorize(claims, c); err != nil { logger.L().Errorf("ws: authorization failed: %v", err) c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } // Authentication and authorization succeeded — set claims in context // for downstream handler (e.g. the actual upgrade handler) to use. c.Set("ws_claims", claims) c.Set("ws_authenticated", true) c.Next() } // findContactInboxByPubsubToken looks up a ContactInbox by its PubsubToken field. // Preloads the associated Contact to resolve the AccountID for authorization. func (a *WSAuthenticator) findContactInboxByPubsubToken(ctx context.Context, pubsubToken string) (*model.ContactInbox, error) { if a.contactInboxRepo == nil { return nil, errors.New("contact inbox repository unavailable") } return a.contactInboxRepo.FindByPubsubToken(ctx, pubsubToken) } // extractWSToken pulls the JWT token from websocket upgrade request. // Matches the HTTP auth middleware behaviour: accepts both 'token' and // 'access-token' query params (browser WebSocket API can't set custom headers), // plus the Authorization header (Bearer token, for non-browser clients). // // NOTE: Sec-WebSocket-Protocol header is NOT used as a JWT source. // // ActionCable sets this to "actioncable-v1-json" for sub-protocol // negotiation, not for authentication. func extractWSToken(c *gin.Context) string { // Query params (browser WebSocket API compatible) if token := c.Query("token"); token != "" { return token } if token := c.Query("access-token"); token != "" { return token } // Authorization header (non-browser clients) authHeader := c.GetHeader("Authorization") if authHeader != "" { parts := strings.SplitN(authHeader, " ", 2) if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { return parts[1] } } return "" } // ParseWSQueryParams extracts all WebSocket-relevant query parameters // from the upgrade request URL. Useful for subscription authorization // after initial authentication. // Reference: Chatwoot RoomChannel subscribe params — account_id, conversation_id, etc. func ParseWSQueryParams(query url.Values) map[string]string { params := make(map[string]string) for _, key := range []string{ "account_id", "conversation_id", "inbox_id", "pubsub_token", "user_id", "token", "ticket", } { if v := query.Get(key); v != "" { params[key] = v } } return params }