540 lines
16 KiB
Go
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)
|
|
}
|
|
}
|
|
} |