// Package config handles loading and validating the KeyCape server configuration // from a YAML file. The config path is resolved from the --config flag or the // KEYCAPE_CONFIG environment variable. package config import ( "fmt" "os" "time" "gopkg.in/yaml.v3" "keycape/internal/adapters/authelia" "keycape/internal/adapters/lldap" "keycape/internal/adapters/privacyidea" "keycape/internal/domain" ) // Config is the top-level server configuration. type Config struct { Issuer string `yaml:"issuer"` Port int `yaml:"port"` TokenLifetime string `yaml:"tokenLifetime"` PrivateKeyPEM string `yaml:"privateKeyPem"` LLDAP lldap.Config `yaml:"lldap"` Authelia authelia.Config `yaml:"authelia"` PrivacyIDEA privacyidea.Config `yaml:"privacyidea"` Clients []ClientConfig `yaml:"clients"` Environment string `yaml:"environment"` TenantEngine TenantEngineConfig `yaml:"tenantEngine,omitempty"` } // TenantEngineConfig configures the optional tenant_roles cache claim. // // Opt-in by design: an empty baseURL leaves the claim off entirely, which is // what the stock server does. tenant_roles is a cache callers must not trust for // privileged decisions, and the adapter fails open, so enabling it is a // performance choice rather than a security one (KEY-WP-0024). type TenantEngineConfig struct { // BaseURL is tenant-engine's cache-read endpoint. Empty disables the claim. BaseURL string `yaml:"baseURL,omitempty"` // Timeout bounds the lookup, which sits on the synchronous token-issuance // path. Empty uses the adapter's own short default. Timeout string `yaml:"timeout,omitempty"` } // ClientConfig is a static OIDC client registration. type ClientConfig struct { ClientID string `yaml:"clientId"` Audience string `yaml:"audience,omitempty"` DisplayName string `yaml:"displayName"` RedirectURIs []string `yaml:"redirectUris"` AllowedScopes []string `yaml:"allowedScopes"` GrantTypes []string `yaml:"grantTypes"` ClientType string `yaml:"clientType"` // "confidential" | "public" SecretRef string `yaml:"secretRef,omitempty"` ServiceSubject string `yaml:"serviceSubject,omitempty"` Tenant string `yaml:"tenant,omitempty"` Roles []string `yaml:"roles,omitempty"` TokenLifetime string `yaml:"tokenLifetime,omitempty"` MFARequired *bool `yaml:"mfaRequired,omitempty"` RegistrationURL string `yaml:"registrationUrl,omitempty"` EnrollmentURL string `yaml:"enrollmentUrl,omitempty"` } // Load reads and parses the YAML config file at path. // If path is empty, it falls back to the KEYCAPE_CONFIG environment variable. // Returns an error if the file cannot be read or parsed. func Load(path string) (*Config, error) { if path == "" { path = os.Getenv("KEYCAPE_CONFIG") } if path == "" { return nil, fmt.Errorf("config: no config path specified (use --config or KEYCAPE_CONFIG)") } data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("config: read %q: %w", path, err) } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("config: parse %q: %w", path, err) } return &cfg, nil } // Registrations converts the configured clients to domain registrations without // resolving any secret. Migration and inspection tooling needs the registration // contract — grants, audience, tenant, roles, lifetime, MFA and handoff policy — // but must never load secret material, so ClientSecret is deliberately left // empty here. The server has its own conversion that does resolve secrets. // // An unparseable tokenLifetime is an error rather than a silent zero: a // migration that quietly drops a per-client lifetime is the class of defect this // exists to avoid. func (c *Config) Registrations() ([]domain.Client, error) { clients := make([]domain.Client, 0, len(c.Clients)) for _, cc := range c.Clients { var lifetime time.Duration if cc.TokenLifetime != "" { parsed, err := time.ParseDuration(cc.TokenLifetime) if err != nil { return nil, fmt.Errorf("config: client %q tokenLifetime is invalid: %w", cc.ClientID, err) } lifetime = parsed } clients = append(clients, domain.Client{ ClientID: cc.ClientID, DisplayName: cc.DisplayName, RedirectURIs: cc.RedirectURIs, AllowedScopes: cc.AllowedScopes, GrantTypes: cc.GrantTypes, ClientType: cc.ClientType, SecretRef: cc.SecretRef, Audience: cc.Audience, ServiceSubject: cc.ServiceSubject, Tenant: cc.Tenant, Roles: cc.Roles, TokenLifetime: lifetime, MFARequired: cc.MFARequired, RegistrationURL: cc.RegistrationURL, EnrollmentURL: cc.EnrollmentURL, }) } return clients, nil }