key-cape/src/internal/config/validate.go
tegwick 74b35b6107
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Reject service-identity fields a browser client silently ignores
serviceSubject and roles are read only on the client_credentials path. On a
browser client they are accepted and then ignored, since subject and roles come
from the directory user -- so a registration that looks effective fails later as
a downstream rejection rather than as a registration defect. tokenLifetime was
already rejected this way, so the rule existed and was incomplete.

Nothing in dev-config, the example fixture or the live deployment sets either
field on a browser client, checked against all three rather than assumed, so this
breaks no existing configuration.

tenant is deliberately excluded, and a test pins that: a browser client may
declare one, and humanTenant resolves it against the directory, refusing issuance
when they disagree (KEY-WP-0013-T05). An earlier version of this change rejected
tenant too and would have made that feature unusable. It started from T05's
blocker paragraph, which was accurate when written and already fixed by the time
this task began -- blocker prose ages faster than the code it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 713576@bnt-lap001
Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
2026-09-09 14:42:26 +02:00

163 lines
6.3 KiB
Go

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
}