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")
|
|
|
|
|
}
|
2026-09-09 14:42:26 +02:00
|
|
|
// 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".
|
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")
|
|
|
|
|
}
|
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
|
|
|
|
|
}
|