2026-03-13 02:18:36 +01:00
package config
import (
"fmt"
"net/url"
"strings"
2026-08-23 13:10:13 +02:00
"time"
2026-03-13 02:18:36 +01:00
)
// 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 ) )
}
2026-09-08 08:59:40 +02:00
// 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" )
}
}
2026-03-13 02:18:36 +01:00
// 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" )
}
2026-09-05 00:41:17 +02:00
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" )
}
2026-07-27 20:03:07 +02:00
hasAuthorizationCode := contains ( c . GrantTypes , "authorization_code" )
hasClientCredentials := contains ( c . GrantTypes , "client_credentials" )
if ( hasAuthorizationCode || ! hasClientCredentials ) && len ( c . RedirectURIs ) == 0 {
2026-03-13 02:18:36 +01:00
errs = append ( errs , prefix + ": redirect_uri: at least one redirectUri must be registered" )
}
2026-07-27 20:03:07 +02:00
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" )
}
2026-08-23 13:10:13 +02:00
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" )
}
}
2026-09-09 14:40:36 +02:00
} else {
if c . TokenLifetime != "" {
errs = append ( errs , prefix + ": tokenLifetime is only supported for client_credentials clients" )
}
// Service-identity fields are read only on the client_credentials
// path. On a browser client they are silently ignored, and the
// tenant one is a trap worth failing on: an approver client
// registered with tenant: tenant:platform starts cleanly and then
// issues tokens carrying the directory tenant, which a resource
// server comparing tenant exactly will refuse. That surfaces as a
// mysterious failed approval, not as a registration defect
// (KEY-WP-0028). Human tenant comes from the directory user; see
// docs/approval-engine-auth-contract.md.
if c . Tenant != "" {
errs = append ( errs , prefix + ": tenant is only read for client_credentials clients; a browser client's tenant claim comes from the directory user, so this value would be silently ignored" )
}
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" )
}
2026-07-27 20:03:07 +02:00
}
2026-03-13 02:18:36 +01:00
// 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 ) )
}
}
2026-08-16 01:05:27 +02:00
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 ( ) )
}
}
2026-03-13 02:18:36 +01:00
}
// 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
}
2026-07-27 20:03:07 +02:00
func contains ( values [ ] string , wanted string ) bool {
for _ , value := range values {
if value == wanted {
return true
}
}
return false
}
2026-08-16 01:05:27 +02:00
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
}