From 79a9167ad557e683f34cd6b45d0a6f564253c922 Mon Sep 17 00:00:00 2001 From: Liewe Gutter Date: Thu, 16 Oct 2025 12:57:19 +0200 Subject: [PATCH] Add support for CA certificate for auth provider When using an SSO provider with a certificate signed by our own internal CA, the ui server is currently unable to verify the certificate. This change adds support for providing a CA certificate to enable verification of the used certificate. --- server/config/development.yaml | 2 + server/config/docker.yaml | 2 + server/server/config/config.go | 4 ++ server/server/route/auth.go | 39 +++++++++++++++++-- server/server/route/auth_test.go | 47 +++++++++++++++++++++++ server/server/rpc/tls.go | 8 ++-- server/server/rpc/tls_cert_loader_test.go | 17 ++++++++ 7 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 server/server/route/auth_test.go diff --git a/server/config/development.yaml b/server/config/development.yaml index 0b44b32ef5..88b4f3e08b 100644 --- a/server/config/development.yaml +++ b/server/config/development.yaml @@ -32,6 +32,8 @@ auth: issuerUrl: "" # needed if the Issuer Url and the Provider Url are different clientId: xxxxxxxxxxxxxxxxxxxx clientSecret: xxxxxxxxxxxxxxxxxxxx + caFile: + caData: scopes: - openid - profile diff --git a/server/config/docker.yaml b/server/config/docker.yaml index e661b8ba3f..61960c28db 100644 --- a/server/config/docker.yaml +++ b/server/config/docker.yaml @@ -59,6 +59,8 @@ auth: clientSecret: {{ env "TEMPORAL_AUTH_CLIENT_SECRET" }} callbackUrl: {{ env "TEMPORAL_AUTH_CALLBACK_URL" }} useIdTokenAsBearer: {{ env "TEMPORAL_AUTH_USE_ID_TOKEN_AS_BEARER" | default "false" }} + caFile: {{ env "TEMPORAL_AUTH_CA" | default "" }} + caData: {{ env "TEMPORAL_AUTH_CA_DATA" | default "" }} scopes: {{- if env "TEMPORAL_AUTH_SCOPES" }} {{- range env "TEMPORAL_AUTH_SCOPES" | split "," }} diff --git a/server/server/config/config.go b/server/server/config/config.go index d0cc562b0f..cefb6cb066 100644 --- a/server/server/config/config.go +++ b/server/server/config/config.go @@ -126,6 +126,10 @@ type ( Options map[string]interface{} `yaml:"options"` // UseIDTokenAsBearer - Use ID token instead of access token as Bearer in Authorization header UseIDTokenAsBearer bool `yaml:"useIdTokenAsBearer"` + // CaFile - optional custom CA bundle for contacting the auth provider + CaFile string `yaml:"caFile"` + // CaData - optional base64-encoded CA bundle for contacting the auth provider + CaData string `yaml:"caData"` } Codec struct { diff --git a/server/server/route/auth.go b/server/server/route/auth.go index 21cab99728..728c0dba5b 100644 --- a/server/server/route/auth.go +++ b/server/server/route/auth.go @@ -23,6 +23,7 @@ package route import ( + "crypto/tls" "encoding/base64" "encoding/json" "errors" @@ -37,6 +38,7 @@ import ( "github.com/labstack/echo/v4" "github.com/temporalio/ui-server/v2/server/auth" "github.com/temporalio/ui-server/v2/server/config" + "github.com/temporalio/ui-server/v2/server/rpc" "golang.org/x/net/context" "golang.org/x/oauth2" ) @@ -46,15 +48,17 @@ func SetAuthRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh) ctx := context.Background() serverCfg, err := cfgProvider.GetConfig() if err != nil { - fmt.Printf("unable to get auth config: %s\n", err) + log.Printf("unable to get auth config: %s\n", err) + return } if !serverCfg.Auth.Enabled { return } - if len(serverCfg.Auth.Providers) == 0 { - log.Fatal(`auth providers configuration is empty. Configure an auth provider or disable auth`) + err = validateAuthConfig(&serverCfg.Auth) + if err != nil { + log.Fatalf("invalid auth config: %s\n", err) } providerCfg := serverCfg.Auth.Providers[0] // only single provider is currently supported @@ -62,6 +66,22 @@ func SetAuthRoutes(e *echo.Echo, cfgProvider *config.ConfigProviderWithRefresh) if len(providerCfg.IssuerUrl) > 0 { ctx = oidc.InsecureIssuerURLContext(ctx, providerCfg.IssuerUrl) } + + // Configure HTTP client (with timeout) and optional custom CA if provided via caFile or caData + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + if providerCfg.CaFile != "" || providerCfg.CaData != "" { + caCertPool, err := rpc.LoadCACert(providerCfg.CaFile, providerCfg.CaData) + if err != nil { + log.Fatalf("Unable to load auth CA certificate: %s\n", err) + } + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: caCertPool}, + } + } + ctx = oidc.ClientContext(ctx, httpClient) + provider, err := oidc.NewProvider(ctx, providerCfg.ProviderURL) if err != nil { log.Fatal(err) @@ -231,3 +251,16 @@ type Nonce struct { Nonce string `json:"nonce"` ReturnURL string `json:"return_url"` } + +func validateAuthConfig(cfg *config.Auth) error { + if len(cfg.Providers) == 0 { + return fmt.Errorf(`auth providers configuration is empty. Configure an auth provider or disable auth`) + } + for _, providerCfg := range cfg.Providers { + if providerCfg.CaFile != "" && providerCfg.CaData != "" { + return fmt.Errorf("cannot specify Auth CA file and CA data at the same time") + } + } + + return nil +} diff --git a/server/server/route/auth_test.go b/server/server/route/auth_test.go new file mode 100644 index 0000000000..89653283a5 --- /dev/null +++ b/server/server/route/auth_test.go @@ -0,0 +1,47 @@ +package route + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/temporalio/ui-server/v2/server/config" +) + +func TestValidateAuthConfig_NoProviders(t *testing.T) { + err := validateAuthConfig(&config.Auth{ + Enabled: true, + Providers: []config.AuthProvider{}, + }) + + assert.Error(t, err) +} + +func TestValidateAuthConfig_CaFileAndCaDataMutuallyExclusive(t *testing.T) { + err := validateAuthConfig(&config.Auth{ + Enabled: true, + Providers: []config.AuthProvider{ + { + CaFile: "file", + CaData: "data", + }, + }, + }) + + assert.Error(t, err) +} + +func TestValidateAuthConfig_ValidConfig(t *testing.T) { + err := validateAuthConfig(&config.Auth{ + Enabled: true, + Providers: []config.AuthProvider{ + { + ProviderURL: "https://example.com", + ClientID: "id", + CallbackURL: "https://example.com/callback", + CaFile: "file", + }, + }, + }) + + assert.NoError(t, err) +} diff --git a/server/server/rpc/tls.go b/server/server/rpc/tls.go index 382bb3b6ab..68f64dcf29 100644 --- a/server/server/rpc/tls.go +++ b/server/server/rpc/tls.go @@ -139,7 +139,7 @@ func CreateTLSConfig(address string, cfg *config.TLS) (*tls.Config, error) { } if configureCertPool { - caCertPool, err := loadCACert(cfg) + caCertPool, err := LoadCACert(cfg.CaFile, cfg.CaData) if err != nil { log.Fatalf("Unable to load server CA certificate") return nil, err @@ -171,9 +171,9 @@ func CreateTLSConfig(address string, cfg *config.TLS) (*tls.Config, error) { return tlsConfig, nil } -func loadCACert(cfg *config.TLS) (caPool *x509.CertPool, err error) { - pathOrUrl := cfg.CaFile - caData := cfg.CaData +// LoadCACert loads a CA certificate bundle from a file path or HTTPS URL, or from base64-encoded data, +// and returns a certificate pool containing the parsed certificates. +func LoadCACert(pathOrUrl string, caData string) (caPool *x509.CertPool, err error) { caPool = x509.NewCertPool() var caBytes []byte diff --git a/server/server/rpc/tls_cert_loader_test.go b/server/server/rpc/tls_cert_loader_test.go index 472d8daa72..6c17f71f01 100644 --- a/server/server/rpc/tls_cert_loader_test.go +++ b/server/server/rpc/tls_cert_loader_test.go @@ -6,6 +6,7 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/pem" "math/big" "os" @@ -63,6 +64,22 @@ func TestCertLoader_ReloadsNewKeyPair(t *testing.T) { assert.NotEqual(t, expect1.Certificate, loaded2.Certificate) } +func TestLoadCACert_FromBase64Data(t *testing.T) { + certPEM, _ := generateCertKeyPair(t, "ca") + + caData := base64.StdEncoding.EncodeToString(certPEM) + + pool, err := LoadCACert("", caData) + assert.NoError(t, err) + assert.NotNil(t, pool) +} + +func TestLoadCACert_EmptyInputsError(t *testing.T) { + pool, err := LoadCACert("", "") + assert.Error(t, err) + assert.Nil(t, pool) +} + // generateCertKeyPair creates a self-signed certificate and private key for testing. func generateCertKeyPair(t *testing.T, commonName string) (certPEM, keyPEM []byte) { // Generate a private key