Files
gochat/backend/internal/handler/api/v1/oidc_handler.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

540 lines
16 KiB
Go

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)
}
}
}