package auth // Reference: M13 §4 — Unified SSO Middleware + Provider Router // Provides a single entry point for enterprise SSO authentication that routes // requests to the correct provider (SAML, LDAP, OIDC) based on the account's // configuration. This is the "glue" that ties all enterprise auth providers together. // // Enterprise feature: GoChat's unified SSO middleware is a multi-tenant routing layer // that Chatwoot does not offer. While Chatwoot only supports SAML (and basic OAuth), // GoChat provides LDAP, OIDC, and SAML with per-account isolation, enabling enterprise // customers to choose the identity provider that fits their infrastructure. // // Flow: // 1. Client sends auth request to /api/v1/sso/authenticate or /api/v1/sso/callback // 2. SSO middleware resolves the account's configured provider (SAML, LDAP, OIDC) // 3. Middleware delegates to the appropriate service (SAMLService, LDAPService, OIDCService) // 4. After successful authentication, middleware: // a. Auto-provisions GoChat user if configured // b. Maps external groups/roles to GoChat roles // c. Creates SSO session in Redis // d. Issues JWT token for API access // // Provider resolution priority (per-account): // - If account has active SAML settings → SAML // - If account has active OIDC settings → OIDC // - If account has active LDAP settings → LDAP // - If multiple providers are active, the first configured wins (SAML > OIDC > LDAP) // - Override via query param: ?provider=ldap or ?provider=oidc import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // SSOProviderType identifies the enterprise SSO provider. type SSOProviderType string const ( SSOProviderSAML SSOProviderType = "saml" SSOProviderLDAP SSOProviderType = "ldap" SSOProviderOIDC SSOProviderType = "oidc" ) // SSOAuthResult holds the result of a unified SSO authentication attempt. type SSOAuthResult struct { Provider SSOProviderType // which provider authenticated the user UserID uint // GoChat user ID (0 if auto-provision needed) AccountID uint // account context Email string // user email from IdP Name string // user display name from IdP FirstName string // first name (if available) LastName string // last name (if available) Subject string // unique identifier from IdP (NameID, DN, sub) Role string // mapped GoChat role Groups []string // groups from IdP (for audit/logging) AutoProvision bool // whether the user needs auto-provisioning } // SSOMiddleware provides unified SSO routing and post-authentication processing. type SSOMiddleware struct { db *gorm.DB rdb redis.Cmdable cfg *config.Config samlService *SAMLService ldapService *LDAPService oidcService *OIDCService jwtSecret string jwtExpiry time.Duration } // NewSSOMiddleware creates the unified SSO middleware. func NewSSOMiddleware( db *gorm.DB, rdb redis.Cmdable, cfg *config.Config, samlSvc *SAMLService, ldapSvc *LDAPService, oidcSvc *OIDCService, ) *SSOMiddleware { jwtExpiry := 24 * time.Hour // default 24h JWT expiry if cfg.Session.ExpirySeconds > 0 { jwtExpiry = time.Duration(cfg.Session.ExpirySeconds) * time.Second } return &SSOMiddleware{ db: db, rdb: rdb, cfg: cfg, samlService: samlSvc, ldapService: ldapSvc, oidcService: oidcSvc, jwtSecret: cfg.JWT.Secret, jwtExpiry: jwtExpiry, } } // ResolveProvider determines which SSO provider is active for a given account. // Returns the provider type, or empty string if no provider is configured. // Optional override via providerHint query parameter. func (m *SSOMiddleware) ResolveProvider(ctx context.Context, accountID uint, providerHint string) (SSOProviderType, error) { // If an explicit provider hint is given, validate it's active for this account if providerHint != "" { pt := SSOProviderType(strings.ToLower(providerHint)) active, err := m.isProviderActive(ctx, accountID, pt) if err != nil { return "", fmt.Errorf("provider %s check failed: %w", pt, err) } if !active { return "", fmt.Errorf("provider %s is not active for account %d", pt, accountID) } return pt, nil } // Priority: SAML > OIDC > LDAP (SAML is the most mature enterprise SSO protocol) providers := []SSOProviderType{SSOProviderSAML, SSOProviderOIDC, SSOProviderLDAP} for _, pt := range providers { active, err := m.isProviderActive(ctx, accountID, pt) if err != nil { applogger.L().Warnf("SSO provider check failed (account=%d, provider=%s): %v", accountID, pt, err) continue } if active { return pt, nil } } return "", fmt.Errorf("no SSO provider configured for account %d", accountID) } // isProviderActive checks whether a given SSO provider is active for an account. func (m *SSOMiddleware) isProviderActive(ctx context.Context, accountID uint, provider SSOProviderType) (bool, error) { switch provider { case SSOProviderSAML: if m.cfg.SAML.Enabled { var settings model.AccountSamlSettings err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error if err == nil { return true, nil // per-account SAML is active } if err == gorm.ErrRecordNotFound { return m.cfg.SAML.Enabled, nil // fall back to global } return false, err } return false, nil case SSOProviderLDAP: if m.cfg.LDAP.Enabled { var settings model.AccountLDAPSettings err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error if err == nil { return true, nil } if err == gorm.ErrRecordNotFound { return m.cfg.LDAP.Enabled, nil } return false, err } return false, nil case SSOProviderOIDC: if m.cfg.OIDC.Enabled { var settings model.AccountOIDCSettings err := m.db.WithContext(ctx).Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error if err == nil { return true, nil } if err == gorm.ErrRecordNotFound { return m.cfg.OIDC.Enabled, nil } return false, err } return false, nil default: return false, fmt.Errorf("unknown SSO provider: %s", provider) } } // AuthenticateLDAP performs LDAP authentication via the unified SSO middleware. // Returns SSOAuthResult with user info and provisioning status. func (m *SSOMiddleware) AuthenticateLDAP(ctx context.Context, accountID uint, username, password string) (*SSOAuthResult, error) { if m.ldapService == nil { return nil, fmt.Errorf("LDAP service not initialized") } userInfo, err := m.ldapService.Authenticate(ctx, accountID, username, password) if err != nil { return nil, fmt.Errorf("LDAP authentication failed: %w", err) } // Resolve the GoChat user from LDAP attributes result := &SSOAuthResult{ Provider: SSOProviderLDAP, AccountID: accountID, Email: userInfo.Email, Name: userInfo.DisplayName, FirstName: userInfo.FirstName, LastName: userInfo.LastName, Subject: userInfo.DN, // LDAP DN is the unique identifier Groups: userInfo.Groups, } // Map LDAP groups to GoChat role ldapSettings := m.getLDAPSettings(accountID) if ldapSettings != nil { result.Role = m.ldapService.MapLDAPGroupsToRoles(ldapSettings, userInfo.Groups) } else { result.Role = "agent" // default role } // Look up existing GoChat user by email user, err := m.findOrCreateUser(ctx, accountID, userInfo.Email, userInfo.DisplayName, userInfo.DN, "ldap", result.Role) if err != nil { return nil, fmt.Errorf("failed to resolve GoChat user: %w", err) } result.UserID = user.ID result.AutoProvision = user.ID == 0 return result, nil } // AuthenticateOIDC performs OIDC callback processing via the unified SSO middleware. // Returns SSOAuthResult with user info and provisioning status. func (m *SSOMiddleware) AuthenticateOIDC(ctx context.Context, state, code string) (*SSOAuthResult, error) { if m.oidcService == nil { return nil, fmt.Errorf("OIDC service not initialized") } oidcUserInfo, oidcState, err := m.oidcService.HandleCallback(ctx, state, code) if err != nil { return nil, fmt.Errorf("OIDC authentication failed: %w", err) } result := &SSOAuthResult{ Provider: SSOProviderOIDC, AccountID: oidcState.AccountID, Email: oidcUserInfo.Email, Name: oidcUserInfo.Name, FirstName: oidcUserInfo.FirstName, LastName: oidcUserInfo.LastName, Subject: oidcUserInfo.Subject, // OIDC sub claim Groups: oidcUserInfo.Groups, } // Map OIDC groups to GoChat role oidcSettings, _ := m.oidcService.getAccountSettings(oidcState.AccountID) if oidcSettings != nil { result.Role = m.oidcService.MapOIDCGroupsToRoles(oidcSettings, oidcUserInfo.Groups) } else { result.Role = "agent" } // Look up existing GoChat user by email user, err := m.findOrCreateUser(ctx, oidcState.AccountID, oidcUserInfo.Email, oidcUserInfo.Name, oidcUserInfo.Subject, "oidc", result.Role) if err != nil { return nil, fmt.Errorf("failed to resolve GoChat user: %w", err) } result.UserID = user.ID result.AutoProvision = user.ID == 0 return result, nil } // IssueJWT creates a JWT token for the authenticated SSO user. func (m *SSOMiddleware) IssueJWT(result *SSOAuthResult) (string, error) { if result.UserID == 0 { return "", fmt.Errorf("cannot issue JWT for unprovisioned user") } claims := jwt.MapClaims{ "user_id": result.UserID, "account_id": result.AccountID, "role": result.Role, "provider": string(result.Provider), "subject": result.Subject, "email": result.Email, "iat": time.Now().Unix(), "exp": time.Now().Add(m.jwtExpiry).Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(m.jwtSecret)) } // CreateSSOSession creates an SSO session in Redis for the authenticated user. func (m *SSOMiddleware) CreateSSOSession(ctx context.Context, result *SSOAuthResult) (string, error) { sessionID, err := generateSSOSessionID() if err != nil { return "", fmt.Errorf("failed to generate SSO session ID: %w", err) } sessionData := SSOSessionData{ SessionID: sessionID, UserID: result.UserID, Provider: string(result.Provider), AccountID: result.AccountID, Role: result.Role, NameID: result.Subject, CreatedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(m.jwtExpiry).Unix(), } // Store session data in Redis sessionJSON, _ := json.Marshal(sessionData) key := fmt.Sprintf("sso:session:%s", sessionID) if err := m.rdb.Set(ctx, key, sessionJSON, m.jwtExpiry).Err(); err != nil { return "", fmt.Errorf("failed to store SSO session: %w", err) } // Also track user→sessions mapping for SLO (single logout) userSessionsKey := fmt.Sprintf("sso:user_sessions:%d", result.UserID) m.rdb.SAdd(ctx, userSessionsKey, sessionID) applogger.L().Infof("SSO session created (user=%d, account=%d, provider=%s, session=%s)", result.UserID, result.AccountID, result.Provider, sessionID) return sessionID, nil } // --- Gin middleware handler for SSO session validation --- // SSOSessionValidator returns a gin.HandlerFunc that validates SSO sessions // from the Authorization header or query parameter. // This middleware is optional — it supplements the regular JWT AuthMiddleware // by also checking SSO session validity in Redis. func (m *SSOMiddleware) SSOSessionValidator() gin.HandlerFunc { return func(c *gin.Context) { // Get session ID from header or query sessionID := c.GetHeader("X-SSO-Session") if sessionID == "" { sessionID = c.Query("sso_session") } if sessionID == "" { // No SSO session provided — skip SSO validation (regular JWT auth continues) c.Next() return } // Validate session in Redis key := fmt.Sprintf("sso:session:%s", sessionID) val, err := m.rdb.Get(c.Request.Context(), key).Bytes() if err != nil { if err == redis.Nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "SSO session expired or invalid", }) return } applogger.L().Warnf("SSO session lookup failed: %v", err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": "SSO session validation failed", }) return } var sessionData SSOSessionData if err := json.Unmarshal(val, &sessionData); err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "SSO session data corrupt", }) return } // Set SSO session data in gin context for downstream handlers c.Set("sso_session", sessionData) c.Set("sso_provider", sessionData.Provider) c.Set("user_id", sessionData.UserID) c.Set("account_id", sessionData.AccountID) c.Set("role", sessionData.Role) c.Next() } } // --- Internal helpers --- // getLDAPSettings loads per-account LDAP settings for the middleware. func (m *SSOMiddleware) getLDAPSettings(accountID uint) *model.AccountLDAPSettings { if m.db == nil { return nil } var settings model.AccountLDAPSettings err := m.db.Where("account_id = ? AND active = ?", accountID, true).First(&settings).Error if err != nil { if err == gorm.ErrRecordNotFound { // Return nil — LDAPService.Authenticate will use global defaults return nil } applogger.L().Warnf("LDAP settings lookup failed (account=%d): %v", accountID, err) return nil } return &settings } // findOrCreateUser looks up an existing GoChat user by email/provider/uid, // or creates a new user if auto-provision is enabled. func (m *SSOMiddleware) findOrCreateUser(ctx context.Context, accountID uint, email, name, uid, provider, role string) (*model.User, error) { if m.db == nil { return nil, fmt.Errorf("database not available") } // 1. Try to find existing user by email var user model.User err := m.db.WithContext(ctx).Where("email = ?", email).First(&user).Error if err == nil { // User found — ensure they belong to this account m.ensureAccountMembership(ctx, user.ID, accountID, role) return &user, nil } if err != gorm.ErrRecordNotFound { return nil, fmt.Errorf("user lookup failed: %w", err) } // 2. No user found — auto-provision if enabled autoProvision := true // default switch provider { case "ldap": settings := m.getLDAPSettings(accountID) if settings != nil { autoProvision = settings.AutoProvision } case "oidc": if m.oidcService != nil { oidcSettings, _ := m.oidcService.getAccountSettings(accountID) if oidcSettings != nil { autoProvision = oidcSettings.AutoProvision } } case "saml": // SAML model has no AutoProvision field — default to true. // Role mapping is handled by the SAML service itself. autoProvision = true } if !autoProvision { return &model.User{}, fmt.Errorf("user %s not found and auto-provision is disabled", email) } // 3. Create new user newUser := model.User{ Email: email, Name: name, Provider: provider, UID: uid, } if err := m.db.WithContext(ctx).Create(&newUser).Error; err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } applogger.L().Infof("Auto-provisioned user (id=%d, email=%s, provider=%s)", newUser.ID, email, provider) // 4. Add user to account with mapped role m.ensureAccountMembership(ctx, newUser.ID, accountID, role) return &newUser, nil } // ensureAccountMembership ensures a user belongs to the given account with the specified role. func (m *SSOMiddleware) ensureAccountMembership(ctx context.Context, userID, accountID uint, role string) { var accountUser model.AccountUser err := m.db.WithContext(ctx).Where("account_id = ? AND user_id = ?", accountID, userID).First(&accountUser).Error if err == nil { // Already a member — update role if needed if accountUser.Role != role { m.db.WithContext(ctx).Model(&accountUser).Update("role", role) } return } // Not a member yet — add them accountUser = model.AccountUser{ AccountID: accountID, UserID: userID, Role: role, } if err := m.db.WithContext(ctx).Create(&accountUser).Error; err != nil { applogger.L().Warnf("Failed to add user %d to account %d: %v", userID, accountID, err) } else { applogger.L().Infof("Added user %d to account %d with role %s", userID, accountID, role) } }