|
| 1 | +package externaloidc |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "crypto/tls" |
| 6 | + "crypto/x509" |
| 7 | + "encoding/json" |
| 8 | + "fmt" |
| 9 | + "io" |
| 10 | + "net/http" |
| 11 | + "net/url" |
| 12 | + "strings" |
| 13 | + "time" |
| 14 | + |
| 15 | + configv1 "github.com/openshift/api/config/v1" |
| 16 | + configinformers "github.com/openshift/client-go/config/informers/externalversions" |
| 17 | + configv1listers "github.com/openshift/client-go/config/listers/config/v1" |
| 18 | + "github.com/openshift/library-go/pkg/controller/factory" |
| 19 | + "github.com/openshift/library-go/pkg/operator/events" |
| 20 | + "github.com/openshift/library-go/pkg/operator/resource/retry" |
| 21 | + "github.com/openshift/library-go/pkg/operator/v1helpers" |
| 22 | + "golang.org/x/net/http/httpproxy" |
| 23 | + |
| 24 | + "k8s.io/apimachinery/pkg/api/equality" |
| 25 | + "k8s.io/apimachinery/pkg/api/errors" |
| 26 | + apierrors "k8s.io/apimachinery/pkg/api/errors" |
| 27 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 28 | + apiserverv1beta1 "k8s.io/apiserver/pkg/apis/apiserver/v1beta1" |
| 29 | + corev1ac "k8s.io/client-go/applyconfigurations/core/v1" |
| 30 | + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" |
| 31 | + corev1listers "k8s.io/client-go/listers/core/v1" |
| 32 | + "k8s.io/client-go/util/cert" |
| 33 | + "k8s.io/utils/ptr" |
| 34 | +) |
| 35 | + |
| 36 | +const ( |
| 37 | + configNamespace = "openshift-config" |
| 38 | + managedNamespace = "openshift-config-managed" |
| 39 | + targetAuthConfigCMName = "auth-config" |
| 40 | + authConfigDataKey = "auth-config.json" |
| 41 | + oidcDiscoveryEndpointPath = "/.well-known/openid-configuration" |
| 42 | + kindAuthenticationConfiguration = "AuthenticationConfiguration" |
| 43 | +) |
| 44 | + |
| 45 | +type externalOIDCController struct { |
| 46 | + name string |
| 47 | + eventName string |
| 48 | + authLister configv1listers.AuthenticationLister |
| 49 | + configMapLister corev1listers.ConfigMapLister |
| 50 | + configMaps corev1client.ConfigMapsGetter |
| 51 | +} |
| 52 | + |
| 53 | +func NewExternalOIDCController( |
| 54 | + kubeInformersForNamespaces v1helpers.KubeInformersForNamespaces, |
| 55 | + configInformer configinformers.SharedInformerFactory, |
| 56 | + operatorClient v1helpers.OperatorClient, |
| 57 | + configMaps corev1client.ConfigMapsGetter, |
| 58 | + recorder events.Recorder, |
| 59 | +) factory.Controller { |
| 60 | + |
| 61 | + c := &externalOIDCController{ |
| 62 | + name: "ExternalOIDCController", |
| 63 | + eventName: "external-oidc-controller", |
| 64 | + |
| 65 | + authLister: configInformer.Config().V1().Authentications().Lister(), |
| 66 | + configMapLister: kubeInformersForNamespaces.ConfigMapLister(), |
| 67 | + configMaps: configMaps, |
| 68 | + } |
| 69 | + |
| 70 | + return factory.New().WithInformers( |
| 71 | + // track openshift-config for changes to the provider's CA bundle |
| 72 | + kubeInformersForNamespaces.InformersFor(configNamespace).Core().V1().ConfigMaps().Informer(), |
| 73 | + // track auth resource |
| 74 | + configInformer.Config().V1().Authentications().Informer(), |
| 75 | + ).WithFilteredEventsInformers( |
| 76 | + // track openshift-config-managed/auth-config cm in case it gets changed externally |
| 77 | + factory.NamesFilter(targetAuthConfigCMName), |
| 78 | + kubeInformersForNamespaces.InformersFor(managedNamespace).Core().V1().ConfigMaps().Informer(), |
| 79 | + ).WithSync(c.sync). |
| 80 | + WithSyncDegradedOnError(operatorClient). |
| 81 | + ToController(c.name, recorder.WithComponentSuffix(c.eventName)) |
| 82 | +} |
| 83 | + |
| 84 | +func (c *externalOIDCController) sync(ctx context.Context, syncCtx factory.SyncContext) error { |
| 85 | + auth, err := c.authLister.Get("cluster") |
| 86 | + if err != nil { |
| 87 | + return fmt.Errorf("could not get authentication/cluster: %v", err) |
| 88 | + } |
| 89 | + |
| 90 | + if auth.Spec.Type != configv1.AuthenticationTypeOIDC { |
| 91 | + // auth type is "IntegratedOAuth", "" or "None"; delete structured auth configmap if it exists |
| 92 | + return c.deleteAuthConfig(ctx, syncCtx) |
| 93 | + } |
| 94 | + |
| 95 | + authConfig, err := c.generateAuthConfig(*auth) |
| 96 | + if err != nil { |
| 97 | + return err |
| 98 | + } |
| 99 | + |
| 100 | + expectedApplyConfig, err := getExpectedApplyConfig(*authConfig) |
| 101 | + if err != nil { |
| 102 | + return err |
| 103 | + } |
| 104 | + |
| 105 | + existingApplyConfig, err := c.getExistingApplyConfig() |
| 106 | + if err != nil { |
| 107 | + return err |
| 108 | + } |
| 109 | + |
| 110 | + if existingApplyConfig != nil && equality.Semantic.DeepEqual(existingApplyConfig.Data, expectedApplyConfig.Data) { |
| 111 | + return nil |
| 112 | + } |
| 113 | + |
| 114 | + if err := validateAuthConfig(*authConfig); err != nil { |
| 115 | + return fmt.Errorf("auth config validation failed: %v", err) |
| 116 | + } |
| 117 | + |
| 118 | + if _, err := c.configMaps.ConfigMaps(managedNamespace).Apply(ctx, expectedApplyConfig, metav1.ApplyOptions{FieldManager: c.name, Force: true}); err != nil { |
| 119 | + return fmt.Errorf("could not apply changes to auth configmap %s/%s: %v", managedNamespace, targetAuthConfigCMName, err) |
| 120 | + } |
| 121 | + |
| 122 | + syncCtx.Recorder().Eventf(c.eventName, "Synced auth configmap %s/%s", managedNamespace, targetAuthConfigCMName) |
| 123 | + |
| 124 | + return nil |
| 125 | +} |
| 126 | + |
| 127 | +// deleteAuthConfig checks if the auth config ConfigMap exists in the managed namespace, and deletes it |
| 128 | +// if it does; it returns an error if it encounters one. |
| 129 | +func (c *externalOIDCController) deleteAuthConfig(ctx context.Context, syncCtx factory.SyncContext) error { |
| 130 | + if _, err := c.configMapLister.ConfigMaps(managedNamespace).Get(targetAuthConfigCMName); errors.IsNotFound(err) { |
| 131 | + return nil |
| 132 | + } else if err != nil { |
| 133 | + return err |
| 134 | + } |
| 135 | + |
| 136 | + if err := c.configMaps.ConfigMaps(managedNamespace).Delete(ctx, targetAuthConfigCMName, metav1.DeleteOptions{}); err == nil { |
| 137 | + syncCtx.Recorder().Eventf(c.eventName, "Removed auth configmap %s/%s", managedNamespace, targetAuthConfigCMName) |
| 138 | + |
| 139 | + } else if !apierrors.IsNotFound(err) { |
| 140 | + return fmt.Errorf("could not delete existing configmap %s/%s: %v", managedNamespace, targetAuthConfigCMName, err) |
| 141 | + } |
| 142 | + |
| 143 | + return nil |
| 144 | +} |
| 145 | + |
| 146 | +// generateAuthConfig creates a structured JWT AuthenticationConfiguration for OIDC |
| 147 | +// from the configuration found in the authentication/cluster resource. |
| 148 | +func (c *externalOIDCController) generateAuthConfig(auth configv1.Authentication) (*apiserverv1beta1.AuthenticationConfiguration, error) { |
| 149 | + authConfig := apiserverv1beta1.AuthenticationConfiguration{ |
| 150 | + TypeMeta: metav1.TypeMeta{ |
| 151 | + Kind: kindAuthenticationConfiguration, |
| 152 | + APIVersion: apiserverv1beta1.ConfigSchemeGroupVersion.String(), |
| 153 | + }, |
| 154 | + } |
| 155 | + |
| 156 | + for _, provider := range auth.Spec.OIDCProviders { |
| 157 | + jwt := apiserverv1beta1.JWTAuthenticator{ |
| 158 | + Issuer: apiserverv1beta1.Issuer{ |
| 159 | + URL: provider.Issuer.URL, |
| 160 | + AudienceMatchPolicy: apiserverv1beta1.AudienceMatchPolicyMatchAny, |
| 161 | + }, |
| 162 | + ClaimMappings: apiserverv1beta1.ClaimMappings{ |
| 163 | + Username: apiserverv1beta1.PrefixedClaimOrExpression{ |
| 164 | + Claim: provider.ClaimMappings.Username.Claim, |
| 165 | + }, |
| 166 | + Groups: apiserverv1beta1.PrefixedClaimOrExpression{ |
| 167 | + Claim: provider.ClaimMappings.Groups.Claim, |
| 168 | + Prefix: &provider.ClaimMappings.Groups.Prefix, |
| 169 | + }, |
| 170 | + }, |
| 171 | + } |
| 172 | + |
| 173 | + for _, aud := range provider.Issuer.Audiences { |
| 174 | + jwt.Issuer.Audiences = append(jwt.Issuer.Audiences, string(aud)) |
| 175 | + } |
| 176 | + |
| 177 | + if len(provider.Issuer.CertificateAuthority.Name) > 0 { |
| 178 | + caConfigMap, err := c.configMapLister.ConfigMaps(configNamespace).Get(provider.Issuer.CertificateAuthority.Name) |
| 179 | + if err != nil { |
| 180 | + return nil, fmt.Errorf("could not retrieve auth configmap %s/%s to check CA bundle: %v", configNamespace, provider.Issuer.CertificateAuthority.Name, err) |
| 181 | + } |
| 182 | + |
| 183 | + caData, ok := caConfigMap.Data["ca-bundle.crt"] |
| 184 | + if !ok || len(caData) == 0 { |
| 185 | + return nil, fmt.Errorf("configmap %s/%s key \"ca-bundle.crt\" missing or empty", configNamespace, provider.Issuer.CertificateAuthority.Name) |
| 186 | + } |
| 187 | + |
| 188 | + jwt.Issuer.CertificateAuthority = caData |
| 189 | + } |
| 190 | + |
| 191 | + switch provider.ClaimMappings.Username.PrefixPolicy { |
| 192 | + |
| 193 | + case configv1.Prefix: |
| 194 | + if provider.ClaimMappings.Username.Prefix == nil { |
| 195 | + return nil, fmt.Errorf("nil username prefix while policy expects one") |
| 196 | + } else { |
| 197 | + jwt.ClaimMappings.Username.Prefix = &provider.ClaimMappings.Username.Prefix.PrefixString |
| 198 | + } |
| 199 | + |
| 200 | + case configv1.NoPrefix: |
| 201 | + jwt.ClaimMappings.Username.Prefix = ptr.To("") |
| 202 | + |
| 203 | + case configv1.NoOpinion: |
| 204 | + prefix := "" |
| 205 | + if provider.ClaimMappings.Username.Claim != "email" { |
| 206 | + prefix = provider.Issuer.URL + "#" |
| 207 | + } |
| 208 | + jwt.ClaimMappings.Username.Prefix = &prefix |
| 209 | + |
| 210 | + default: |
| 211 | + return nil, fmt.Errorf("invalid username prefix policy: %s", provider.ClaimMappings.Username.PrefixPolicy) |
| 212 | + } |
| 213 | + |
| 214 | + for i, rule := range provider.ClaimValidationRules { |
| 215 | + if rule.RequiredClaim == nil { |
| 216 | + return nil, fmt.Errorf("empty validation rule at index %d", i) |
| 217 | + } |
| 218 | + |
| 219 | + jwt.ClaimValidationRules = append(jwt.ClaimValidationRules, apiserverv1beta1.ClaimValidationRule{ |
| 220 | + Claim: rule.RequiredClaim.Claim, |
| 221 | + RequiredValue: rule.RequiredClaim.RequiredValue, |
| 222 | + }) |
| 223 | + } |
| 224 | + |
| 225 | + authConfig.JWT = append(authConfig.JWT, jwt) |
| 226 | + } |
| 227 | + |
| 228 | + return &authConfig, nil |
| 229 | +} |
| 230 | + |
| 231 | +// getExpectedApplyConfig serializes the input authConfig into JSON and creates an apply configuration |
| 232 | +// for a configmap with the serialized authConfig in the right key. |
| 233 | +func getExpectedApplyConfig(authConfig apiserverv1beta1.AuthenticationConfiguration) (*corev1ac.ConfigMapApplyConfiguration, error) { |
| 234 | + authConfigBytes, err := json.Marshal(authConfig) |
| 235 | + if err != nil { |
| 236 | + return nil, fmt.Errorf("could not marshal auth config into JSON: %v", err) |
| 237 | + } |
| 238 | + |
| 239 | + expectedCMApplyConfig := corev1ac.ConfigMap(targetAuthConfigCMName, managedNamespace). |
| 240 | + WithData(map[string]string{ |
| 241 | + authConfigDataKey: string(authConfigBytes), |
| 242 | + }) |
| 243 | + |
| 244 | + return expectedCMApplyConfig, nil |
| 245 | +} |
| 246 | + |
| 247 | +// getExistingApplyConfig checks if an authConfig configmap already exists, and returns an apply configuration |
| 248 | +// that represents it if it does; it returns nil otherwise. |
| 249 | +func (c *externalOIDCController) getExistingApplyConfig() (*corev1ac.ConfigMapApplyConfiguration, error) { |
| 250 | + existingCM, err := c.configMapLister.ConfigMaps(managedNamespace).Get(targetAuthConfigCMName) |
| 251 | + if apierrors.IsNotFound(err) { |
| 252 | + return nil, nil |
| 253 | + } else if err != nil { |
| 254 | + return nil, fmt.Errorf("could not retrieve auth configmap %s/%s to check data before sync: %v", managedNamespace, targetAuthConfigCMName, err) |
| 255 | + } |
| 256 | + |
| 257 | + existingCMApplyConfig, err := corev1ac.ExtractConfigMap(existingCM, c.name) |
| 258 | + if err != nil { |
| 259 | + return nil, fmt.Errorf("could not extract ConfigMap apply configuration: %v", err) |
| 260 | + } |
| 261 | + |
| 262 | + return existingCMApplyConfig, nil |
| 263 | +} |
| 264 | + |
| 265 | +// validateAuthConfig performs validations that are not done at the server-side, |
| 266 | +// including validation that the provided CA cert (or system CAs if not specified) can be used for |
| 267 | +// TLS cert verification. |
| 268 | +func validateAuthConfig(auth apiserverv1beta1.AuthenticationConfiguration) error { |
| 269 | + for _, jwt := range auth.JWT { |
| 270 | + var caCertPool *x509.CertPool |
| 271 | + var err error |
| 272 | + if len(jwt.Issuer.CertificateAuthority) > 0 { |
| 273 | + caCertPool, err = cert.NewPoolFromBytes([]byte(jwt.Issuer.CertificateAuthority)) |
| 274 | + if err != nil { |
| 275 | + return fmt.Errorf("issuer CA is invalid: %v", err) |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + // make sure we can access the issuer with the given cert pool (system CAs used if pool is empty) |
| 280 | + if err := validateCACert(jwt.Issuer.URL, caCertPool); err != nil { |
| 281 | + certMessage := "using the specified CA cert" |
| 282 | + if caCertPool == nil { |
| 283 | + certMessage = "using the system CAs" |
| 284 | + } |
| 285 | + return fmt.Errorf("could not validate IDP URL %s: %v", certMessage, err) |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + return nil |
| 290 | +} |
| 291 | + |
| 292 | +// validateCACert makes a request to the provider's well-known endpoint using the |
| 293 | +// specified CA cert pool to validate that the certs in the pool match the host. |
| 294 | +func validateCACert(hostURL string, caCertPool *x509.CertPool) error { |
| 295 | + client := &http.Client{ |
| 296 | + Transport: &http.Transport{ |
| 297 | + TLSClientConfig: &tls.Config{RootCAs: caCertPool}, |
| 298 | + Proxy: func(*http.Request) (*url.URL, error) { |
| 299 | + if proxyConfig := httpproxy.FromEnvironment(); len(proxyConfig.HTTPSProxy) > 0 { |
| 300 | + return url.Parse(proxyConfig.HTTPSProxy) |
| 301 | + } |
| 302 | + return nil, nil |
| 303 | + }, |
| 304 | + }, |
| 305 | + Timeout: 5 * time.Second, |
| 306 | + } |
| 307 | + |
| 308 | + wellKnown := strings.TrimSuffix(hostURL, "/") + oidcDiscoveryEndpointPath |
| 309 | + req, err := http.NewRequest(http.MethodGet, wellKnown, nil) |
| 310 | + if err != nil { |
| 311 | + return fmt.Errorf("could not create well-known HTTP request: %v", err) |
| 312 | + } |
| 313 | + |
| 314 | + var resp *http.Response |
| 315 | + var connErr error |
| 316 | + retryCtx, cancel := context.WithTimeout(req.Context(), 10*time.Second) |
| 317 | + defer cancel() |
| 318 | + retry.RetryOnConnectionErrors(retryCtx, func(ctx context.Context) (done bool, err error) { |
| 319 | + resp, connErr = client.Do(req) |
| 320 | + return connErr == nil, connErr |
| 321 | + }) |
| 322 | + if connErr != nil { |
| 323 | + return fmt.Errorf("GET well-known error: %v", connErr) |
| 324 | + } |
| 325 | + defer resp.Body.Close() |
| 326 | + |
| 327 | + if resp.StatusCode != http.StatusOK { |
| 328 | + body, err := io.ReadAll(resp.Body) |
| 329 | + if err != nil { |
| 330 | + return fmt.Errorf("unable to read response body; HTTP status: %s; error: %v", resp.Status, err) |
| 331 | + } |
| 332 | + |
| 333 | + return fmt.Errorf("unexpected well-known status code %s: %s", resp.Status, body) |
| 334 | + } |
| 335 | + |
| 336 | + return nil |
| 337 | +} |
0 commit comments