second commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user