Skip to content

Commit e44a923

Browse files
Merge pull request #713 from liouk/oidc-config-structured-auth
AUTH-541: OIDC structured auth config
2 parents 514e843 + cc82f46 commit e44a923

File tree

9 files changed

+2168
-0
lines changed

9 files changed

+2168
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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

Comments
 (0)