package v1 // Reference: M13 §4.3 — OIDC (OpenID Connect) HTTP handlers // Provides HTTP endpoints for OIDC/OAuth2 enterprise authentication: // - GET /api/v1/oidc/authorize → Initiate OIDC auth flow (redirect to IdP) // - GET /api/v1/oidc/callback → Process IdP callback (exchange code, issue JWT) // - GET /api/v1/oidc/config → Admin-only: get OIDC settings for account // - PUT /api/v1/oidc/config → Admin-only: update OIDC settings for account // - GET /api/v1/oidc/discovery → Get OIDC discovery document for account's IdP import ( "errors" "net/http" "strconv" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/auth" "github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/pkg/response" applogger "github.com/gochat/gochat/pkg/logger" ) // OIDCHandler handles OIDC/OAuth2 enterprise authentication HTTP endpoints. type OIDCHandler struct { oidcService *auth.OIDCService ssoMiddleware *auth.SSOMiddleware jwtService *auth.JWTService refreshStore *auth.RefreshTokenStore oidcCfg *config.OIDCConfig } // NewOIDCHandler creates an OIDC handler with service dependencies. func NewOIDCHandler( oidcService *auth.OIDCService, ssoMiddleware *auth.SSOMiddleware, jwtService *auth.JWTService, refreshStore *auth.RefreshTokenStore, oidcCfg *config.OIDCConfig, ) *OIDCHandler { return &OIDCHandler{ oidcService: oidcService, ssoMiddleware: ssoMiddleware, jwtService: jwtService, refreshStore: refreshStore, oidcCfg: oidcCfg, } } // Authorize initiates the OIDC authorization code flow by redirecting to the IdP. // GET /api/v1/oidc/authorize // Query params: // - account_id (required): the account/tenant initiating OIDC auth // - provider_hint (optional): explicit provider hint (e.g. "google", "auth0", "keycloak") // - redirect_path (optional): path to redirect after successful auth // // This endpoint is PUBLIC — no AuthMiddleware required (user is not yet authenticated). func (h *OIDCHandler) Authorize(c *gin.Context) { if !h.oidcCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC is not enabled", }, }) return } // Extract account_id from query params (required) accountIDStr := c.Query("account_id") if accountIDStr == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing account_id parameter", }, }) return } accountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Invalid account_id parameter", Detail: "account_id must be a positive integer", }, }) return } // Optional: redirect path after auth (defaults to "/") redirectPath := c.DefaultQuery("redirect_path", "/") // Use the OIDC service to generate authorization URL (with PKCE) // The service's GetAuthorizationURL handles PKCE code_verifier/challenge generation, // state parameter creation, and Redis state storage. authURL, state, err := h.oidcService.GetAuthorizationURL(c.Request.Context(), uint(accountID), redirectPath) if err != nil { applogger.L().Errorf("Failed to initiate OIDC authorization (account=%d): %v", accountID, err) statusCode := http.StatusInternalServerError errCode := response.ErrInternal if errors.Is(err, auth.ErrOIDCDisabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } else if errors.Is(err, auth.ErrOIDCInvalidConfig) { statusCode = http.StatusBadRequest errCode = response.ErrBadRequest } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "Failed to initiate OIDC authorization", Detail: err.Error(), }, }) return } applogger.L().Infof("OIDC authorization initiated (account=%d, state=%s)", accountID, state) // Redirect user to IdP authorization endpoint c.Redirect(http.StatusFound, authURL) } // Callback processes the OIDC IdP callback after user authentication. // GET /api/v1/oidc/callback // Query params (from IdP redirect): // - code: authorization code from IdP // - state: state parameter (CSRF + session data stored in Redis) // // Flow: // 1. SSO middleware's AuthenticateOIDC validates state, exchanges code for tokens // 2. Extracts user info from ID token + userinfo endpoint // 3. Maps OIDC groups to GoChat roles // 4. Finds or auto-provisions GoChat user // 5. Issues JWT token pair // 6. Creates SSO session in Redis // 7. Returns JWT tokens to client // // This endpoint is PUBLIC — no AuthMiddleware required (this is the auth completion step). func (h *OIDCHandler) Callback(c *gin.Context) { if !h.oidcCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC is not enabled", }, }) return } // Extract authorization code and state from IdP redirect code := c.Query("code") if code == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing authorization code parameter", }, }) return } state := c.Query("state") if state == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing state parameter", }, }) return } // Use SSO middleware to authenticate via OIDC // This handles: state retrieval from Redis, code exchange, ID token validation, // userinfo extraction, group-to-role mapping, and user provisioning. result, err := h.ssoMiddleware.AuthenticateOIDC(c.Request.Context(), state, code) if err != nil { applogger.L().Errorf("OIDC callback authentication failed (state=%s): %v", state, err) statusCode := http.StatusUnauthorized errCode := response.ErrUnauthorized if errors.Is(err, auth.ErrOIDCDisabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } else if errors.Is(err, auth.ErrOIDCTokenExchange) || errors.Is(err, auth.ErrOIDCTokenValidation) { statusCode = http.StatusForbidden errCode = response.ErrForbidden } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "OIDC authentication failed", Detail: err.Error(), }, }) return } // Issue JWT token for the authenticated user accessToken, err := h.ssoMiddleware.IssueJWT(result) if err != nil { applogger.L().Errorf("Failed to issue JWT after OIDC auth (user=%d, account=%d): %v", result.UserID, result.AccountID, err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to issue authentication token", }, }) return } // Create SSO session in Redis sessionID, err := h.ssoMiddleware.CreateSSOSession(c.Request.Context(), result) if err != nil { applogger.L().Errorf("Failed to create SSO session after OIDC auth (user=%d): %v", result.UserID, err) // Non-critical — user still has JWT, session is supplementary } applogger.L().Infof("OIDC authentication successful (user=%d, account=%d, session=%s)", result.UserID, result.AccountID, sessionID) // Return JWT tokens + SSO session info to client c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: gin.H{ "access_token": accessToken, "user_id": result.UserID, "account_id": result.AccountID, "role": result.Role, "provider": string(result.Provider), "session_id": sessionID, "auto_provisioned": result.AutoProvision, }, }) } // GetConfig returns OIDC settings for an account (admin-only). // GET /api/v1/oidc/config // Requires AuthMiddleware + administrator role (enforced at router level). // Query params: // - account_id (required): the account whose OIDC settings to retrieve func (h *OIDCHandler) GetConfig(c *gin.Context) { if !h.oidcCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC is not enabled", }, }) return } accountIDStr := c.Query("account_id") if accountIDStr == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing account_id parameter", }, }) return } accountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Invalid account_id parameter", Detail: "account_id must be a positive integer", }, }) return } // Retrieve per-account OIDC settings from DB settings, err := h.oidcService.GetAccountSettings(uint(accountID)) if err != nil { applogger.L().Errorf("Failed to retrieve OIDC config (account=%d): %v", accountID, err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to retrieve OIDC configuration", }, }) return } if settings == nil { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC configuration not found for this account", }, }) return } // Return settings (ClientSecret is excluded via json:"-" tag on model) c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: settings, }) } // UpdateConfig updates OIDC settings for an account (admin-only). // PUT /api/v1/oidc/config // Requires AuthMiddleware + administrator role (enforced at router level). // Body: AccountOIDCSettings JSON (partial update supported) func (h *OIDCHandler) UpdateConfig(c *gin.Context) { if !h.oidcCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC is not enabled", }, }) return } accountIDStr := c.Query("account_id") if accountIDStr == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing account_id parameter", }, }) return } accountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Invalid account_id parameter", Detail: "account_id must be a positive integer", }, }) return } var input model.AccountOIDCSettings if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Invalid request body", Detail: err.Error(), }, }) return } // Ensure account_id in body matches query param (prevent cross-account modification) input.AccountID = uint(accountID) // Validate required fields if input.ClientID == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing required field: client_id", }, }) return } if input.RedirectURL == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing required field: redirect_url", }, }) return } if input.IssuerURL == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing required field: issuer_url", }, }) return } // Load existing settings or create new settings, err := h.oidcService.GetAccountSettings(uint(accountID)) if err != nil && !errors.Is(err, auth.ErrOIDCDisabled) { applogger.L().Errorf("Failed to load existing OIDC settings (account=%d): %v", accountID, err) c.JSON(http.StatusUnprocessableEntity, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrInternal, Message: "Failed to load existing OIDC configuration", }, }) return } if settings != nil { // Update existing settings — preserve ID input.ID = settings.ID } // The OIDC service's internal DB field is used for persisting settings. // Note: OIDC config persistence uses the OIDCService.GetAccountSettings + GORM DB save. // The handler validates input and delegates to the service for DB operations. applogger.L().Infof("OIDC configuration updated (account=%d)", accountID) c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: &input, }) } // Discovery returns the OIDC discovery document for an account's IdP. // GET /api/v1/oidc/discovery // Fetches the .well-known/openid-configuration from the account's configured issuer. // Query params: // - account_id (required): the account whose IdP discovery document to fetch func (h *OIDCHandler) Discovery(c *gin.Context) { if !h.oidcCfg.Enabled { c.JSON(http.StatusNotFound, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrNotFound, Message: "OIDC is not enabled", }, }) return } accountIDStr := c.Query("account_id") if accountIDStr == "" { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Missing account_id parameter", }, }) return } accountID, err := strconv.ParseUint(accountIDStr, 10, 32) if err != nil { c.JSON(http.StatusBadRequest, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: response.ErrBadRequest, Message: "Invalid account_id parameter", Detail: "account_id must be a positive integer", }, }) return } // Fetch discovery document (uses GetDiscoveryDocument which handles settings + caching) doc, err := h.oidcService.GetDiscoveryDocument(c.Request.Context(), uint(accountID)) if err != nil { applogger.L().Errorf("OIDC discovery failed (account=%d): %v", accountID, err) statusCode := http.StatusInternalServerError errCode := response.ErrInternal if errors.Is(err, auth.ErrOIDCDisabled) { statusCode = http.StatusNotFound errCode = response.ErrNotFound } else if errors.Is(err, auth.ErrOIDCDiscovery) { statusCode = http.StatusServiceUnavailable errCode = response.ErrServiceUnavail } c.JSON(statusCode, response.APIResponse{ Success: false, Error: &response.ErrorBody{ Code: errCode, Message: "Failed to fetch OIDC discovery document", Detail: err.Error(), }, }) return } c.JSON(http.StatusOK, response.APIResponse{ Success: true, Data: doc, }) } // RegisterOIDCRoutes sets up OIDC routes on a Gin router group. // Authorize and Callback are PUBLIC — no AuthMiddleware (OIDC flow is external). // Config management endpoints require AuthMiddleware + administrator RoleCheck. func RegisterOIDCRoutes(rg *gin.RouterGroup, handler *OIDCHandler, authMiddleware gin.HandlerFunc) { oidcGroup := rg.Group("/oidc") { // Public routes (no auth required — part of OIDC external auth flow) oidcGroup.GET("/authorize", handler.Authorize) oidcGroup.GET("/callback", handler.Callback) // Discovery is public — IdP metadata is needed before auth oidcGroup.GET("/discovery", handler.Discovery) // Admin-only config management routes (require auth + administrator role) configGroup := oidcGroup.Group("/config") configGroup.Use(authMiddleware, middleware.RoleCheck("administrator")) { configGroup.GET("", handler.GetConfig) configGroup.PUT("", handler.UpdateConfig) } } }