mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 04:06:21 +00:00
bd6f77e093
Tighten v5-to-v6 upgrade safety, release installability, provider MSP mode handling, AI cost accounting, metrics flushing, and frontend guardrails for the v6.0.0 GA candidate.
1059 lines
37 KiB
Go
1059 lines
37 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/crewjam/saml"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/system"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/extensions"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
const featureSSOKey = "sso"
|
|
|
|
func (r *Router) registerAuthSecurityInstallRoutes() {
|
|
// API routes
|
|
r.mux.HandleFunc("/api/health", r.handleHealth)
|
|
r.mux.HandleFunc("/api/state", r.handleState)
|
|
r.mux.HandleFunc("/api/state/summary", r.handleStateSummary)
|
|
r.mux.HandleFunc("/api/version", r.handleVersion)
|
|
r.mux.HandleFunc("/api/security/validate-bootstrap-token", r.handleValidateBootstrapToken)
|
|
// Security routes
|
|
r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword)
|
|
r.mux.HandleFunc("/api/logout", r.handleLogout)
|
|
r.mux.HandleFunc("/api/login", r.handleLogin)
|
|
r.mux.HandleFunc("/api/security/reset-lockout", r.handleResetLockout)
|
|
ssoAdminEndpoints := resolveSSOAdminEndpoints(ssoAdminEndpointAdapter{router: r}, newSSOAdminRuntime(r))
|
|
// Per-provider SSO OIDC routes: /api/oidc/{providerID}/login and /api/oidc/{providerID}/callback
|
|
// Use a prefix handler since Go 1.x ServeMux doesn't support path params.
|
|
// Requests matching /api/oidc/{something}/ are dispatched here.
|
|
r.mux.HandleFunc("/api/oidc/", func(w http.ResponseWriter, req *http.Request) {
|
|
// Determine which sub-endpoint was requested
|
|
parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
|
|
// Expected: ["api", "oidc", "{providerID}", "{endpoint}"]
|
|
if len(parts) < 4 {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
endpoint := parts[3]
|
|
switch endpoint {
|
|
case "login":
|
|
r.handleSSOOIDCLogin(w, req)
|
|
case "callback":
|
|
r.handleSSOOIDCCallback(w, req)
|
|
default:
|
|
http.NotFound(w, req)
|
|
}
|
|
})
|
|
r.mux.HandleFunc("/api/security/sso/providers/test", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers, featureSSOKey, func(w http.ResponseWriter, req *http.Request) {
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
ssoAdminEndpoints.HandleProviderTest(w, req)
|
|
})))
|
|
r.mux.HandleFunc("/api/security/sso/providers/metadata/preview", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers, featureSSOKey, func(w http.ResponseWriter, req *http.Request) {
|
|
if !ensureSettingsReadScope(r.config, w, req) {
|
|
return
|
|
}
|
|
ssoAdminEndpoints.HandleMetadataPreview(w, req)
|
|
})))
|
|
r.mux.HandleFunc("/api/security/sso/providers", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers, featureSSOKey, func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
if !ensureSettingsReadScope(r.config, w, req) {
|
|
return
|
|
}
|
|
case http.MethodPost:
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
ssoAdminEndpoints.HandleProvidersCollection(w, req)
|
|
})))
|
|
r.mux.HandleFunc("/api/security/sso/providers/", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, RequireLicenseFeature(r.licenseHandlers, featureSSOKey, func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
if !ensureSettingsReadScope(r.config, w, req) {
|
|
return
|
|
}
|
|
case http.MethodPut, http.MethodDelete:
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
ssoAdminEndpoints.HandleProviderItem(w, req)
|
|
})))
|
|
|
|
// SAML login flow routes (unauthenticated - these are login/callback endpoints).
|
|
// SAML is part of the core SSO contract and is included with Community.
|
|
r.mux.HandleFunc("/api/saml/", RequireLicenseFeature(r.licenseHandlers, featureSSOKey, func(w http.ResponseWriter, req *http.Request) {
|
|
parts := strings.Split(strings.TrimPrefix(req.URL.Path, "/"), "/")
|
|
if len(parts) < 4 {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
switch parts[3] {
|
|
case "login":
|
|
r.handleSAMLLogin(w, req)
|
|
case "acs":
|
|
r.handleSAMLACS(w, req)
|
|
case "metadata":
|
|
r.handleSAMLMetadata(w, req)
|
|
case "logout":
|
|
r.handleSAMLLogout(w, req)
|
|
case "slo":
|
|
r.handleSAMLSLO(w, req)
|
|
default:
|
|
http.NotFound(w, req)
|
|
}
|
|
}))
|
|
|
|
r.mux.HandleFunc("/api/security/tokens/relay-mobile", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, func(w http.ResponseWriter, req *http.Request) {
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
if req.Method != http.MethodPost {
|
|
r.handleCreateRelayMobileAccessToken(w, req)
|
|
return
|
|
}
|
|
RequireLicenseFeature(r.licenseHandlers, featureRelayKey, r.handleCreateRelayMobileAccessToken)(w, req)
|
|
}))
|
|
r.mux.HandleFunc("/api/security/tokens", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, func(w http.ResponseWriter, req *http.Request) {
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
if !ensureSettingsReadScope(r.config, w, req) {
|
|
return
|
|
}
|
|
r.handleListAPITokens(w, req)
|
|
case http.MethodPost:
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
r.handleCreateAPIToken(w, req)
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}))
|
|
r.mux.HandleFunc("/api/security/tokens/", RequirePermission(r.config, r.authorizer, auth.ActionAdmin, auth.ResourceUsers, func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method == http.MethodGet {
|
|
if !ensureSettingsReadScope(r.config, w, req) {
|
|
return
|
|
}
|
|
r.handleGetAPIToken(w, req)
|
|
return
|
|
}
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
if strings.HasSuffix(req.URL.Path, "/rotate") && req.Method == http.MethodPost {
|
|
r.handleRotateAPIToken(w, req)
|
|
return
|
|
}
|
|
r.handleDeleteAPIToken(w, req)
|
|
}))
|
|
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method == http.MethodGet {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
// Check for basic auth configuration
|
|
// Check both environment variables and loaded config
|
|
ssoCfg := r.ensureSSOConfig()
|
|
enabledProviders := []config.SSOProvider{}
|
|
if ssoCfg != nil {
|
|
enabledProviders = ssoCfg.GetEnabledProviders()
|
|
}
|
|
hasEnabledSSO := len(enabledProviders) > 0
|
|
var primaryOIDCConfig *config.OIDCProviderConfig
|
|
for i := range enabledProviders {
|
|
if enabledProviders[i].Type == config.SSOProviderTypeOIDC && enabledProviders[i].OIDC != nil {
|
|
primaryOIDCConfig = enabledProviders[i].OIDC
|
|
break
|
|
}
|
|
}
|
|
hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" ||
|
|
os.Getenv("REQUIRE_AUTH") == "true" ||
|
|
r.config.AuthUser != "" ||
|
|
r.config.AuthPass != "" ||
|
|
r.config.HasAPITokens() ||
|
|
r.config.ProxyAuthSecret != "" ||
|
|
r.hostedMode ||
|
|
hasEnabledSSO
|
|
|
|
// Check if .env file exists but hasn't been loaded yet (pending restart)
|
|
configuredButPendingRestart := false
|
|
envPath := resolveAuthEnvPath(r.config.ConfigPath)
|
|
|
|
authLastModified := ""
|
|
if stat, err := os.Stat(envPath); err == nil {
|
|
authLastModified = stat.ModTime().UTC().Format(time.RFC3339)
|
|
if !hasAuthentication && r.config.AuthUser == "" && r.config.AuthPass == "" {
|
|
configuredButPendingRestart = true
|
|
}
|
|
}
|
|
|
|
// Check for audit logging
|
|
hasAuditLogging := os.Getenv("PULSE_AUDIT_LOG") == "true" || os.Getenv("AUDIT_LOG_ENABLED") == "true"
|
|
|
|
// Credentials are always encrypted in current implementation
|
|
credentialsEncrypted := true
|
|
|
|
// Check network context
|
|
clientIP := GetClientIP(req)
|
|
isPrivateNetwork := isPrivateIP(clientIP)
|
|
|
|
// Get trusted networks from environment
|
|
trustedNetworks := []string{}
|
|
if nets := os.Getenv("PULSE_TRUSTED_NETWORKS"); nets != "" {
|
|
trustedNetworks = strings.Split(nets, ",")
|
|
}
|
|
isTrustedNetwork := isTrustedNetwork(clientIP, trustedNetworks)
|
|
|
|
// Determine whether the caller is authenticated before exposing sensitive fields.
|
|
// SECURITY: Do NOT check bearer or query-string tokens here. This endpoint is
|
|
// intentionally limited to session cookies, proxy auth, and X-API-Token.
|
|
authSnapshot := r.buildSecurityStatusAuthSnapshot(req)
|
|
isAuthenticated := authSnapshot.authenticated
|
|
tokenScopes := authSnapshot.tokenScopes()
|
|
|
|
// Create token hint if token exists (only revealed to authenticated callers)
|
|
apiTokenHint := ""
|
|
if isAuthenticated {
|
|
apiTokenHint = r.config.PrimaryAPITokenHint()
|
|
}
|
|
|
|
// Check for proxy auth
|
|
hasProxyAuth := r.config.ProxyAuthSecret != ""
|
|
proxyAuthUsername := ""
|
|
proxyAuthIsAdmin := false
|
|
if hasProxyAuth {
|
|
// Check if current request has valid proxy auth
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid {
|
|
proxyAuthUsername = username
|
|
proxyAuthIsAdmin = isAdmin
|
|
}
|
|
}
|
|
|
|
// Check for SSO-backed session
|
|
ssoSessionUsername := ""
|
|
if hasEnabledSSO {
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
|
|
if ValidateSession(cookie.Value) {
|
|
ssoSessionUsername = GetSessionUsername(cookie.Value)
|
|
}
|
|
}
|
|
}
|
|
|
|
requiresAuth := r.config.HasAPITokens() ||
|
|
(r.config.AuthUser != "" && r.config.AuthPass != "") ||
|
|
r.config.ProxyAuthSecret != "" ||
|
|
hasEnabledSSO
|
|
|
|
// Resolve the public URL for agent install commands
|
|
// If PULSE_PUBLIC_URL is configured, use that; otherwise derive from request
|
|
agentURL := r.resolvePublicURL(req)
|
|
|
|
status := map[string]interface{}{
|
|
"apiTokenConfigured": r.config.HasAPITokens(),
|
|
"apiTokenHint": apiTokenHint,
|
|
"requiresAuth": requiresAuth,
|
|
"exportProtected": r.config.HasAPITokens() || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
|
|
"unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
|
|
"hasAuthentication": hasAuthentication,
|
|
"configuredButPendingRestart": configuredButPendingRestart,
|
|
"hasAuditLogging": hasAuditLogging,
|
|
"credentialsEncrypted": credentialsEncrypted,
|
|
"hasHTTPS": req.TLS != nil || strings.EqualFold(req.Header.Get("X-Forwarded-Proto"), "https"),
|
|
"clientIP": clientIP,
|
|
"isPrivateNetwork": isPrivateNetwork,
|
|
"isTrustedNetwork": isTrustedNetwork,
|
|
"publicAccess": !isPrivateNetwork,
|
|
"hasProxyAuth": hasProxyAuth,
|
|
"proxyAuthLogoutURL": r.config.ProxyAuthLogoutURL,
|
|
"proxyAuthUsername": proxyAuthUsername,
|
|
"proxyAuthIsAdmin": proxyAuthIsAdmin,
|
|
"authUsername": "",
|
|
"authLastModified": "",
|
|
"ssoEnabled": hasEnabledSSO,
|
|
"ssoSessionUsername": ssoSessionUsername,
|
|
"hideLocalLogin": r.config.HideLocalLogin,
|
|
"agentUrl": agentURL,
|
|
"sessionCapabilities": r.securityStatusSessionCapabilities(req.Context()),
|
|
"presentationPolicy": r.securityStatusPresentationPolicy(),
|
|
"settingsCapabilities": r.securityStatusSettingsCapabilitiesFromSnapshot(authSnapshot),
|
|
}
|
|
|
|
if isAuthenticated {
|
|
status["authUsername"] = r.config.AuthUser
|
|
status["authLastModified"] = authLastModified
|
|
}
|
|
|
|
// Include token scopes when authenticated via API token (for kiosk mode UI)
|
|
if len(tokenScopes) > 0 {
|
|
status["tokenScopes"] = tokenScopes
|
|
}
|
|
|
|
if primaryOIDCConfig != nil {
|
|
status["ssoLogoutURL"] = primaryOIDCConfig.LogoutURL
|
|
}
|
|
|
|
// Include SSO providers for login page discovery
|
|
if len(enabledProviders) > 0 {
|
|
baseURL := r.config.PublicURL
|
|
if baseURL == "" {
|
|
baseURL = ""
|
|
}
|
|
type ssoProviderInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
IconURL string `json:"iconUrl,omitempty"`
|
|
LoginURL string `json:"loginUrl"`
|
|
}
|
|
var ssoProviders []ssoProviderInfo
|
|
for _, p := range enabledProviders {
|
|
info := ssoProviderInfo{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Type: string(p.Type),
|
|
DisplayName: p.DisplayName,
|
|
IconURL: p.IconURL,
|
|
}
|
|
if info.DisplayName == "" {
|
|
info.DisplayName = p.Name
|
|
}
|
|
switch p.Type {
|
|
case config.SSOProviderTypeOIDC:
|
|
info.LoginURL = "/api/oidc/" + p.ID + "/login"
|
|
case config.SSOProviderTypeSAML:
|
|
info.LoginURL = "/api/saml/" + p.ID + "/login"
|
|
}
|
|
ssoProviders = append(ssoProviders, info)
|
|
}
|
|
status["ssoProviders"] = ssoProviders
|
|
}
|
|
|
|
// Add bootstrap token location for first-run setup UI
|
|
if r.bootstrapTokenHash != "" {
|
|
status["bootstrapTokenPath"] = r.bootstrapTokenPath
|
|
status["isDocker"] = os.Getenv("PULSE_DOCKER") == "true"
|
|
status["inContainer"] = system.InContainer()
|
|
// Try auto-detection first, then fall back to env override
|
|
if ctid := system.DetectLXCCTID(); ctid != "" {
|
|
status["lxcCtid"] = ctid
|
|
} else if envCtid := os.Getenv("PULSE_LXC_CTID"); envCtid != "" {
|
|
status["lxcCtid"] = envCtid
|
|
}
|
|
if containerName := system.DetectDockerContainerName(); containerName != "" {
|
|
status["dockerContainerName"] = containerName
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(status)
|
|
} else {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Quick security setup route - using fixed version
|
|
r.mux.HandleFunc("/api/security/quick-setup", handleQuickSecuritySetupFixed(r))
|
|
r.mux.HandleFunc("/api/security/dev/reset-first-run", r.handleResetFirstRunSecurity)
|
|
|
|
// API token regeneration endpoint
|
|
r.mux.HandleFunc("/api/security/regenerate-token", r.HandleRegenerateAPIToken)
|
|
|
|
// API token validation endpoint
|
|
r.mux.HandleFunc("/api/security/validate-token", r.HandleValidateAPIToken)
|
|
|
|
// Apply security restart endpoint
|
|
// SECURITY: Require admin auth to prevent DoS via unauthenticated service restarts
|
|
r.mux.HandleFunc("/api/security/apply-restart", func(w http.ResponseWriter, req *http.Request) {
|
|
if req.Method == http.MethodPost {
|
|
// SECURITY: Require authentication - this endpoint can trigger service restart (DoS risk)
|
|
// Allow if: (1) auth is not configured yet (initial setup), or (2) caller is admin-authenticated
|
|
authConfigured := (r.config.AuthUser != "" && r.config.AuthPass != "") ||
|
|
r.config.HasAPITokens() ||
|
|
r.config.ProxyAuthSecret != "" ||
|
|
(func() bool {
|
|
ssoCfg := r.ensureSSOConfig()
|
|
return ssoCfg != nil && ssoCfg.HasEnabledProviders()
|
|
})()
|
|
if authConfigured {
|
|
if !CheckAuth(r.config, w, req) {
|
|
log.Warn().
|
|
Str("ip", GetClientIP(req)).
|
|
Msg("Unauthenticated apply-restart attempt blocked")
|
|
return // CheckAuth already wrote the error
|
|
}
|
|
// Check proxy auth for admin status (session users with basic auth are implicitly admin)
|
|
if r.config.ProxyAuthSecret != "" {
|
|
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid && !isAdmin {
|
|
log.Warn().
|
|
Str("ip", GetClientIP(req)).
|
|
Str("username", username).
|
|
Msg("Non-admin user attempted service restart")
|
|
http.Error(w, "Admin privileges required", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
// Require settings:write scope for API tokens
|
|
if !ensureSettingsWriteScope(r.config, w, req) {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Only allow restart if we're running under systemd (safer)
|
|
isSystemd := os.Getenv("INVOCATION_ID") != ""
|
|
|
|
if !isSystemd {
|
|
response := map[string]interface{}{
|
|
"success": false,
|
|
"message": "Automatic restart is only available when running under systemd. Please restart Pulse manually.",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
return
|
|
}
|
|
|
|
// Retire the legacy filesystem recovery toggle before restart. Recovery
|
|
// remains available through the localhost-bound recovery session flow.
|
|
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
|
|
if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) {
|
|
log.Warn().Err(err).Str("path", recoveryFile).Msg("Failed to remove legacy recovery flag file")
|
|
}
|
|
|
|
// Schedule restart with full service restart to pick up new config
|
|
go func() {
|
|
time.Sleep(2 * time.Second)
|
|
log.Info().Msg("Triggering restart to apply security settings")
|
|
|
|
// We need to do a full systemctl restart to pick up new environment variables
|
|
// First try daemon-reload
|
|
cmd := exec.Command("sudo", "-n", "systemctl", "daemon-reload")
|
|
if err := cmd.Run(); err != nil {
|
|
log.Error().Err(err).Msg("Failed to reload systemd daemon")
|
|
}
|
|
|
|
// Then restart the service - this will kill us and restart with new env
|
|
time.Sleep(500 * time.Millisecond)
|
|
// Try to restart with the detected service name
|
|
serviceName := detectServiceName()
|
|
cmd = exec.Command("sudo", "-n", "systemctl", "restart", serviceName)
|
|
if err := cmd.Run(); err != nil {
|
|
log.Error().Err(err).Str("service", serviceName).Msg("Failed to restart service, falling back to exit")
|
|
// Fallback to exit if restart fails
|
|
os.Exit(0)
|
|
}
|
|
// If restart succeeds, we'll be killed by systemctl
|
|
}()
|
|
|
|
response := map[string]interface{}{
|
|
"success": true,
|
|
"message": "Restarting Pulse to apply security settings...",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
|
|
// Recovery endpoint - requires localhost access OR valid recovery token
|
|
r.mux.HandleFunc("/api/security/recovery", func(w http.ResponseWriter, req *http.Request) {
|
|
// Get client IP
|
|
isLoopback := isDirectLoopbackRequest(req)
|
|
clientIP := GetClientIP(req)
|
|
|
|
// Check for recovery token in header
|
|
recoveryToken := req.Header.Get("X-Recovery-Token")
|
|
hasValidToken := false
|
|
if recoveryToken != "" {
|
|
hasValidToken = GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, clientIP)
|
|
}
|
|
|
|
// Only allow from localhost OR with valid recovery token
|
|
if !isLoopback && !hasValidToken {
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Bool("direct_loopback", isLoopback).
|
|
Bool("has_token", recoveryToken != "").
|
|
Msg("Unauthorized recovery endpoint access attempt")
|
|
http.Error(w, "Recovery endpoint requires localhost access or valid recovery token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if req.Method == http.MethodPost {
|
|
// Parse action
|
|
var recoveryRequest struct {
|
|
Action string `json:"action"`
|
|
Duration int `json:"duration,omitempty"` // Duration in minutes for token generation
|
|
}
|
|
|
|
if err := json.NewDecoder(req.Body).Decode(&recoveryRequest); err != nil {
|
|
http.Error(w, "Invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
response := map[string]interface{}{}
|
|
|
|
switch recoveryRequest.Action {
|
|
case "generate_token":
|
|
// Only allow token generation from localhost
|
|
if !isLoopback {
|
|
http.Error(w, "Token generation only allowed from localhost", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Default to 15 minutes if not specified
|
|
duration := 15
|
|
if recoveryRequest.Duration > 0 && recoveryRequest.Duration <= 60 {
|
|
duration = recoveryRequest.Duration
|
|
}
|
|
|
|
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Duration(duration)*time.Minute, clientIP)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to generate recovery token")
|
|
response["success"] = false
|
|
response["message"] = "Failed to generate recovery token"
|
|
} else {
|
|
response["success"] = true
|
|
response["token"] = token
|
|
response["expires_in_minutes"] = duration
|
|
response["message"] = fmt.Sprintf("Recovery token generated. Valid for %d minutes.", duration)
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Bool("direct_loopback", isLoopback).
|
|
Int("duration_minutes", duration).
|
|
Msg("Recovery token generated")
|
|
}
|
|
|
|
case "disable_auth":
|
|
recoveryUser := strings.TrimSpace(r.config.AuthUser)
|
|
if recoveryUser == "" {
|
|
recoveryUser = "recovery"
|
|
}
|
|
if err := r.establishRecoverySession(w, req, recoveryUser); err != nil {
|
|
log.Error().Err(err).Msg("Failed to establish recovery session")
|
|
response["success"] = false
|
|
response["message"] = "Failed to enable recovery mode"
|
|
} else {
|
|
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
|
|
if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) {
|
|
log.Warn().Err(err).Str("path", recoveryFile).Msg("Failed to remove legacy recovery flag file")
|
|
}
|
|
response["success"] = true
|
|
response["message"] = "Recovery mode enabled for this local browser session only."
|
|
log.Warn().
|
|
Str("ip", clientIP).
|
|
Bool("direct_loopback", isLoopback).
|
|
Bool("via_token", hasValidToken).
|
|
Msg("AUTH RECOVERY: Recovery session established")
|
|
}
|
|
|
|
case "enable_auth":
|
|
r.clearSession(w, req)
|
|
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
|
|
if err := os.Remove(recoveryFile); err != nil && !os.IsNotExist(err) {
|
|
log.Error().Err(err).Msg("Failed to disable recovery mode")
|
|
response["success"] = false
|
|
response["message"] = "Failed to disable recovery mode"
|
|
} else {
|
|
response["success"] = true
|
|
response["message"] = "Recovery mode disabled for this browser session."
|
|
log.Info().Msg("AUTH RECOVERY: Recovery session cleared via recovery endpoint")
|
|
}
|
|
|
|
default:
|
|
response["success"] = false
|
|
response["message"] = "Invalid action. Use 'disable_auth' or 'enable_auth'"
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else if req.Method == http.MethodGet {
|
|
recoveryMode := false
|
|
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" && ValidateSession(cookie.Value) {
|
|
if session := GetSessionStore().GetSession(cookie.Value); requestMatchesRecoverySession(req, session) {
|
|
recoveryMode = true
|
|
}
|
|
}
|
|
response := map[string]interface{}{
|
|
"recovery_mode": recoveryMode,
|
|
"message": "Recovery endpoint accessible from localhost only; recovery sessions are browser-bound",
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
})
|
|
// Agent WebSocket for AI command execution
|
|
r.mux.HandleFunc("/api/agent/ws", r.handleAgentWebSocket)
|
|
|
|
// Unified Agent endpoints (public but rate limited)
|
|
r.mux.HandleFunc("/install.sh", r.downloadLimiter.Middleware(r.handleDownloadUnifiedInstallScript))
|
|
r.mux.HandleFunc("/install.ps1", r.downloadLimiter.Middleware(r.handleDownloadUnifiedInstallScriptPS))
|
|
r.mux.HandleFunc("/download/pulse-agent", r.downloadLimiter.Middleware(r.handleDownloadUnifiedAgent))
|
|
|
|
r.mux.HandleFunc("/api/agent/version", r.handleAgentVersion)
|
|
r.mux.HandleFunc("/api/server/info", r.handleServerInfo)
|
|
|
|
// WebSocket endpoint
|
|
r.mux.HandleFunc("/ws", r.handleWebSocket)
|
|
|
|
// Simple stats page - requires authentication
|
|
r.mux.HandleFunc("/simple-stats", RequireAuth(r.config, r.handleSimpleStats))
|
|
}
|
|
|
|
type ssoAdminEndpointAdapter struct {
|
|
router *Router
|
|
}
|
|
|
|
func newSSOAdminRuntime(router *Router) extensions.SSOAdminRuntime {
|
|
runtime := extensions.SSOAdminRuntime{
|
|
GetClientIP: GetClientIP,
|
|
AllowAuthRequest: func(clientIP string) bool {
|
|
return authLimiter.Allow(clientIP)
|
|
},
|
|
LogAuditEvent: func(ctx context.Context, event, path string, success bool, message, clientIP string) {
|
|
LogAuditEventForTenant(GetOrgID(ctx), event, "", clientIP, path, success, message)
|
|
},
|
|
WriteError: writeErrorResponse,
|
|
RequireFeature: func(ctx context.Context, feature string) error {
|
|
if router == nil || router.licenseHandlers == nil {
|
|
return fmt.Errorf("license service unavailable")
|
|
}
|
|
svc := router.licenseHandlers.Service(ctx)
|
|
if svc == nil {
|
|
return fmt.Errorf("license service unavailable")
|
|
}
|
|
return svc.RequireFeature(feature)
|
|
},
|
|
WriteLicenseRequired: WriteLicenseRequired,
|
|
}
|
|
|
|
if router == nil {
|
|
return runtime
|
|
}
|
|
|
|
runtime.TestSAMLConnection = func(ctx context.Context, cfg *extensions.SAMLTestConfig) extensions.SSOTestResponse {
|
|
return toExtensionSSOTestResponse(router.testSAMLConnection(ctx, toAPISAMLTestConfig(cfg)))
|
|
}
|
|
runtime.TestOIDCConnection = func(ctx context.Context, cfg *extensions.OIDCTestConfig) extensions.SSOTestResponse {
|
|
return toExtensionSSOTestResponse(router.testOIDCConnection(ctx, toAPIOIDCTestConfig(cfg)))
|
|
}
|
|
runtime.PreviewSAMLMetadata = previewSAMLMetadataFromRuntime
|
|
runtime.IsValidProviderID = validateProviderID
|
|
runtime.GetSSOConfigSnapshot = func() extensions.SSOConfigSnapshot {
|
|
return toExtensionSSOConfigSnapshot(router.ensureSSOConfig())
|
|
}
|
|
runtime.SaveSSOConfigSnapshot = func(snapshot extensions.SSOConfigSnapshot) error {
|
|
if router == nil {
|
|
return nil
|
|
}
|
|
previous := router.ssoConfig
|
|
router.ssoConfig = toCoreSSOConfig(snapshot)
|
|
if err := router.saveSSOConfig(); err != nil {
|
|
router.ssoConfig = previous
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
runtime.GetPublicURL = func() string {
|
|
if router == nil || router.config == nil {
|
|
return ""
|
|
}
|
|
return router.config.PublicURL
|
|
}
|
|
runtime.InitializeSAMLProvider = func(ctx context.Context, id string, samlCfg *extensions.SAMLProviderConfig) error {
|
|
if router == nil || router.samlManager == nil || samlCfg == nil {
|
|
return nil
|
|
}
|
|
return router.samlManager.InitializeProvider(ctx, id, toCoreSAMLProviderConfig(samlCfg))
|
|
}
|
|
runtime.RemoveSAMLProvider = func(id string) {
|
|
if router == nil || router.samlManager == nil {
|
|
return
|
|
}
|
|
router.samlManager.RemoveProvider(id)
|
|
}
|
|
runtime.InitializeOIDCProvider = func(ctx context.Context, id string, provider *extensions.SSOProvider) error {
|
|
if router == nil || router.oidcManager == nil || provider == nil {
|
|
return nil
|
|
}
|
|
coreProvider := toCoreSSOProvider(*provider)
|
|
return router.oidcManager.InitializeProvider(ctx, id, &coreProvider, "")
|
|
}
|
|
runtime.RemoveOIDCProvider = func(id string) {
|
|
if router == nil || router.oidcManager == nil {
|
|
return
|
|
}
|
|
router.oidcManager.RemoveService(id)
|
|
}
|
|
return runtime
|
|
}
|
|
|
|
func toAPISAMLTestConfig(cfg *extensions.SAMLTestConfig) *SAMLTestConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &SAMLTestConfig{
|
|
IDPMetadataURL: cfg.IDPMetadataURL,
|
|
IDPMetadataXML: cfg.IDPMetadataXML,
|
|
IDPSSOURL: cfg.IDPSSOURL,
|
|
IDPCertificate: cfg.IDPCertificate,
|
|
}
|
|
}
|
|
|
|
func toAPIOIDCTestConfig(cfg *extensions.OIDCTestConfig) *OIDCTestConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &OIDCTestConfig{
|
|
IssuerURL: cfg.IssuerURL,
|
|
ClientID: cfg.ClientID,
|
|
}
|
|
}
|
|
|
|
func toExtensionSSOTestResponse(resp SSOTestResponse) extensions.SSOTestResponse {
|
|
return extensions.SSOTestResponse{
|
|
Success: resp.Success,
|
|
Message: resp.Message,
|
|
Error: resp.Error,
|
|
Details: toExtensionSSOTestDetails(resp.Details),
|
|
}
|
|
}
|
|
|
|
func toExtensionSSOTestDetails(details *SSOTestDetails) *extensions.SSOTestDetails {
|
|
if details == nil {
|
|
return nil
|
|
}
|
|
|
|
converted := &extensions.SSOTestDetails{
|
|
Type: details.Type,
|
|
EntityID: details.EntityID,
|
|
SSOURL: details.SSOURL,
|
|
SLOURL: details.SLOURL,
|
|
TokenEndpoint: details.TokenEndpoint,
|
|
UserinfoEndpoint: details.UserinfoEndpoint,
|
|
JWKSURI: details.JWKSURI,
|
|
SupportedScopes: details.SupportedScopes,
|
|
}
|
|
|
|
if len(details.Certificates) > 0 {
|
|
converted.Certificates = make([]extensions.CertificateInfo, 0, len(details.Certificates))
|
|
for _, cert := range details.Certificates {
|
|
converted.Certificates = append(converted.Certificates, extensions.CertificateInfo{
|
|
Subject: cert.Subject,
|
|
Issuer: cert.Issuer,
|
|
NotBefore: cert.NotBefore,
|
|
NotAfter: cert.NotAfter,
|
|
IsExpired: cert.IsExpired,
|
|
})
|
|
}
|
|
}
|
|
|
|
return converted
|
|
}
|
|
|
|
func toExtensionSSOConfigSnapshot(cfg *config.SSOConfig) extensions.SSOConfigSnapshot {
|
|
if cfg == nil {
|
|
return extensions.SSOConfigSnapshot{
|
|
Providers: []extensions.SSOProvider{},
|
|
AllowMultipleProviders: true,
|
|
}
|
|
}
|
|
|
|
out := extensions.SSOConfigSnapshot{
|
|
Providers: make([]extensions.SSOProvider, 0, len(cfg.Providers)),
|
|
DefaultProviderID: cfg.DefaultProviderID,
|
|
AllowMultipleProviders: cfg.AllowMultipleProviders,
|
|
}
|
|
|
|
for _, p := range cfg.Providers {
|
|
out.Providers = append(out.Providers, toExtensionSSOProvider(p))
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func toExtensionSSOProvider(p config.SSOProvider) extensions.SSOProvider {
|
|
out := extensions.SSOProvider{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Type: extensions.SSOProviderType(p.Type),
|
|
Enabled: p.Enabled,
|
|
DisplayName: p.DisplayName,
|
|
IconURL: p.IconURL,
|
|
Priority: p.Priority,
|
|
AllowedGroups: p.AllowedGroups,
|
|
AllowedDomains: p.AllowedDomains,
|
|
AllowedEmails: p.AllowedEmails,
|
|
GroupsClaim: p.GroupsClaim,
|
|
GroupRoleMappings: p.GroupRoleMappings,
|
|
}
|
|
|
|
if p.OIDC != nil {
|
|
out.OIDC = &extensions.OIDCProviderConfig{
|
|
IssuerURL: p.OIDC.IssuerURL,
|
|
ClientID: p.OIDC.ClientID,
|
|
ClientSecret: p.OIDC.ClientSecret,
|
|
RedirectURL: p.OIDC.RedirectURL,
|
|
LogoutURL: p.OIDC.LogoutURL,
|
|
Scopes: p.OIDC.Scopes,
|
|
UsernameClaim: p.OIDC.UsernameClaim,
|
|
EmailClaim: p.OIDC.EmailClaim,
|
|
CABundle: p.OIDC.CABundle,
|
|
ClientSecretSet: p.OIDC.ClientSecretSet,
|
|
}
|
|
}
|
|
|
|
if p.SAML != nil {
|
|
out.SAML = &extensions.SAMLProviderConfig{
|
|
IDPMetadataURL: p.SAML.IDPMetadataURL,
|
|
IDPMetadataXML: p.SAML.IDPMetadataXML,
|
|
IDPSSOURL: p.SAML.IDPSSOURL,
|
|
IDPSLOURL: p.SAML.IDPSLOURL,
|
|
IDPCertificate: p.SAML.IDPCertificate,
|
|
IDPCertFile: p.SAML.IDPCertFile,
|
|
IDPEntityID: p.SAML.IDPEntityID,
|
|
IDPIssuer: p.SAML.IDPIssuer,
|
|
SPEntityID: p.SAML.SPEntityID,
|
|
SPACSPath: p.SAML.SPACSPath,
|
|
SPMetadataPath: p.SAML.SPMetadataPath,
|
|
SPCertificate: p.SAML.SPCertificate,
|
|
SPPrivateKey: p.SAML.SPPrivateKey,
|
|
SPCertFile: p.SAML.SPCertFile,
|
|
SPKeyFile: p.SAML.SPKeyFile,
|
|
SignRequests: p.SAML.SignRequests,
|
|
WantAssertionsSigned: p.SAML.WantAssertionsSigned,
|
|
AllowUnencrypted: p.SAML.AllowUnencrypted,
|
|
UsernameAttr: p.SAML.UsernameAttr,
|
|
EmailAttr: p.SAML.EmailAttr,
|
|
GroupsAttr: p.SAML.GroupsAttr,
|
|
FirstNameAttr: p.SAML.FirstNameAttr,
|
|
LastNameAttr: p.SAML.LastNameAttr,
|
|
NameIDFormat: p.SAML.NameIDFormat,
|
|
ForceAuthn: p.SAML.ForceAuthn,
|
|
AllowIDPInitiated: p.SAML.AllowIDPInitiated,
|
|
RelayStateTemplate: p.SAML.RelayStateTemplate,
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func toCoreSSOConfig(snapshot extensions.SSOConfigSnapshot) *config.SSOConfig {
|
|
cfg := &config.SSOConfig{
|
|
Providers: make([]config.SSOProvider, 0, len(snapshot.Providers)),
|
|
DefaultProviderID: snapshot.DefaultProviderID,
|
|
AllowMultipleProviders: snapshot.AllowMultipleProviders,
|
|
}
|
|
for _, p := range snapshot.Providers {
|
|
cfg.Providers = append(cfg.Providers, toCoreSSOProvider(p))
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func toCoreSSOProvider(p extensions.SSOProvider) config.SSOProvider {
|
|
out := config.SSOProvider{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Type: config.SSOProviderType(p.Type),
|
|
Enabled: p.Enabled,
|
|
DisplayName: p.DisplayName,
|
|
IconURL: p.IconURL,
|
|
Priority: p.Priority,
|
|
AllowedGroups: p.AllowedGroups,
|
|
AllowedDomains: p.AllowedDomains,
|
|
AllowedEmails: p.AllowedEmails,
|
|
GroupsClaim: p.GroupsClaim,
|
|
GroupRoleMappings: p.GroupRoleMappings,
|
|
}
|
|
|
|
if p.OIDC != nil {
|
|
out.OIDC = &config.OIDCProviderConfig{
|
|
IssuerURL: p.OIDC.IssuerURL,
|
|
ClientID: p.OIDC.ClientID,
|
|
ClientSecret: p.OIDC.ClientSecret,
|
|
RedirectURL: p.OIDC.RedirectURL,
|
|
LogoutURL: p.OIDC.LogoutURL,
|
|
Scopes: p.OIDC.Scopes,
|
|
UsernameClaim: p.OIDC.UsernameClaim,
|
|
EmailClaim: p.OIDC.EmailClaim,
|
|
CABundle: p.OIDC.CABundle,
|
|
ClientSecretSet: p.OIDC.ClientSecretSet,
|
|
}
|
|
}
|
|
|
|
if p.SAML != nil {
|
|
out.SAML = toCoreSAMLProviderConfig(p.SAML)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func toCoreSAMLProviderConfig(cfg *extensions.SAMLProviderConfig) *config.SAMLProviderConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
return &config.SAMLProviderConfig{
|
|
IDPMetadataURL: cfg.IDPMetadataURL,
|
|
IDPMetadataXML: cfg.IDPMetadataXML,
|
|
IDPSSOURL: cfg.IDPSSOURL,
|
|
IDPSLOURL: cfg.IDPSLOURL,
|
|
IDPCertificate: cfg.IDPCertificate,
|
|
IDPCertFile: cfg.IDPCertFile,
|
|
IDPEntityID: cfg.IDPEntityID,
|
|
IDPIssuer: cfg.IDPIssuer,
|
|
SPEntityID: cfg.SPEntityID,
|
|
SPACSPath: cfg.SPACSPath,
|
|
SPMetadataPath: cfg.SPMetadataPath,
|
|
SPCertificate: cfg.SPCertificate,
|
|
SPPrivateKey: cfg.SPPrivateKey,
|
|
SPCertFile: cfg.SPCertFile,
|
|
SPKeyFile: cfg.SPKeyFile,
|
|
SignRequests: cfg.SignRequests,
|
|
WantAssertionsSigned: cfg.WantAssertionsSigned,
|
|
AllowUnencrypted: cfg.AllowUnencrypted,
|
|
UsernameAttr: cfg.UsernameAttr,
|
|
EmailAttr: cfg.EmailAttr,
|
|
GroupsAttr: cfg.GroupsAttr,
|
|
FirstNameAttr: cfg.FirstNameAttr,
|
|
LastNameAttr: cfg.LastNameAttr,
|
|
NameIDFormat: cfg.NameIDFormat,
|
|
ForceAuthn: cfg.ForceAuthn,
|
|
AllowIDPInitiated: cfg.AllowIDPInitiated,
|
|
RelayStateTemplate: cfg.RelayStateTemplate,
|
|
}
|
|
}
|
|
|
|
func previewSAMLMetadataFromRuntime(ctx context.Context, req extensions.MetadataPreviewRequest) (extensions.MetadataPreviewResponse, error) {
|
|
var (
|
|
rawXML []byte
|
|
metadata *saml.EntityDescriptor
|
|
err error
|
|
)
|
|
|
|
httpClient := newTestHTTPClient()
|
|
|
|
if req.MetadataURL != "" {
|
|
if !validateURL(req.MetadataURL, []string{"https", "http"}) {
|
|
return extensions.MetadataPreviewResponse{}, &extensions.MetadataPreviewError{
|
|
Code: "validation_error",
|
|
Message: "Invalid metadata URL",
|
|
}
|
|
}
|
|
rawXML, metadata, err = fetchSAMLMetadataFromURL(ctx, httpClient, req.MetadataURL)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to fetch SAML metadata for preview")
|
|
return extensions.MetadataPreviewResponse{}, &extensions.MetadataPreviewError{
|
|
Code: "fetch_error",
|
|
Message: "Failed to fetch metadata from the provided URL",
|
|
}
|
|
}
|
|
} else {
|
|
rawXML = []byte(req.MetadataXML)
|
|
metadata, err = parseSAMLMetadataXML(rawXML)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to parse SAML metadata XML for preview")
|
|
return extensions.MetadataPreviewResponse{}, &extensions.MetadataPreviewError{
|
|
Code: "parse_error",
|
|
Message: "Failed to parse metadata XML",
|
|
}
|
|
}
|
|
}
|
|
|
|
parsed := &extensions.ParsedMetadataInfo{
|
|
EntityID: metadata.EntityID,
|
|
}
|
|
|
|
if len(metadata.IDPSSODescriptors) > 0 {
|
|
idpDesc := metadata.IDPSSODescriptors[0]
|
|
for _, sso := range idpDesc.SingleSignOnServices {
|
|
if sso.Binding == saml.HTTPPostBinding || sso.Binding == saml.HTTPRedirectBinding {
|
|
parsed.SSOURL = sso.Location
|
|
break
|
|
}
|
|
}
|
|
for _, slo := range idpDesc.SingleLogoutServices {
|
|
parsed.SLOURL = slo.Location
|
|
break
|
|
}
|
|
for _, nid := range idpDesc.NameIDFormats {
|
|
parsed.NameIDFormats = append(parsed.NameIDFormats, string(nid))
|
|
}
|
|
for _, kd := range idpDesc.KeyDescriptors {
|
|
if kd.Use == "signing" || kd.Use == "" {
|
|
for _, x509Cert := range kd.KeyInfo.X509Data.X509Certificates {
|
|
certInfo := extractCertificateInfo(x509Cert.Data)
|
|
if certInfo != nil {
|
|
parsed.Certificates = append(parsed.Certificates, extensions.CertificateInfo{
|
|
Subject: certInfo.Subject,
|
|
Issuer: certInfo.Issuer,
|
|
NotBefore: certInfo.NotBefore,
|
|
NotAfter: certInfo.NotAfter,
|
|
IsExpired: certInfo.IsExpired,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return extensions.MetadataPreviewResponse{
|
|
XML: formatXML(rawXML),
|
|
Parsed: parsed,
|
|
}, nil
|
|
}
|
|
|
|
var _ extensions.SSOAdminEndpoints = ssoAdminEndpointAdapter{}
|
|
|
|
func (a ssoAdminEndpointAdapter) HandleProvidersCollection(w http.ResponseWriter, req *http.Request) {
|
|
if a.router == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "sso_unavailable", "SSO management is unavailable", nil)
|
|
return
|
|
}
|
|
a.router.handleSSOProviders(w, req)
|
|
}
|
|
|
|
func (a ssoAdminEndpointAdapter) HandleProviderItem(w http.ResponseWriter, req *http.Request) {
|
|
if a.router == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "sso_unavailable", "SSO management is unavailable", nil)
|
|
return
|
|
}
|
|
a.router.handleSSOProvider(w, req)
|
|
}
|
|
|
|
func (a ssoAdminEndpointAdapter) HandleProviderTest(w http.ResponseWriter, req *http.Request) {
|
|
if a.router == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "sso_unavailable", "SSO management is unavailable", nil)
|
|
return
|
|
}
|
|
a.router.handleTestSSOProvider(w, req)
|
|
}
|
|
|
|
func (a ssoAdminEndpointAdapter) HandleMetadataPreview(w http.ResponseWriter, req *http.Request) {
|
|
if a.router == nil {
|
|
writeErrorResponse(w, http.StatusServiceUnavailable, "sso_unavailable", "SSO management is unavailable", nil)
|
|
return
|
|
}
|
|
a.router.handleMetadataPreview(w, req)
|
|
}
|