mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
1c8a9346ef
Refs #1533
368 lines
11 KiB
Go
368 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// defaultOIDCScopes defines the scopes we request when none are provided.
|
|
var defaultOIDCScopes = []string{"openid", "profile", "email"}
|
|
|
|
// DefaultOIDCCallbackPath is the path we expose for the OIDC redirect handler.
|
|
const DefaultOIDCCallbackPath = "/api/oidc/callback"
|
|
|
|
// LegacyOIDCProviderID is the synthetic SSO provider ID used for v5 OIDC
|
|
// configuration migrated from oidc.enc or OIDC_* environment variables.
|
|
const LegacyOIDCProviderID = "legacy-oidc"
|
|
|
|
// OIDCConfig captures configuration required to integrate with an OpenID Connect provider.
|
|
type OIDCConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
IssuerURL string `json:"issuerUrl"`
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret,omitempty"`
|
|
RedirectURL string `json:"redirectUrl"`
|
|
LogoutURL string `json:"logoutUrl,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
UsernameClaim string `json:"usernameClaim,omitempty"`
|
|
EmailClaim string `json:"emailClaim,omitempty"`
|
|
GroupsClaim string `json:"groupsClaim,omitempty"`
|
|
AllowedGroups []string `json:"allowedGroups,omitempty"`
|
|
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
|
AllowedEmails []string `json:"allowedEmails,omitempty"`
|
|
GroupRoleMappings map[string]string `json:"groupRoleMappings,omitempty"`
|
|
CABundle string `json:"caBundle,omitempty"`
|
|
EnvOverrides map[string]bool `json:"-"`
|
|
}
|
|
|
|
// NewOIDCConfig returns an instance populated with sensible defaults.
|
|
func NewOIDCConfig() *OIDCConfig {
|
|
cfg := &OIDCConfig{}
|
|
cfg.ApplyDefaults("")
|
|
return cfg
|
|
}
|
|
|
|
// Clone returns a deep copy of the configuration.
|
|
func (c *OIDCConfig) Clone() *OIDCConfig {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
clone := *c
|
|
clone.Scopes = append([]string{}, c.Scopes...)
|
|
clone.AllowedGroups = append([]string{}, c.AllowedGroups...)
|
|
clone.AllowedDomains = append([]string{}, c.AllowedDomains...)
|
|
clone.AllowedEmails = append([]string{}, c.AllowedEmails...)
|
|
clone.CABundle = c.CABundle
|
|
if c.GroupRoleMappings != nil {
|
|
clone.GroupRoleMappings = make(map[string]string, len(c.GroupRoleMappings))
|
|
for k, v := range c.GroupRoleMappings {
|
|
clone.GroupRoleMappings[k] = v
|
|
}
|
|
}
|
|
if c.EnvOverrides != nil {
|
|
clone.EnvOverrides = make(map[string]bool, len(c.EnvOverrides))
|
|
for k, v := range c.EnvOverrides {
|
|
clone.EnvOverrides[k] = v
|
|
}
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
// ApplyDefaults normalises the configuration and injects default values where needed.
|
|
func (c *OIDCConfig) ApplyDefaults(publicURL string) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
|
|
c.CABundle = strings.TrimSpace(c.CABundle)
|
|
|
|
if len(c.Scopes) == 0 {
|
|
c.Scopes = append([]string{}, defaultOIDCScopes...)
|
|
} else {
|
|
c.Scopes = normaliseList(c.Scopes)
|
|
}
|
|
|
|
if c.UsernameClaim = strings.TrimSpace(c.UsernameClaim); c.UsernameClaim == "" {
|
|
c.UsernameClaim = "preferred_username"
|
|
}
|
|
if c.EmailClaim = strings.TrimSpace(c.EmailClaim); c.EmailClaim == "" {
|
|
c.EmailClaim = "email"
|
|
}
|
|
c.GroupsClaim = strings.TrimSpace(c.GroupsClaim)
|
|
|
|
c.AllowedGroups = normaliseList(c.AllowedGroups)
|
|
c.AllowedDomains = normaliseList(c.AllowedDomains)
|
|
c.AllowedEmails = normaliseList(c.AllowedEmails)
|
|
|
|
if c.GroupRoleMappings == nil {
|
|
c.GroupRoleMappings = make(map[string]string)
|
|
}
|
|
|
|
if c.EnvOverrides == nil {
|
|
c.EnvOverrides = make(map[string]bool)
|
|
}
|
|
|
|
if strings.TrimSpace(c.RedirectURL) == "" {
|
|
c.RedirectURL = DefaultRedirectURL(publicURL)
|
|
}
|
|
}
|
|
|
|
// DefaultRedirectURL builds a redirect URL using the provided public base URL.
|
|
func DefaultRedirectURL(publicURL string) string {
|
|
if strings.TrimSpace(publicURL) == "" {
|
|
return ""
|
|
}
|
|
base := strings.TrimRight(publicURL, "/")
|
|
return base + DefaultOIDCCallbackPath
|
|
}
|
|
|
|
// LegacyOIDCConfigToSSOConfig converts the v5 single-provider OIDC
|
|
// configuration into the v6 multi-provider SSO model.
|
|
func LegacyOIDCConfigToSSOConfig(legacy *OIDCConfig, publicURL string) (*SSOConfig, bool) {
|
|
provider, ok := LegacyOIDCProviderFromConfig(legacy, publicURL, false)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
cfg := NewSSOConfig()
|
|
cfg.Providers = append(cfg.Providers, *provider)
|
|
cfg.DefaultProviderID = provider.ID
|
|
return cfg, true
|
|
}
|
|
|
|
// LegacyOIDCProviderFromConfig converts a v5 OIDC configuration into a single
|
|
// v6 SSO OIDC provider.
|
|
func LegacyOIDCProviderFromConfig(legacy *OIDCConfig, publicURL string, runtimeManaged bool) (*SSOProvider, bool) {
|
|
if legacy == nil || !legacy.Enabled {
|
|
return nil, false
|
|
}
|
|
|
|
cfg := legacy.Clone()
|
|
cfg.ApplyDefaults(publicURL)
|
|
if strings.TrimSpace(cfg.IssuerURL) == "" || strings.TrimSpace(cfg.ClientID) == "" {
|
|
return nil, false
|
|
}
|
|
|
|
provider := &SSOProvider{
|
|
ID: LegacyOIDCProviderID,
|
|
Name: "Single Sign-On",
|
|
Type: SSOProviderTypeOIDC,
|
|
Enabled: true,
|
|
DisplayName: "Single Sign-On",
|
|
AllowedGroups: append([]string{}, cfg.AllowedGroups...),
|
|
AllowedDomains: append([]string{}, cfg.AllowedDomains...),
|
|
AllowedEmails: append([]string{}, cfg.AllowedEmails...),
|
|
GroupsClaim: cfg.GroupsClaim,
|
|
GroupRoleMappings: cloneStringMap(cfg.GroupRoleMappings),
|
|
RuntimeManaged: runtimeManaged,
|
|
OIDC: &OIDCProviderConfig{
|
|
IssuerURL: strings.TrimSpace(cfg.IssuerURL),
|
|
ClientID: strings.TrimSpace(cfg.ClientID),
|
|
ClientSecret: cfg.ClientSecret,
|
|
RedirectURL: cfg.RedirectURL,
|
|
LogoutURL: cfg.LogoutURL,
|
|
Scopes: append([]string{}, cfg.Scopes...),
|
|
UsernameClaim: cfg.UsernameClaim,
|
|
EmailClaim: cfg.EmailClaim,
|
|
CABundle: cfg.CABundle,
|
|
ClientSecretSet: cfg.ClientSecret != "",
|
|
EnvOverrides: cloneBoolMap(cfg.EnvOverrides),
|
|
},
|
|
}
|
|
return provider, true
|
|
}
|
|
|
|
// LegacyOIDCEnvProvider converts OIDC_* environment variables into a
|
|
// runtime-managed SSO provider.
|
|
func LegacyOIDCEnvProvider(publicURL string) (*SSOProvider, bool) {
|
|
legacy, ok := LegacyOIDCConfigFromEnv(publicURL)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return LegacyOIDCProviderFromConfig(legacy, publicURL, true)
|
|
}
|
|
|
|
// LegacyOIDCConfigFromEnv reads the v5 OIDC_* environment contract.
|
|
func LegacyOIDCConfigFromEnv(publicURL string) (*OIDCConfig, bool) {
|
|
enabledRaw, enabledSet := envValue("OIDC_ENABLED")
|
|
if !enabledSet || !truthyEnv(enabledRaw) {
|
|
return nil, false
|
|
}
|
|
|
|
cfg := &OIDCConfig{
|
|
Enabled: true,
|
|
IssuerURL: envTrim("OIDC_ISSUER_URL"),
|
|
ClientID: envTrim("OIDC_CLIENT_ID"),
|
|
ClientSecret: envTrim("OIDC_CLIENT_SECRET"),
|
|
RedirectURL: envTrim("OIDC_REDIRECT_URL"),
|
|
LogoutURL: envTrim("OIDC_LOGOUT_URL"),
|
|
UsernameClaim: envTrim("OIDC_USERNAME_CLAIM"),
|
|
EmailClaim: envTrim("OIDC_EMAIL_CLAIM"),
|
|
GroupsClaim: envTrim("OIDC_GROUPS_CLAIM"),
|
|
CABundle: envTrim("OIDC_CA_BUNDLE"),
|
|
Scopes: splitOIDCEnvList(envTrim("OIDC_SCOPES")),
|
|
AllowedGroups: splitOIDCEnvList(envTrim("OIDC_ALLOWED_GROUPS")),
|
|
AllowedDomains: splitOIDCEnvList(envTrim("OIDC_ALLOWED_DOMAINS")),
|
|
AllowedEmails: splitOIDCEnvList(envTrim("OIDC_ALLOWED_EMAILS")),
|
|
GroupRoleMappings: parseOIDCGroupRoleMappings(envTrim("OIDC_GROUP_ROLE_MAPPINGS")),
|
|
EnvOverrides: map[string]bool{"enabled": true},
|
|
}
|
|
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_ISSUER_URL", "issuerUrl")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_CLIENT_ID", "clientId")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_CLIENT_SECRET", "clientSecret")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_REDIRECT_URL", "redirectUrl")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_LOGOUT_URL", "logoutUrl")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_SCOPES", "scopes")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_USERNAME_CLAIM", "usernameClaim")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_EMAIL_CLAIM", "emailClaim")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_GROUPS_CLAIM", "groupsClaim")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_ALLOWED_GROUPS", "allowedGroups")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_ALLOWED_DOMAINS", "allowedDomains")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_ALLOWED_EMAILS", "allowedEmails")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_GROUP_ROLE_MAPPINGS", "groupRoleMappings")
|
|
markOIDCEnvOverride(cfg.EnvOverrides, "OIDC_CA_BUNDLE", "caBundle")
|
|
|
|
cfg.ApplyDefaults(publicURL)
|
|
return cfg, true
|
|
}
|
|
|
|
// Validate performs sanity checks and returns the first error encountered.
|
|
func (c *OIDCConfig) Validate() error {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
|
|
if !c.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if strings.TrimSpace(c.IssuerURL) == "" {
|
|
return fmt.Errorf("oidc issuer url is required when OIDC is enabled")
|
|
}
|
|
if _, err := url.ParseRequestURI(c.IssuerURL); err != nil {
|
|
return fmt.Errorf("invalid oidc issuer url: %w", err)
|
|
}
|
|
|
|
if strings.TrimSpace(c.ClientID) == "" {
|
|
return fmt.Errorf("oidc client id is required when OIDC is enabled")
|
|
}
|
|
|
|
if strings.TrimSpace(c.RedirectURL) == "" {
|
|
return fmt.Errorf("oidc redirect url is required when OIDC is enabled (set PUBLIC_URL environment variable or provide redirect URL manually)")
|
|
}
|
|
if _, err := url.ParseRequestURI(c.RedirectURL); err != nil {
|
|
return fmt.Errorf("invalid oidc redirect url: %w", err)
|
|
}
|
|
|
|
if len(c.Scopes) == 0 {
|
|
return fmt.Errorf("oidc scopes must contain at least one entry")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// normaliseList trims entries, removes blanks, and de-duplicates while preserving order.
|
|
func normaliseList(values []string) []string {
|
|
seen := make(map[string]struct{})
|
|
result := make([]string, 0, len(values))
|
|
for _, raw := range values {
|
|
value := strings.TrimSpace(raw)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(value)
|
|
if _, exists := seen[lower]; exists {
|
|
continue
|
|
}
|
|
seen[lower] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func envTrim(name string) string {
|
|
return strings.TrimSpace(os.Getenv(name))
|
|
}
|
|
|
|
func envValue(name string) (string, bool) {
|
|
value, ok := os.LookupEnv(name)
|
|
return strings.TrimSpace(value), ok
|
|
}
|
|
|
|
func truthyEnv(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "1", "true", "yes", "y", "on":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func splitOIDCEnvList(value string) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return []string{}
|
|
}
|
|
parts := strings.FieldsFunc(value, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
|
})
|
|
return normaliseList(parts)
|
|
}
|
|
|
|
func parseOIDCGroupRoleMappings(value string) map[string]string {
|
|
parts := splitOIDCEnvList(value)
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
mappings := make(map[string]string, len(parts))
|
|
for _, part := range parts {
|
|
group, role, ok := strings.Cut(part, "=")
|
|
if !ok {
|
|
continue
|
|
}
|
|
group = strings.TrimSpace(group)
|
|
role = strings.TrimSpace(role)
|
|
if group == "" || role == "" {
|
|
continue
|
|
}
|
|
mappings[group] = role
|
|
}
|
|
if len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
return mappings
|
|
}
|
|
|
|
func markOIDCEnvOverride(overrides map[string]bool, envName string, field string) {
|
|
if overrides == nil {
|
|
return
|
|
}
|
|
if _, ok := os.LookupEnv(envName); ok {
|
|
overrides[field] = true
|
|
}
|
|
}
|
|
|
|
func cloneStringMap(values map[string]string) map[string]string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
clone := make(map[string]string, len(values))
|
|
for key, value := range values {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func cloneBoolMap(values map[string]bool) map[string]bool {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
clone := make(map[string]bool, len(values))
|
|
for key, value := range values {
|
|
clone[key] = value
|
|
}
|
|
return clone
|
|
}
|