key-cape/src/internal/config/config.go
tegwick ac8ed65203
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Support opt-in MFA per browser client with authoritative enrollment checks
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
2026-09-13 00:27:28 +02:00

140 lines
5.2 KiB
Go

// 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 {
AccountPortalURL string `yaml:"accountPortalURL,omitempty"`
BrowserLogoutURL string `yaml:"browserLogoutURL,omitempty"`
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"`
MFAOptional bool `yaml:"mfaOptional,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)
}
if value := os.Getenv("KEYCAPE_ACCOUNT_PORTAL_URL"); value != "" {
cfg.AccountPortalURL = value
}
if value := os.Getenv("KEYCAPE_BROWSER_LOGOUT_URL"); value != "" {
cfg.BrowserLogoutURL = value
}
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,
MFAOptional: cc.MFAOptional,
RegistrationURL: cc.RegistrationURL,
EnrollmentURL: cc.EnrollmentURL,
})
}
return clients, nil
}