key-cape/src/internal/config/validate.go

164 lines
6.3 KiB
Go
Raw Normal View History

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")
}
}
Let a human token carry the zone it is issued into, without relabelling anyone KEY-WP-0013-T05's tenant blocker did not need the decision it was waiting on. The two proposed resolutions differ in where a human's tenant comes from -- the directory record, or the client registration -- and an implementation exists that is correct under either, so the choice can be made later without another migration. A client registration may now declare a tenant. humanTenant() resolves it by four rules: no declaration keeps the directory answer unchanged; a declared zone applies where the directory has placed the user nowhere; agreement passes; and a declared zone conflicting with a directory assignment refuses issuance rather than relabelling the user. The refusal is the design, not an edge case. A registration can bind a zone for unplaced users and can never move a placed one, so this gets the approval chain its tenant:platform without writing a general cross-tenant override into the issuer. It fails closed rather than picking a winner, because either answer would be a silent cross-tenant assertion, and it reports 403 with error_type: tenant_binding so an operator can tell a misconfigured registration from a rejected login. If the owners later populate directory tenants, the same code stops supplying the zone and starts enforcing agreement with it. Safe only because client registrations are static and deployment-owned. The tenant contract records that this rule must be revisited if dynamic client registration is ever admitted. Tests cover all four rules; neutering the conflict check fails the relabel test rather than passing silently. T05 now waits on one thing only: the client_id and callback URI from informed-decision once it has a deployed origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH Assistant: claude-code Assistant-Model: opus Assistant-Process: 1182213@bnt-lap001 Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 14:40:36 +02:00
} 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".
Let a human token carry the zone it is issued into, without relabelling anyone KEY-WP-0013-T05's tenant blocker did not need the decision it was waiting on. The two proposed resolutions differ in where a human's tenant comes from -- the directory record, or the client registration -- and an implementation exists that is correct under either, so the choice can be made later without another migration. A client registration may now declare a tenant. humanTenant() resolves it by four rules: no declaration keeps the directory answer unchanged; a declared zone applies where the directory has placed the user nowhere; agreement passes; and a declared zone conflicting with a directory assignment refuses issuance rather than relabelling the user. The refusal is the design, not an edge case. A registration can bind a zone for unplaced users and can never move a placed one, so this gets the approval chain its tenant:platform without writing a general cross-tenant override into the issuer. It fails closed rather than picking a winner, because either answer would be a silent cross-tenant assertion, and it reports 403 with error_type: tenant_binding so an operator can tell a misconfigured registration from a rejected login. If the owners later populate directory tenants, the same code stops supplying the zone and starts enforcing agreement with it. Safe only because client registrations are static and deployment-owned. The tenant contract records that this rule must be revisited if dynamic client registration is ever admitted. Tests cover all four rules; neutering the conflict check fails the relabel test rather than passing silently. T05 now waits on one thing only: the client_id and callback URI from informed-decision once it has a deployed origin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV8zoCKpA1WRAxsKRYbdH Assistant: claude-code Assistant-Model: opus Assistant-Process: 1182213@bnt-lap001 Assistant-Session: 966597b9-ae61-46a4-8b9e-1594ab3ec4ad
2026-09-09 14:40:36 +02:00
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
}