mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 11:30:15 +00:00
- 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
461 lines
15 KiB
Go
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()
|
|
}
|