package config import ( "fmt" "net/url" "strings" "time" ) // ValidateConfig validates a loaded Config and returns a list of human-readable // error messages. An empty slice means the config is valid. // Called at startup — the server must exit 1 if any errors are returned. func ValidateConfig(cfg *Config) []string { var errs []string // Issuer must be a valid URL with an http(s) scheme. if cfg.Issuer == "" { errs = append(errs, "issuer: must not be empty") } else { u, err := url.Parse(cfg.Issuer) if err != nil || u.Scheme == "" || u.Host == "" { errs = append(errs, fmt.Sprintf("issuer: %q is not a valid URL (must include scheme and host)", cfg.Issuer)) } else if u.Scheme != "http" && u.Scheme != "https" { errs = append(errs, fmt.Sprintf("issuer: scheme must be http or https, got %q", u.Scheme)) } } // Port must be in the valid TCP range. if cfg.Port < 1 || cfg.Port > 65535 { errs = append(errs, fmt.Sprintf("port: must be between 1 and 65535, got %d", cfg.Port)) } // tenant_roles is opt-in: an empty baseURL disables it. A configured one // must be usable, since a misconfigured cache source on the token path is // worse than no cache source (KEY-WP-0024). if cfg.TenantEngine.BaseURL != "" { u, err := url.Parse(cfg.TenantEngine.BaseURL) if err != nil || u.Scheme == "" || u.Host == "" { errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: %q is not a valid URL (must include scheme and host)", cfg.TenantEngine.BaseURL)) } else if u.Scheme != "http" && u.Scheme != "https" { errs = append(errs, fmt.Sprintf("tenantEngine.baseURL: scheme must be http or https, got %q", u.Scheme)) } } if cfg.TenantEngine.Timeout != "" { if cfg.TenantEngine.BaseURL == "" { errs = append(errs, "tenantEngine.timeout: set without a baseURL, so tenant_roles stays disabled") } else if timeout, err := time.ParseDuration(cfg.TenantEngine.Timeout); err != nil { errs = append(errs, "tenantEngine.timeout: must be a valid duration") } else if timeout <= 0 || timeout > 10*time.Second { errs = append(errs, "tenantEngine.timeout: must be greater than 0 and at most 10s; it sits on the token-issuance path") } } // At least one client must be registered. if len(cfg.Clients) == 0 { errs = append(errs, "clients: at least one client must be defined") } // Each client must have at least one redirect URI and a non-empty clientId. for i, c := range cfg.Clients { prefix := fmt.Sprintf("clients[%d] (%s)", i, c.ClientID) if c.ClientID == "" { prefix = fmt.Sprintf("clients[%d]", i) errs = append(errs, prefix+": clientId must not be empty") } if strings.TrimSpace(c.Audience) != c.Audience || strings.ContainsAny(c.Audience, " \t\r\n") { errs = append(errs, prefix+": audience must be a single non-whitespace identifier") } hasAuthorizationCode := contains(c.GrantTypes, "authorization_code") hasClientCredentials := contains(c.GrantTypes, "client_credentials") if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 { errs = append(errs, prefix+": redirect_uri: at least one redirectUri must be registered") } if hasClientCredentials { if c.ClientType != "confidential" { errs = append(errs, prefix+": client_credentials requires clientType confidential") } if !strings.HasPrefix(c.SecretRef, "env:") { errs = append(errs, prefix+": client_credentials requires an env: secretRef") } if c.ServiceSubject == "" || c.Tenant == "" { errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant") } if c.TokenLifetime != "" { lifetime, err := time.ParseDuration(c.TokenLifetime) if err != nil { errs = append(errs, prefix+": tokenLifetime must be a valid duration") } else if lifetime < time.Minute || lifetime > time.Hour { errs = append(errs, prefix+": tokenLifetime must be between 1m and 1h") } } } else { if c.TokenLifetime != "" { errs = append(errs, prefix+": tokenLifetime is only supported for client_credentials clients") } // serviceSubject and roles are read only on the client_credentials // path, so on a browser client they are silently ignored: the // subject and roles come from the directory user. Rejecting them // turns a registration that looks effective into a startup error // (KEY-WP-0028). // // tenant is deliberately NOT in this list. A browser client may // declare one, and humanTenant resolves it against the directory -- // see docs/tenant-claim-contract.md, "How a human token's tenant is // resolved". if c.ServiceSubject != "" { errs = append(errs, prefix+": serviceSubject is only read for client_credentials clients; a browser client's subject is the directory user") } if len(c.Roles) > 0 { errs = append(errs, prefix+": roles is only read for client_credentials clients; a browser client's roles come from the directory user") } } // Warn about wildcard redirect URIs (they are blocked at runtime anyway). for _, uri := range c.RedirectURIs { if strings.ContainsAny(uri, "*?") { errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri)) } } if c.RegistrationURL != "" { if err := validateHandoffURL(c.RegistrationURL); err != nil { errs = append(errs, prefix+": registrationUrl: "+err.Error()) } } if c.EnrollmentURL != "" { if err := validateHandoffURL(c.EnrollmentURL); err != nil { errs = append(errs, prefix+": enrollmentUrl: "+err.Error()) } } } // Private key PEM path must be provided (existence is checked at startup). if cfg.PrivateKeyPEM == "" { errs = append(errs, "privateKeyPem: path must not be empty") } return errs } func contains(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false } func validateHandoffURL(raw string) error { u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("%q is not an absolute URL", raw) } if u.Scheme != "http" && u.Scheme != "https" { return fmt.Errorf("%q scheme must be http or https", raw) } if u.User != nil { return fmt.Errorf("%q must not contain userinfo", raw) } if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") { return fmt.Errorf("%q must not contain wildcards", raw) } return nil }