Files
netbird/idp/dex/config.go
2026-04-15 11:43:51 +02:00

641 lines
21 KiB
Go

package dex
import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
"github.com/dexidp/dex/server"
"github.com/dexidp/dex/storage"
"github.com/dexidp/dex/storage/sql"
"github.com/netbirdio/netbird/idp/dex/web"
)
// parseDuration parses a duration string (e.g., "6h", "24h", "168h").
func parseDuration(s string) (time.Duration, error) {
return time.ParseDuration(s)
}
// YAMLConfig represents the YAML configuration file format (mirrors dex's config format)
type YAMLConfig struct {
Issuer string `yaml:"issuer" json:"issuer"`
Storage Storage `yaml:"storage" json:"storage"`
Web Web `yaml:"web" json:"web"`
GRPC GRPC `yaml:"grpc" json:"grpc"`
OAuth2 OAuth2 `yaml:"oauth2" json:"oauth2"`
Expiry Expiry `yaml:"expiry" json:"expiry"`
Logger Logger `yaml:"logger" json:"logger"`
Frontend Frontend `yaml:"frontend" json:"frontend"`
// StaticConnectors are user defined connectors specified in the config file
StaticConnectors []Connector `yaml:"connectors" json:"connectors"`
// StaticClients cause the server to use this list of clients rather than
// querying the storage. Write operations, like creating a client, will fail.
StaticClients []storage.Client `yaml:"staticClients" json:"staticClients"`
// If enabled, the server will maintain a list of passwords which can be used
// to identify a user.
EnablePasswordDB bool `yaml:"enablePasswordDB" json:"enablePasswordDB"`
// StaticPasswords cause the server use this list of passwords rather than
// querying the storage.
StaticPasswords []Password `yaml:"staticPasswords" json:"staticPasswords"`
// Sessions holds authentication session configuration.
// Requires DEX_SESSIONS_ENABLED=true feature flag.
Sessions *Sessions `json:"sessions"`
// MFA holds multi-factor authentication configuration.
MFA MFAConfig `json:"mfa"`
}
type Sessions struct {
// CookieName is the name of the session cookie. Defaults to "dex_session".
CookieName string `yaml:"cookieName" json:"cookieName"`
// AbsoluteLifetime is the maximum session lifetime from creation. Defaults to "24h".
AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"`
// ValidIfNotUsedFor is the idle timeout. Defaults to "1h".
ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"`
// RememberMeCheckedByDefault controls the default state of the "remember me" checkbox.
RememberMeCheckedByDefault *bool `yaml:"rememberMeCheckedByDefault" json:"rememberMeCheckedByDefault"`
// CookieEncryptionKey is the AES key for encrypting session cookies.
// Must be 16, 24, or 32 bytes for AES-128, AES-192, or AES-256.
// If empty, cookies are not encrypted.
CookieEncryptionKey string `yaml:"cookieEncryptionKey" json:"cookieEncryptionKey"`
// SSOSharedWithDefault is the default SSO sharing policy for clients without explicit ssoSharedWith.
// "all" = share with all clients, "none" = share with no one (default: "none").
SSOSharedWithDefault string `yaml:"ssoSharedWithDefault" json:"ssoSharedWithDefault"`
}
type MFAConfig struct {
Authenticators []MFAAuthenticator `yaml:"authenticators" json:"authenticators"`
}
type MFAAuthenticator struct {
ID string `yaml:"id" json:"id"`
Type string `yaml:"type" json:"type"`
Config json.RawMessage `yaml:"config" json:"config"`
ConnectorTypes []string `yaml:"connectorTypes" json:"connectorTypes"`
}
type TOTPConfig struct {
Issuer string `yaml:"issuer" json:"issuer"`
}
// WebAuthnConfig holds configuration for a WebAuthn authenticator.
type WebAuthnConfig struct {
// RPDisplayName is the human-readable relying party name shown in the browser
// dialog during key registration and authentication (e.g., "My Company SSO").
RPDisplayName string `yaml:"rpDisplayName" json:"rpDisplayName"`
// RPID is the relying party identifier — must match the domain in the browser
// address bar. If empty, derived from the issuer URL hostname.
// Example: "auth.example.com"
RPID string `yaml:"rpID" json:"rpID"`
// RPOrigins is the list of allowed origins for WebAuthn ceremonies.
// If empty, derived from the issuer URL (scheme + host).
// Example: ["https://auth.example.com"]
RPOrigins []string `yaml:"rpOrigins" json:"rpOrigins"`
// AttestationPreference controls what attestation data the authenticator should provide:
// "none" — don't request attestation (simpler, more private)
// "indirect" — authenticator may anonymize attestation (default)
// "direct" — request full attestation (for enterprise key model verification)
AttestationPreference string `yaml:"attestationPreference" json:"attestationPreference"`
// UserVerification controls whether PIN or biometric verification is required:
// "required" — always require (PIN, fingerprint, etc.)
// "preferred" — request if the authenticator supports it (default)
// "discouraged" — skip verification, presence check only
UserVerification string `yaml:"userVerification" json:"userVerification"`
// AuthenticatorAttachment restricts which authenticator types are allowed:
// "platform" — built-in only (Touch ID, Windows Hello)
// "cross-platform" — external only (YubiKey, USB security keys)
// "" — any authenticator (default)
AuthenticatorAttachment string `yaml:"authenticatorAttachment" json:"authenticatorAttachment"`
// Timeout is the duration allowed for the browser WebAuthn ceremony
// (registration or login). Defaults to "60s".
Timeout string `yaml:"timeout" json:"timeout"`
}
// Web is the config format for the HTTP server.
type Web struct {
HTTP string `yaml:"http" json:"http"`
HTTPS string `yaml:"https" json:"https"`
AllowedOrigins []string `yaml:"allowedOrigins" json:"allowedOrigins"`
AllowedHeaders []string `yaml:"allowedHeaders" json:"allowedHeaders"`
}
// GRPC is the config for the gRPC API.
type GRPC struct {
Addr string `yaml:"addr" json:"addr"`
TLSCert string `yaml:"tlsCert" json:"tlsCert"`
TLSKey string `yaml:"tlsKey" json:"tlsKey"`
TLSClientCA string `yaml:"tlsClientCA" json:"tlsClientCA"`
}
// OAuth2 describes enabled OAuth2 extensions.
type OAuth2 struct {
SkipApprovalScreen bool `yaml:"skipApprovalScreen" json:"skipApprovalScreen"`
AlwaysShowLoginScreen bool `yaml:"alwaysShowLoginScreen" json:"alwaysShowLoginScreen"`
PasswordConnector string `yaml:"passwordConnector" json:"passwordConnector"`
ResponseTypes []string `yaml:"responseTypes" json:"responseTypes"`
GrantTypes []string `yaml:"grantTypes" json:"grantTypes"`
}
// Expiry holds configuration for the validity period of components.
type Expiry struct {
SigningKeys string `yaml:"signingKeys" json:"signingKeys"`
IDTokens string `yaml:"idTokens" json:"idTokens"`
AuthRequests string `yaml:"authRequests" json:"authRequests"`
DeviceRequests string `yaml:"deviceRequests" json:"deviceRequests"`
RefreshTokens RefreshTokensExpiry `yaml:"refreshTokens" json:"refreshTokens"`
}
// RefreshTokensExpiry holds configuration for refresh token expiry.
type RefreshTokensExpiry struct {
ReuseInterval string `yaml:"reuseInterval" json:"reuseInterval"`
ValidIfNotUsedFor string `yaml:"validIfNotUsedFor" json:"validIfNotUsedFor"`
AbsoluteLifetime string `yaml:"absoluteLifetime" json:"absoluteLifetime"`
DisableRotation bool `yaml:"disableRotation" json:"disableRotation"`
}
// Logger holds configuration required to customize logging.
type Logger struct {
Level string `yaml:"level" json:"level"`
Format string `yaml:"format" json:"format"`
}
// Frontend holds the server's frontend templates and assets config.
type Frontend struct {
Dir string `yaml:"dir" json:"dir"`
Theme string `yaml:"theme" json:"theme"`
Issuer string `yaml:"issuer" json:"issuer"`
LogoURL string `yaml:"logoURL" json:"logoURL"`
Extra map[string]string `yaml:"extra" json:"extra"`
}
// Storage holds app's storage configuration.
type Storage struct {
Type string `yaml:"type" json:"type"`
Config map[string]interface{} `yaml:"config" json:"config"`
}
type Password storage.Password
func (p *Password) UnmarshalYAML(node *yaml.Node) error {
var data struct {
Email string `yaml:"email"`
Username string `yaml:"username"`
UserID string `yaml:"userID"`
Hash string `yaml:"hash"`
HashFromEnv string `yaml:"hashFromEnv"`
}
if err := node.Decode(&data); err != nil {
return err
}
*p = Password(storage.Password{
Email: data.Email,
Username: data.Username,
UserID: data.UserID,
})
if len(data.Hash) == 0 && len(data.HashFromEnv) > 0 {
data.Hash = os.Getenv(data.HashFromEnv)
}
if len(data.Hash) == 0 {
return fmt.Errorf("no password hash provided for user %s", data.Email)
}
// If this value is a valid bcrypt, use it.
_, bcryptErr := bcrypt.Cost([]byte(data.Hash))
if bcryptErr == nil {
p.Hash = []byte(data.Hash)
return nil
}
// For backwards compatibility try to base64 decode this value.
hashBytes, err := base64.StdEncoding.DecodeString(data.Hash)
if err != nil {
return fmt.Errorf("malformed bcrypt hash: %v", bcryptErr)
}
if _, err := bcrypt.Cost(hashBytes); err != nil {
return fmt.Errorf("malformed bcrypt hash: %v", err)
}
p.Hash = hashBytes
return nil
}
// Connector is a connector configuration that can unmarshal YAML dynamically.
type Connector struct {
Type string `yaml:"type" json:"type"`
Name string `yaml:"name" json:"name"`
ID string `yaml:"id" json:"id"`
Config map[string]interface{} `yaml:"config" json:"config"`
}
// ToStorageConnector converts a Connector to storage.Connector type.
// It maps custom connector types (e.g., "zitadel", "entra") to Dex-native types
// and augments the config with OIDC defaults when needed.
func (c *Connector) ToStorageConnector() (storage.Connector, error) {
dexType, augmentedConfig := mapConnectorToDex(c.Type, c.Config)
data, err := json.Marshal(augmentedConfig)
if err != nil {
return storage.Connector{}, fmt.Errorf("failed to marshal connector config: %v", err)
}
return storage.Connector{
ID: c.ID,
Type: dexType,
Name: c.Name,
Config: data,
}, nil
}
// mapConnectorToDex maps custom connector types to Dex-native types and applies
// OIDC defaults. This ensures static connectors from config files or env vars
// are stored with types that Dex can open.
func mapConnectorToDex(connType string, config map[string]interface{}) (string, map[string]interface{}) {
switch connType {
case "oidc", "zitadel", "entra", "okta", "pocketid", "authentik", "keycloak":
return "oidc", applyOIDCDefaults(connType, config)
default:
return connType, config
}
}
// applyOIDCDefaults clones the config map, sets common OIDC defaults,
// and applies provider-specific overrides.
func applyOIDCDefaults(connType string, config map[string]interface{}) map[string]interface{} {
augmented := make(map[string]interface{}, len(config)+4)
for k, v := range config {
augmented[k] = v
}
setDefault(augmented, "scopes", []string{"openid", "profile", "email"})
setDefault(augmented, "insecureEnableGroups", true)
setDefault(augmented, "insecureSkipEmailVerified", true)
switch connType {
case "zitadel":
setDefault(augmented, "getUserInfo", true)
case "entra":
setDefault(augmented, "claimMapping", map[string]string{"email": "preferred_username"})
case "okta", "pocketid":
augmented["scopes"] = []string{"openid", "profile", "email", "groups"}
}
return augmented
}
// setDefault sets a key in the map only if it doesn't already exist.
func setDefault(m map[string]interface{}, key string, value interface{}) {
if _, ok := m[key]; !ok {
m[key] = value
}
}
// StorageConfig is a configuration that can create a storage.
type StorageConfig interface {
Open(logger *slog.Logger) (storage.Storage, error)
}
// OpenStorage opens a storage based on the config
func (s *Storage) OpenStorage(logger *slog.Logger) (storage.Storage, error) {
switch s.Type {
case "sqlite3":
file, _ := s.Config["file"].(string)
if file == "" {
return nil, fmt.Errorf("sqlite3 storage requires 'file' config")
}
return (&sql.SQLite3{File: file}).Open(logger)
case "postgres":
dsn, _ := s.Config["dsn"].(string)
if dsn == "" {
return nil, fmt.Errorf("postgres storage requires 'dsn' config")
}
pg, err := parsePostgresDSN(dsn)
if err != nil {
return nil, fmt.Errorf("invalid postgres DSN: %w", err)
}
return pg.Open(logger)
default:
return nil, fmt.Errorf("unsupported storage type: %s", s.Type)
}
}
// parsePostgresDSN parses a DSN into a sql.Postgres config.
// It accepts both URI format (postgres://user:pass@host:port/dbname?sslmode=disable)
// and libpq key=value format (host=localhost port=5432 dbname=mydb), including quoted values.
func parsePostgresDSN(dsn string) (*sql.Postgres, error) {
var params map[string]string
var err error
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
params, err = parsePostgresURI(dsn)
} else {
params, err = parsePostgresKeyValue(dsn)
}
if err != nil {
return nil, err
}
host := params["host"]
if host == "" {
host = "localhost"
}
var port uint16 = 5432
if p, ok := params["port"]; ok && p != "" {
v, err := strconv.ParseUint(p, 10, 16)
if err != nil {
return nil, fmt.Errorf("invalid port %q: %w", p, err)
}
if v == 0 {
return nil, fmt.Errorf("invalid port %q: must be non-zero", p)
}
port = uint16(v)
}
dbname := params["dbname"]
if dbname == "" {
return nil, fmt.Errorf("dbname is required in DSN")
}
pg := &sql.Postgres{
NetworkDB: sql.NetworkDB{
Host: host,
Port: port,
Database: dbname,
User: params["user"],
Password: params["password"],
},
}
if sslMode := params["sslmode"]; sslMode != "" {
switch sslMode {
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
pg.SSL.Mode = sslMode
default:
return nil, fmt.Errorf("unsupported sslmode %q: valid values are disable, allow, prefer, require, verify-ca, verify-full", sslMode)
}
}
return pg, nil
}
// parsePostgresURI parses a postgres:// or postgresql:// URI into parameter key-value pairs.
func parsePostgresURI(dsn string) (map[string]string, error) {
u, err := url.Parse(dsn)
if err != nil {
return nil, fmt.Errorf("invalid postgres URI: %w", err)
}
params := make(map[string]string)
if u.User != nil {
params["user"] = u.User.Username()
if p, ok := u.User.Password(); ok {
params["password"] = p
}
}
if u.Hostname() != "" {
params["host"] = u.Hostname()
}
if u.Port() != "" {
params["port"] = u.Port()
}
dbname := strings.TrimPrefix(u.Path, "/")
if dbname != "" {
params["dbname"] = dbname
}
for k, v := range u.Query() {
if len(v) > 0 {
params[k] = v[0]
}
}
return params, nil
}
// parsePostgresKeyValue parses a libpq key=value DSN string, handling single-quoted values
// (e.g., password='my pass' host=localhost).
func parsePostgresKeyValue(dsn string) (map[string]string, error) {
params := make(map[string]string)
s := strings.TrimSpace(dsn)
for s != "" {
eqIdx := strings.IndexByte(s, '=')
if eqIdx < 0 {
break
}
key := strings.TrimSpace(s[:eqIdx])
value, rest, err := parseDSNValue(s[eqIdx+1:])
if err != nil {
return nil, fmt.Errorf("%w for key %q", err, key)
}
params[key] = value
s = strings.TrimSpace(rest)
}
return params, nil
}
// parseDSNValue parses the next value from a libpq key=value string positioned after the '='.
// It returns the parsed value and the remaining unparsed string.
func parseDSNValue(s string) (value, rest string, err error) {
if len(s) > 0 && s[0] == '\'' {
return parseQuotedDSNValue(s[1:])
}
// Unquoted value: read until whitespace.
idx := strings.IndexAny(s, " \t\n")
if idx < 0 {
return s, "", nil
}
return s[:idx], s[idx:], nil
}
// parseQuotedDSNValue parses a single-quoted value starting after the opening quote.
// Libpq uses ” to represent a literal single quote inside quoted values.
func parseQuotedDSNValue(s string) (value, rest string, err error) {
var buf strings.Builder
for len(s) > 0 {
if s[0] == '\'' {
if len(s) > 1 && s[1] == '\'' {
buf.WriteByte('\'')
s = s[2:]
continue
}
return buf.String(), s[1:], nil
}
buf.WriteByte(s[0])
s = s[1:]
}
return "", "", fmt.Errorf("unterminated quoted value")
}
// Validate validates the configuration
func (c *YAMLConfig) Validate() error {
if c.Issuer == "" {
return fmt.Errorf("no issuer specified in config file")
}
if c.Storage.Type == "" {
return fmt.Errorf("no storage type specified in config file")
}
if c.Web.HTTP == "" && c.Web.HTTPS == "" {
return fmt.Errorf("must supply a HTTP/HTTPS address to listen on")
}
if !c.EnablePasswordDB && len(c.StaticPasswords) != 0 {
return fmt.Errorf("cannot specify static passwords without enabling password db")
}
return nil
}
func buildMFAProviders(authenticators []MFAAuthenticator, issuerURL string, logger *slog.Logger) map[string]server.MFAProvider {
if len(authenticators) == 0 {
return nil
}
providers := make(map[string]server.MFAProvider, len(authenticators))
for _, auth := range authenticators {
switch auth.Type {
case "TOTP":
var cfg TOTPConfig
if err := json.Unmarshal(auth.Config, &cfg); err != nil {
logger.Error("failed to parse TOTP config", "id", auth.ID, "err", err)
continue
}
providers[auth.ID] = server.NewTOTPProvider(cfg.Issuer, auth.ConnectorTypes)
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
case "WebAuthn":
var cfg WebAuthnConfig
if err := json.Unmarshal(auth.Config, &cfg); err != nil {
logger.Error("failed to parse WebAuthn config", "id", auth.ID, "err", err)
continue
}
provider, err := server.NewWebAuthnProvider(cfg.RPDisplayName, cfg.RPID, cfg.RPOrigins,
cfg.AttestationPreference, cfg.Timeout, issuerURL, auth.ConnectorTypes)
if err != nil {
logger.Error("failed to create WebAuthn provider", "id", auth.ID, "err", err)
continue
}
providers[auth.ID] = provider
logger.Info("MFA authenticator configured", "id", auth.ID, "type", auth.Type)
default:
logger.Error("unknown MFA authenticator type, skipping", "id", auth.ID, "type", auth.Type)
}
}
return providers
}
func buildSessionsConfig(sessions *Sessions) *server.SessionConfig {
if sessions == nil {
return nil
}
absoluteLifetime, _ := parseDuration(sessions.AbsoluteLifetime)
validIfNotUsedFor, _ := parseDuration(sessions.ValidIfNotUsedFor)
return &server.SessionConfig{
CookieEncryptionKey: []byte(sessions.CookieEncryptionKey),
CookieName: sessions.CookieName,
AbsoluteLifetime: absoluteLifetime,
ValidIfNotUsedFor: validIfNotUsedFor,
RememberMeCheckedByDefault: *sessions.RememberMeCheckedByDefault,
SSOSharedWithDefault: sessions.SSOSharedWithDefault,
}
}
// ToServerConfig converts YAMLConfig to dex server.Config
func (c *YAMLConfig) ToServerConfig(stor storage.Storage, logger *slog.Logger) server.Config {
cfg := server.Config{
Issuer: c.Issuer,
Storage: stor,
Logger: logger,
SkipApprovalScreen: c.OAuth2.SkipApprovalScreen,
AllowedOrigins: c.Web.AllowedOrigins,
AllowedHeaders: c.Web.AllowedHeaders,
Web: server.WebConfig{
Issuer: c.Frontend.Issuer,
LogoURL: c.Frontend.LogoURL,
Theme: c.Frontend.Theme,
Dir: c.Frontend.Dir,
Extra: c.Frontend.Extra,
},
SessionConfig: buildSessionsConfig(c.Sessions),
MFAProviders: buildMFAProviders(c.MFA.Authenticators, c.Issuer, logger),
}
// Use embedded NetBird-styled templates if no custom dir specified
if c.Frontend.Dir == "" {
cfg.Web.WebFS = web.FS()
}
if len(c.OAuth2.ResponseTypes) > 0 {
cfg.SupportedResponseTypes = c.OAuth2.ResponseTypes
}
// Apply expiry settings
if c.Expiry.IDTokens != "" {
if d, err := parseDuration(c.Expiry.IDTokens); err == nil {
cfg.IDTokensValidFor = d
}
}
if c.Expiry.AuthRequests != "" {
if d, err := parseDuration(c.Expiry.AuthRequests); err == nil {
cfg.AuthRequestsValidFor = d
}
}
if c.Expiry.DeviceRequests != "" {
if d, err := parseDuration(c.Expiry.DeviceRequests); err == nil {
cfg.DeviceRequestsValidFor = d
}
}
return cfg
}
// GetRefreshTokenPolicy creates a RefreshTokenPolicy from the expiry config.
// This should be called after ToServerConfig and the policy set on the config.
func (c *YAMLConfig) GetRefreshTokenPolicy(logger *slog.Logger) (*server.RefreshTokenPolicy, error) {
return server.NewRefreshTokenPolicy(
logger,
c.Expiry.RefreshTokens.DisableRotation,
c.Expiry.RefreshTokens.ValidIfNotUsedFor,
c.Expiry.RefreshTokens.AbsoluteLifetime,
c.Expiry.RefreshTokens.ReuseInterval,
)
}
// LoadConfig loads configuration from a YAML file
func LoadConfig(path string) (*YAMLConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg YAMLConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return &cfg, nil
}