Pulse/internal/config/sso.go
rcourtman 97701297c4 feat(sso): add SAML 2.0 and multi-provider SSO support
- Add SAML 2.0 Service Provider implementation using crewjam/saml
- Support IdP metadata from URL or raw XML
- Add multi-provider SSO configuration model
- Implement provider management API (CRUD operations)
- Add provider connection testing endpoint
- Add IdP metadata preview endpoint
- Add SSOProvidersPanel component for settings UI
- Support attribute-based role mapping (groups → Pulse roles)

API endpoints:
- GET/POST /api/security/sso/providers - List/create providers
- GET/PUT/DELETE /api/security/sso/providers/{id} - Provider CRUD
- POST /api/security/sso/providers/test - Test connection
- POST /api/security/sso/providers/metadata/preview - Preview metadata
- /api/saml/{id}/login, /acs, /metadata, /logout, /slo - SAML endpoints
2026-01-12 15:19:59 +00:00

461 lines
15 KiB
Go

package config
import (
"fmt"
"net/url"
"strings"
)
// SSOProviderType defines the type of SSO provider
type SSOProviderType string
const (
SSOProviderTypeOIDC SSOProviderType = "oidc"
SSOProviderTypeSAML SSOProviderType = "saml"
)
// SSOProvider represents a single SSO identity provider configuration.
// It can be either OIDC or SAML type.
type SSOProvider struct {
// Common fields
ID string `json:"id"` // Unique identifier (auto-generated if empty)
Name string `json:"name"` // Display name (e.g., "Corporate SSO", "Google", "Okta")
Type SSOProviderType `json:"type"` // "oidc" or "saml"
Enabled bool `json:"enabled"` // Whether this provider is active
DisplayName string `json:"displayName,omitempty"` // Button text on login page (defaults to Name)
IconURL string `json:"iconUrl,omitempty"` // Optional icon URL for login button
Priority int `json:"priority,omitempty"` // Display order (lower = higher priority)
// Access restrictions (apply to both OIDC and SAML)
AllowedGroups []string `json:"allowedGroups,omitempty"` // Restrict to specific groups
AllowedDomains []string `json:"allowedDomains,omitempty"` // Restrict to email domains
AllowedEmails []string `json:"allowedEmails,omitempty"` // Restrict to specific emails
// Role mapping (apply to both OIDC and SAML)
GroupsClaim string `json:"groupsClaim,omitempty"` // Claim/attribute containing groups
GroupRoleMappings map[string]string `json:"groupRoleMappings,omitempty"` // Map groups to Pulse roles
// OIDC-specific configuration
OIDC *OIDCProviderConfig `json:"oidc,omitempty"`
// SAML-specific configuration
SAML *SAMLProviderConfig `json:"saml,omitempty"`
}
// OIDCProviderConfig contains OIDC-specific settings
type OIDCProviderConfig struct {
IssuerURL string `json:"issuerUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret,omitempty"`
RedirectURL string `json:"redirectUrl,omitempty"` // Optional - auto-detected if empty
LogoutURL string `json:"logoutUrl,omitempty"` // Optional OIDC end-session URL
Scopes []string `json:"scopes,omitempty"` // Defaults to ["openid", "profile", "email"]
UsernameClaim string `json:"usernameClaim,omitempty"`
EmailClaim string `json:"emailClaim,omitempty"`
CABundle string `json:"caBundle,omitempty"` // Path to CA bundle for self-signed certs
// Internal tracking
ClientSecretSet bool `json:"clientSecretSet,omitempty"` // True if secret is configured (don't expose actual value)
EnvOverrides map[string]bool `json:"-"` // Fields locked by env vars
}
// SAMLProviderConfig contains SAML 2.0 specific settings
type SAMLProviderConfig struct {
// Identity Provider settings (from IdP metadata)
IDPMetadataURL string `json:"idpMetadataUrl,omitempty"` // URL to fetch IdP metadata (preferred)
IDPMetadataXML string `json:"idpMetadataXml,omitempty"` // Raw XML metadata (alternative to URL)
IDPSSOURL string `json:"idpSsoUrl,omitempty"` // SSO URL (extracted from metadata or manual)
IDPSLOUrl string `json:"idpSloUrl,omitempty"` // Single Logout URL (optional)
IDPCertificate string `json:"idpCertificate,omitempty"` // IdP signing certificate (PEM format)
IDPCertFile string `json:"idpCertFile,omitempty"` // Path to IdP certificate file (alternative)
IDPEntityID string `json:"idpEntityId,omitempty"` // IdP Entity ID (extracted from metadata or manual)
IDPIssuer string `json:"idpIssuer,omitempty"` // Alias for IDPEntityID for compatibility
// Service Provider settings (Pulse as SP)
SPEntityID string `json:"spEntityId,omitempty"` // Pulse's Entity ID (auto-generated if empty)
SPACSPath string `json:"spAcsPath,omitempty"` // Assertion Consumer Service path (default: /api/saml/{id}/acs)
SPMetadataPath string `json:"spMetadataPath,omitempty"` // SP Metadata path (default: /api/saml/{id}/metadata)
SPCertificate string `json:"spCertificate,omitempty"` // SP signing certificate (PEM format, optional)
SPPrivateKey string `json:"spPrivateKey,omitempty"` // SP private key (PEM format, optional)
SPCertFile string `json:"spCertFile,omitempty"` // Path to SP certificate file
SPKeyFile string `json:"spKeyFile,omitempty"` // Path to SP private key file
SignRequests bool `json:"signRequests,omitempty"` // Sign SAML requests (requires SP cert/key)
WantAssertionsSigned bool `json:"wantAssertionsSigned,omitempty"` // Require signed assertions (recommended)
AllowUnencrypted bool `json:"allowUnencrypted,omitempty"` // Allow unencrypted assertions (less secure)
// Attribute mapping
UsernameAttr string `json:"usernameAttr,omitempty"` // SAML attribute for username (default: NameID)
EmailAttr string `json:"emailAttr,omitempty"` // SAML attribute for email
GroupsAttr string `json:"groupsAttr,omitempty"` // SAML attribute for groups
FirstNameAttr string `json:"firstNameAttr,omitempty"`
LastNameAttr string `json:"lastNameAttr,omitempty"`
// Advanced settings
NameIDFormat string `json:"nameIdFormat,omitempty"` // NameID format (default: unspecified)
ForceAuthn bool `json:"forceAuthn,omitempty"` // Force re-authentication
AllowIDPInitiated bool `json:"allowIdpInitiated,omitempty"` // Allow IdP-initiated SSO (security risk)
RelayStateTemplate string `json:"relayStateTemplate,omitempty"` // Custom relay state template
}
// SSOConfig holds the complete SSO configuration with multiple providers
type SSOConfig struct {
// Providers is the list of configured SSO providers
Providers []SSOProvider `json:"providers,omitempty"`
// DefaultProviderID is the provider to use when none is specified
// If empty and only one provider exists, that provider is used
DefaultProviderID string `json:"defaultProviderId,omitempty"`
// AllowMultipleProviders enables showing provider selection on login page
// If false and multiple providers exist, only the default is shown
AllowMultipleProviders bool `json:"allowMultipleProviders,omitempty"`
}
// NewSSOConfig creates a new empty SSO configuration
func NewSSOConfig() *SSOConfig {
return &SSOConfig{
Providers: []SSOProvider{},
AllowMultipleProviders: true,
}
}
// GetProvider returns a provider by ID
func (c *SSOConfig) GetProvider(id string) *SSOProvider {
if c == nil {
return nil
}
for i := range c.Providers {
if c.Providers[i].ID == id {
return &c.Providers[i]
}
}
return nil
}
// GetEnabledProviders returns all enabled providers sorted by priority
func (c *SSOConfig) GetEnabledProviders() []SSOProvider {
if c == nil {
return nil
}
result := make([]SSOProvider, 0)
for _, p := range c.Providers {
if p.Enabled {
result = append(result, p)
}
}
// Sort by priority (lower = first)
for i := 0; i < len(result)-1; i++ {
for j := i + 1; j < len(result); j++ {
if result[j].Priority < result[i].Priority {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
// GetDefaultProvider returns the default provider
func (c *SSOConfig) GetDefaultProvider() *SSOProvider {
if c == nil {
return nil
}
if c.DefaultProviderID != "" {
return c.GetProvider(c.DefaultProviderID)
}
enabled := c.GetEnabledProviders()
if len(enabled) > 0 {
return &enabled[0]
}
return nil
}
// HasEnabledProviders returns true if at least one provider is enabled
func (c *SSOConfig) HasEnabledProviders() bool {
return len(c.GetEnabledProviders()) > 0
}
// AddProvider adds a new provider to the configuration
func (c *SSOConfig) AddProvider(p SSOProvider) error {
if c == nil {
return fmt.Errorf("sso config is nil")
}
if p.ID == "" {
return fmt.Errorf("provider id is required")
}
if c.GetProvider(p.ID) != nil {
return fmt.Errorf("provider with id %q already exists", p.ID)
}
c.Providers = append(c.Providers, p)
return nil
}
// UpdateProvider updates an existing provider
func (c *SSOConfig) UpdateProvider(p SSOProvider) error {
if c == nil {
return fmt.Errorf("sso config is nil")
}
for i := range c.Providers {
if c.Providers[i].ID == p.ID {
c.Providers[i] = p
return nil
}
}
return fmt.Errorf("provider with id %q not found", p.ID)
}
// RemoveProvider removes a provider by ID
func (c *SSOConfig) RemoveProvider(id string) error {
if c == nil {
return fmt.Errorf("sso config is nil")
}
for i := range c.Providers {
if c.Providers[i].ID == id {
c.Providers = append(c.Providers[:i], c.Providers[i+1:]...)
// Clear default if we removed it
if c.DefaultProviderID == id {
c.DefaultProviderID = ""
}
return nil
}
}
return fmt.Errorf("provider with id %q not found", id)
}
// Validate checks the SSO configuration for errors
func (c *SSOConfig) Validate() error {
if c == nil {
return nil
}
seenIDs := make(map[string]bool)
for i, p := range c.Providers {
if p.ID == "" {
return fmt.Errorf("provider %d: id is required", i)
}
if seenIDs[p.ID] {
return fmt.Errorf("provider %d: duplicate id %q", i, p.ID)
}
seenIDs[p.ID] = true
if p.Name == "" {
return fmt.Errorf("provider %q: name is required", p.ID)
}
if p.Type != SSOProviderTypeOIDC && p.Type != SSOProviderTypeSAML {
return fmt.Errorf("provider %q: invalid type %q (must be 'oidc' or 'saml')", p.ID, p.Type)
}
if p.Enabled {
if err := validateProvider(&p); err != nil {
return fmt.Errorf("provider %q: %w", p.ID, err)
}
}
}
if c.DefaultProviderID != "" && c.GetProvider(c.DefaultProviderID) == nil {
return fmt.Errorf("default provider %q not found", c.DefaultProviderID)
}
return nil
}
func validateProvider(p *SSOProvider) error {
switch p.Type {
case SSOProviderTypeOIDC:
if p.OIDC == nil {
return fmt.Errorf("oidc configuration is required for oidc provider")
}
return validateOIDCProvider(p.OIDC)
case SSOProviderTypeSAML:
if p.SAML == nil {
return fmt.Errorf("saml configuration is required for saml provider")
}
return validateSAMLProvider(p.SAML)
}
return nil
}
func validateOIDCProvider(cfg *OIDCProviderConfig) error {
if strings.TrimSpace(cfg.IssuerURL) == "" {
return fmt.Errorf("issuer url is required")
}
if _, err := url.ParseRequestURI(cfg.IssuerURL); err != nil {
return fmt.Errorf("invalid issuer url: %w", err)
}
if strings.TrimSpace(cfg.ClientID) == "" {
return fmt.Errorf("client id is required")
}
return nil
}
func validateSAMLProvider(cfg *SAMLProviderConfig) error {
// Must have either metadata URL, metadata XML, or manual SSO URL
hasMetadata := strings.TrimSpace(cfg.IDPMetadataURL) != "" || strings.TrimSpace(cfg.IDPMetadataXML) != ""
hasManual := strings.TrimSpace(cfg.IDPSSOURL) != ""
if !hasMetadata && !hasManual {
return fmt.Errorf("either idp metadata (url or xml) or idp sso url is required")
}
if cfg.IDPMetadataURL != "" {
if _, err := url.ParseRequestURI(cfg.IDPMetadataURL); err != nil {
return fmt.Errorf("invalid idp metadata url: %w", err)
}
}
if cfg.IDPSSOURL != "" {
if _, err := url.ParseRequestURI(cfg.IDPSSOURL); err != nil {
return fmt.Errorf("invalid idp sso url: %w", err)
}
}
// If signing is enabled, certificate and key are required
if cfg.SignRequests {
hasCert := strings.TrimSpace(cfg.SPCertificate) != "" || strings.TrimSpace(cfg.SPCertFile) != ""
hasKey := strings.TrimSpace(cfg.SPPrivateKey) != "" || strings.TrimSpace(cfg.SPKeyFile) != ""
if !hasCert || !hasKey {
return fmt.Errorf("sp certificate and private key are required when signing requests")
}
}
return nil
}
// Clone creates a deep copy of the SSO configuration
func (c *SSOConfig) Clone() *SSOConfig {
if c == nil {
return nil
}
clone := &SSOConfig{
DefaultProviderID: c.DefaultProviderID,
AllowMultipleProviders: c.AllowMultipleProviders,
Providers: make([]SSOProvider, len(c.Providers)),
}
for i, p := range c.Providers {
clone.Providers[i] = cloneProvider(p)
}
return clone
}
func cloneProvider(p SSOProvider) SSOProvider {
clone := p
// Clone slices
if p.AllowedGroups != nil {
clone.AllowedGroups = append([]string{}, p.AllowedGroups...)
}
if p.AllowedDomains != nil {
clone.AllowedDomains = append([]string{}, p.AllowedDomains...)
}
if p.AllowedEmails != nil {
clone.AllowedEmails = append([]string{}, p.AllowedEmails...)
}
if p.GroupRoleMappings != nil {
clone.GroupRoleMappings = make(map[string]string, len(p.GroupRoleMappings))
for k, v := range p.GroupRoleMappings {
clone.GroupRoleMappings[k] = v
}
}
// Clone OIDC config
if p.OIDC != nil {
oidc := *p.OIDC
if p.OIDC.Scopes != nil {
oidc.Scopes = append([]string{}, p.OIDC.Scopes...)
}
if p.OIDC.EnvOverrides != nil {
oidc.EnvOverrides = make(map[string]bool, len(p.OIDC.EnvOverrides))
for k, v := range p.OIDC.EnvOverrides {
oidc.EnvOverrides[k] = v
}
}
clone.OIDC = &oidc
}
// Clone SAML config
if p.SAML != nil {
saml := *p.SAML
clone.SAML = &saml
}
return clone
}
// MigrateFromOIDCConfig converts legacy OIDCConfig to new SSOConfig format
func MigrateFromOIDCConfig(oidc *OIDCConfig) *SSOConfig {
if oidc == nil {
return NewSSOConfig()
}
sso := NewSSOConfig()
// Only migrate if OIDC was actually configured
if oidc.IssuerURL == "" || oidc.ClientID == "" {
return sso
}
provider := SSOProvider{
ID: "legacy-oidc",
Name: "Single Sign-On",
Type: SSOProviderTypeOIDC,
Enabled: oidc.Enabled,
AllowedGroups: oidc.AllowedGroups,
AllowedDomains: oidc.AllowedDomains,
AllowedEmails: oidc.AllowedEmails,
GroupsClaim: oidc.GroupsClaim,
GroupRoleMappings: oidc.GroupRoleMappings,
OIDC: &OIDCProviderConfig{
IssuerURL: oidc.IssuerURL,
ClientID: oidc.ClientID,
ClientSecret: oidc.ClientSecret,
RedirectURL: oidc.RedirectURL,
LogoutURL: oidc.LogoutURL,
Scopes: oidc.Scopes,
UsernameClaim: oidc.UsernameClaim,
EmailClaim: oidc.EmailClaim,
CABundle: oidc.CABundle,
ClientSecretSet: oidc.ClientSecret != "",
EnvOverrides: oidc.EnvOverrides,
},
}
sso.Providers = append(sso.Providers, provider)
sso.DefaultProviderID = provider.ID
return sso
}
// ToLegacyOIDCConfig converts the first OIDC provider back to legacy format for backwards compatibility
func (c *SSOConfig) ToLegacyOIDCConfig() *OIDCConfig {
if c == nil {
return NewOIDCConfig()
}
for _, p := range c.Providers {
if p.Type == SSOProviderTypeOIDC && p.OIDC != nil {
return &OIDCConfig{
Enabled: p.Enabled,
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,
GroupsClaim: p.GroupsClaim,
AllowedGroups: p.AllowedGroups,
AllowedDomains: p.AllowedDomains,
AllowedEmails: p.AllowedEmails,
GroupRoleMappings: p.GroupRoleMappings,
CABundle: p.OIDC.CABundle,
EnvOverrides: p.OIDC.EnvOverrides,
}
}
}
return NewOIDCConfig()
}