package v1 // Reference: P2E §1.6 — SAML 2.0 SP HTTP handlers // Provides three endpoints for SAML SSO integration: // - GET /api/v1/saml/metadata → SP metadata (for IdP config import) // - GET /api/v1/saml/login → Initiate SP-initiated SSO (redirect to IdP) // - POST /api/v1/saml/acs → ACS endpoint (process IdP response, issue JWT) import ( "errors" "net/http" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/pkg/response" applogger "github.com/gochat/gochat/pkg/logger" ) // SAMLHandler handles SAML 2.0 authentication HTTP endpoints. type SAMLHandler struct { samlService *auth.SAMLService jwtService *auth.JWTService refreshStore *auth.RefreshTokenStore ssoSessionStore *auth.SSOSessionStore samlCfg *config.SAMLConfig } // NewSAMLHandler creates a SAML handler with service dependencies. func NewSAMLHandler( samlService *auth.SAMLService, jwtService *auth.JWTService, refreshStore *auth.RefreshTokenStore, ssoSessionStore *auth.SSOSessionStore, samlCfg *config.SAMLConfig, ) *SAMLHandler { return &SAMLHandler{ samlService: samlService, jwtService: jwtService, refreshStore: refreshStore, ssoSessionStore: ssoSessionStore, samlCfg: samlCfg, } } // Metadata returns the SP XML metadata for IdP administrators to import. // GET /api/v1/saml/metadata func (h *SAMLHandler) Metadata(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } xml, err := h.samlService.GetSPMetadata() if err != nil { applogger.L().Errorf("Failed to generate SAML SP metadata: %v", err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to generate SP metadata", }, }) return } // Return raw XML with appropriate content type c.Data(http.StatusOK, "application/samlmetadata+xml", xml) } // Login initiates SP-initiated SSO by redirecting to the IdP. // GET /api/v1/saml/login func (h *SAMLHandler) Login(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } // Generate state token for CSRF protection (same pattern as OAuth) state := generateOAuthState() redirectURL, err := h.samlService.InitiateLogin(state) if err != nil { applogger.L().Errorf("Failed to initiate SAML login: %v", err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to initiate SAML login", }, }) return } // Redirect user to IdP c.Redirect(http.StatusFound, redirectURL) } // ACS (Assertion Consumer Service) processes the SAML Response from the IdP. // POST /api/v1/saml/acs // The IdP posts a base64-encoded SAMLResponse + RelayState to this endpoint. // On success: validates assertion, finds/creates user, issues JWT token pair. func (h *SAMLHandler) ACS(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } // Extract SAMLResponse from form POST (IdP sends as base64-encoded form param) samlResponse := c.PostForm("SAMLResponse") if samlResponse == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing SAMLResponse parameter", }, }) return } // Process and validate the SAML response userInfo, err := h.samlService.ProcessResponse(samlResponse) if err != nil { applogger.L().Errorf("SAML ACS validation failed: %v", err) statusCode := http.StatusUnauthorized errCode := response.ErrUnauthorized if errors.Is(err, auth.ErrSAMLReplay) { statusCode = http.StatusForbidden errCode = response.ErrForbidden } else if errors.Is(err, auth.ErrSAMLInvalidResponse) { statusCode = http.StatusUnauthorized } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "SAML authentication failed", Detail: err.Error(), }, }) return } // Find or create user in GoChat user, err := h.samlService.FindOrCreateUser(userInfo) if err != nil { applogger.L().Errorf("SAML user lookup/creation failed: %v", err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to process SAML user", Detail: err.Error(), }, }) return } // Issue JWT token pair (same pattern as login flow) tokenPair, err := h.jwtService.GenerateTokenPair(user, user.AccountID, user.Role) if err != nil { applogger.L().Errorf("Failed to generate JWT for SAML user: %v", err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to generate authentication tokens", }, }) return } // Store refresh token if h.refreshStore != nil { if err := h.refreshStore.Store(c.Request.Context(), user.ID, tokenPair.RefreshToken); err != nil { applogger.L().Warnf("Failed to store refresh token for SAML user: %v", err) // Non-fatal: access token is still valid } } // Create SSO session in Redis (for SLO and session tracking) // Reference: M13 §4 — SSO session creation in ACS flow if h.ssoSessionStore != nil { idpEntityID := h.samlService.GetIdPEntityID() sessionData := &auth.SSOSessionData{ UserID: user.ID, Provider: "saml", IdPEntityID: idpEntityID, NameID: userInfo.NameID, AccountID: user.AccountID, Role: user.Role, CreatedAt: time.Now().Unix(), ExpiresAt: time.Now().Add(h.ssoSessionStore.SessionTTL()).Unix(), } sessionID, err := h.ssoSessionStore.Create(c.Request.Context(), sessionData) if err != nil { applogger.L().Warnf("Failed to create SSO session for SAML user: %v", err) // Non-fatal: JWT tokens are still valid, SSO session is for tracking/SLO only } else { applogger.L().Infof("SSO session %s created for SAML user %d via IdP %s", sessionID, user.ID, idpEntityID) } } // Return successful auth response (same format as login endpoint) response.OK(c, gin.H{ "user": user, "access_token": tokenPair.AccessToken, "refresh_token": tokenPair.RefreshToken, "expires_at": tokenPair.ExpiresAt, }) } // RegisterSAMLRoutes sets up SAML routes on a Gin router group. // These routes are PUBLIC — no AuthRequired middleware (SAML flow is external). func RegisterSAMLRoutes(rg *gin.RouterGroup, handler *SAMLHandler) { samlGroup := rg.Group("/saml") { samlGroup.GET("/metadata", handler.Metadata) samlGroup.GET("/login", handler.Login) samlGroup.POST("/acs", handler.ACS) // SLO (Single Logout) endpoints — M13 §1 samlGroup.GET("/slo", handler.SPInitiatedSLO) // SP-initiated: redirect user to IdP for logout samlGroup.POST("/slo", handler.IdPInitiatedSLO) // IdP-initiated: IdP sends LogoutRequest to us } } // --- SAML Single Logout (SLO) Handlers --- // Reference: M13 §1 — SAML 2.0 Single Logout (SLO) // SPInitiatedSLO handles SP-initiated Single Logout (HTTP-Redirect binding). // GET /api/v1/saml/slo // The user clicks logout in GoChat → we generate a SAML LogoutRequest // and redirect to the IdP's SLO endpoint. The IdP then propagates // logout to all SPs in the session. // Query params: // - session_id: the SSO session ID to terminate // - state: optional RelayState for post-logout redirect func (h *SAMLHandler) SPInitiatedSLO(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } sessionID := c.Query("session_id") if sessionID == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing session_id parameter", }, }) return } state := c.Query("state") if state == "" { state = "/" // default redirect to home after logout } // In a real flow, we'd look up the SSO session to get NameID + SessionIndex // from the DB/Redis. For now, use the session_id as both. // Production note: session store should be backed by Redis/DB for SLO validation. // Current implementation passes empty IDs as placeholders until SSOSessionRepo is wired. redirectURL, err := h.samlService.InitiateLogout(sessionID, sessionID, "", state) if err != nil { applogger.L().Errorf("SAML SLO initiation failed: %v", err) statusCode := http.StatusInternalServerError errCode := response.ErrInternal if errors.Is(err, auth.ErrSAMLEnabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "Failed to initiate SAML logout", Detail: err.Error(), }, }) return } // Redirect user to IdP SLO endpoint c.Redirect(http.StatusFound, redirectURL) } // IdPInitiatedSLO handles IdP-initiated Single Logout. // POST /api/v1/saml/slo // The IdP sends a base64-encoded SAML LogoutRequest to this endpoint. // We validate the request, terminate all matching SSO sessions, // and return a LogoutResponse. func (h *SAMLHandler) IdPInitiatedSLO(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } samlRequest := c.PostForm("SAMLRequest") if samlRequest == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing SAMLRequest parameter", }, }) return } // Process the LogoutRequest from the IdP logoutResponse, err := h.samlService.ProcessLogoutRequest(samlRequest) if err != nil { applogger.L().Errorf("SAML IdP-initiated SLO failed: %v", err) statusCode := http.StatusInternalServerError errCode := response.ErrInternal if errors.Is(err, auth.ErrSAMLEnabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "Failed to process SAML logout request", Detail: err.Error(), }, }) return } // Return the LogoutResponse for the IdP (base64-encoded XML) c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: map[string]string{ "logout_response": logoutResponse, }, }) } // SLOResponse handles the IdP's LogoutResponse for SP-initiated SLO. // GET /api/v1/saml/slo/response // After the IdP processes our LogoutRequest, it redirects the user back // to this endpoint with a SAMLResponse (LogoutResponse) + RelayState. func (h *SAMLHandler) SLOResponse(c *gin.Context) { if !h.samlCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "SAML is not enabled", }, }) return } samlResponse := c.Query("SAMLResponse") if samlResponse == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing SAMLResponse parameter", }, }) return } relayState := c.Query("RelayState") err := h.samlService.ProcessLogoutResponse(samlResponse, relayState) if err != nil { applogger.L().Errorf("SAML SLO response validation failed: %v", err) statusCode := http.StatusInternalServerError errCode := response.ErrInternal if errors.Is(err, auth.ErrSAMLEnabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "SAML logout response validation failed", Detail: err.Error(), }, }) return } // SLO successful — redirect user to the RelayState URL (or home) redirectURL := relayState if redirectURL == "" { redirectURL = "/" } c.Redirect(http.StatusFound, redirectURL) }