Files
pulse/internal/config/oidc.go
T
2026-07-07 09:54:14 +01:00

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
}