All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 40s
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
140 lines
5.2 KiB
Go
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
|
|
}
|