wiring existing oidc flags with internal API struct
Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com> Kubernetes-commit: 1bad3cbbf59a61805a48f609b8cc0a2a40c168ef
This commit is contained in:
parent
496ba1943b
commit
fdfc990c33
|
@ -0,0 +1,204 @@
|
|||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
api "k8s.io/apiserver/pkg/apis/apiserver"
|
||||
"k8s.io/client-go/util/cert"
|
||||
)
|
||||
|
||||
const (
|
||||
atLeastOneRequiredErrFmt = "at least one %s is required"
|
||||
)
|
||||
|
||||
var (
|
||||
root = field.NewPath("jwt")
|
||||
)
|
||||
|
||||
// ValidateAuthenticationConfiguration validates a given AuthenticationConfiguration.
|
||||
func ValidateAuthenticationConfiguration(c *api.AuthenticationConfiguration) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
// This stricter validation is solely based on what the current implementation supports.
|
||||
// TODO(aramase): when StructuredAuthenticationConfiguration feature gate is added and wired up,
|
||||
// relax this check to allow 0 authenticators. This will allow us to support the case where
|
||||
// API server is initially configured with no authenticators and then authenticators are added
|
||||
// later via dynamic config.
|
||||
if len(c.JWT) == 0 {
|
||||
allErrs = append(allErrs, field.Required(root, fmt.Sprintf(atLeastOneRequiredErrFmt, root)))
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// This stricter validation is because the --oidc-* flag option is singular.
|
||||
// TODO(aramase): when StructuredAuthenticationConfiguration feature gate is added and wired up,
|
||||
// remove the 1 authenticator limit check and add set the limit to 64.
|
||||
if len(c.JWT) > 1 {
|
||||
allErrs = append(allErrs, field.TooMany(root, len(c.JWT), 1))
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// TODO(aramase): right now we only support a single JWT authenticator as
|
||||
// this is wired to the --oidc-* flags. When StructuredAuthenticationConfiguration
|
||||
// feature gate is added and wired up, we will remove the 1 authenticator limit
|
||||
// check and add validation for duplicate issuers.
|
||||
for i, a := range c.JWT {
|
||||
fldPath := root.Index(i)
|
||||
allErrs = append(allErrs, validateJWTAuthenticator(a, fldPath)...)
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
// ValidateJWTAuthenticator validates a given JWTAuthenticator.
|
||||
// This is exported for use in oidc package.
|
||||
func ValidateJWTAuthenticator(authenticator api.JWTAuthenticator) field.ErrorList {
|
||||
return validateJWTAuthenticator(authenticator, nil)
|
||||
}
|
||||
|
||||
func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...)
|
||||
allErrs = append(allErrs, validateClaimValidationRules(authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"))...)
|
||||
allErrs = append(allErrs, validateClaimMappings(authenticator.ClaimMappings, fldPath.Child("claimMappings"))...)
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateIssuer(issuer api.Issuer, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
allErrs = append(allErrs, validateURL(issuer.URL, fldPath.Child("url"))...)
|
||||
allErrs = append(allErrs, validateAudiences(issuer.Audiences, fldPath.Child("audiences"))...)
|
||||
allErrs = append(allErrs, validateCertificateAuthority(issuer.CertificateAuthority, fldPath.Child("certificateAuthority"))...)
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
if len(issuerURL) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath, "URL is required"))
|
||||
return allErrs
|
||||
}
|
||||
|
||||
u, err := url.Parse(issuerURL)
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, err.Error()))
|
||||
return allErrs
|
||||
}
|
||||
if u.Scheme != "https" {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL scheme must be https"))
|
||||
}
|
||||
if u.User != nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a username or password"))
|
||||
}
|
||||
if len(u.RawQuery) > 0 {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a query"))
|
||||
}
|
||||
if len(u.Fragment) > 0 {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a fragment"))
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateAudiences(audiences []string, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
if len(audiences) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath, fmt.Sprintf(atLeastOneRequiredErrFmt, fldPath)))
|
||||
return allErrs
|
||||
}
|
||||
// This stricter validation is because the --oidc-client-id flag option is singular.
|
||||
// This will be removed when we support multiple audiences with the StructuredAuthenticationConfiguration feature gate.
|
||||
if len(audiences) > 1 {
|
||||
allErrs = append(allErrs, field.TooMany(fldPath, len(audiences), 1))
|
||||
return allErrs
|
||||
}
|
||||
|
||||
for i, audience := range audiences {
|
||||
fldPath := fldPath.Index(i)
|
||||
if len(audience) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath, "audience can't be empty"))
|
||||
}
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateCertificateAuthority(certificateAuthority string, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
if len(certificateAuthority) == 0 {
|
||||
return allErrs
|
||||
}
|
||||
_, err := cert.NewPoolFromBytes([]byte(certificateAuthority))
|
||||
if err != nil {
|
||||
allErrs = append(allErrs, field.Invalid(fldPath, "<omitted>", err.Error()))
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateClaimValidationRules(rules []api.ClaimValidationRule, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
seenClaims := sets.NewString()
|
||||
for i, rule := range rules {
|
||||
fldPath := fldPath.Index(i)
|
||||
|
||||
if len(rule.Claim) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath.Child("claim"), "claim name is required"))
|
||||
continue
|
||||
}
|
||||
|
||||
if seenClaims.Has(rule.Claim) {
|
||||
allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim))
|
||||
continue
|
||||
}
|
||||
seenClaims.Insert(rule.Claim)
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
||||
|
||||
func validateClaimMappings(m api.ClaimMappings, fldPath *field.Path) field.ErrorList {
|
||||
var allErrs field.ErrorList
|
||||
|
||||
if len(m.Username.Claim) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath.Child("username", "claim"), "claim name is required"))
|
||||
}
|
||||
// TODO(aramase): when Expression is added to PrefixedClaimOrExpression, check prefix and expression are not both set.
|
||||
if m.Username.Prefix == nil {
|
||||
allErrs = append(allErrs, field.Required(fldPath.Child("username", "prefix"), "prefix is required"))
|
||||
}
|
||||
if len(m.Groups.Claim) > 0 && m.Groups.Prefix == nil {
|
||||
allErrs = append(allErrs, field.Required(fldPath.Child("groups", "prefix"), "prefix is required when claim is set"))
|
||||
}
|
||||
if m.Groups.Prefix != nil && len(m.Groups.Claim) == 0 {
|
||||
allErrs = append(allErrs, field.Required(fldPath.Child("groups", "claim"), "non-empty claim name is required when prefix is set"))
|
||||
}
|
||||
|
||||
return allErrs
|
||||
}
|
|
@ -0,0 +1,414 @@
|
|||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package validation
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/errors"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
api "k8s.io/apiserver/pkg/apis/apiserver"
|
||||
certutil "k8s.io/client-go/util/cert"
|
||||
"k8s.io/utils/pointer"
|
||||
)
|
||||
|
||||
func TestValidateAuthenticationConfiguration(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
in *api.AuthenticationConfiguration
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "jwt authenticator is empty",
|
||||
in: &api.AuthenticationConfiguration{},
|
||||
want: "jwt: Required value: at least one jwt is required",
|
||||
},
|
||||
{
|
||||
name: ">1 jwt authenticator",
|
||||
in: &api.AuthenticationConfiguration{
|
||||
JWT: []api.JWTAuthenticator{
|
||||
{Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}},
|
||||
{Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}},
|
||||
},
|
||||
},
|
||||
want: "jwt: Too many: 2: must have at most 1 items",
|
||||
},
|
||||
{
|
||||
name: "failed issuer validation",
|
||||
in: &api.AuthenticationConfiguration{
|
||||
JWT: []api.JWTAuthenticator{
|
||||
{
|
||||
Issuer: api.Issuer{
|
||||
URL: "invalid-url",
|
||||
Audiences: []string{"audience"},
|
||||
},
|
||||
ClaimMappings: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{
|
||||
Claim: "claim",
|
||||
Prefix: pointer.String("prefix"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: `jwt[0].issuer.url: Invalid value: "invalid-url": URL scheme must be https`,
|
||||
},
|
||||
{
|
||||
name: "failed claimValidationRule validation",
|
||||
in: &api.AuthenticationConfiguration{
|
||||
JWT: []api.JWTAuthenticator{
|
||||
{
|
||||
Issuer: api.Issuer{
|
||||
URL: "https://issuer-url",
|
||||
Audiences: []string{"audience"},
|
||||
},
|
||||
ClaimValidationRules: []api.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "baz",
|
||||
},
|
||||
},
|
||||
ClaimMappings: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{
|
||||
Claim: "claim",
|
||||
Prefix: pointer.String("prefix"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: `jwt[0].claimValidationRules[1].claim: Duplicate value: "foo"`,
|
||||
},
|
||||
{
|
||||
name: "failed claimMapping validation",
|
||||
in: &api.AuthenticationConfiguration{
|
||||
JWT: []api.JWTAuthenticator{
|
||||
{
|
||||
Issuer: api.Issuer{
|
||||
URL: "https://issuer-url",
|
||||
Audiences: []string{"audience"},
|
||||
},
|
||||
ClaimValidationRules: []api.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
ClaimMappings: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{
|
||||
Prefix: pointer.String("prefix"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "jwt[0].claimMappings.username.claim: Required value: claim name is required",
|
||||
},
|
||||
{
|
||||
name: "valid authentication configuration",
|
||||
in: &api.AuthenticationConfiguration{
|
||||
JWT: []api.JWTAuthenticator{
|
||||
{
|
||||
Issuer: api.Issuer{
|
||||
URL: "https://issuer-url",
|
||||
Audiences: []string{"audience"},
|
||||
},
|
||||
ClaimValidationRules: []api.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
ClaimMappings: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String("prefix"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ValidateAuthenticationConfiguration(tt.in).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("AuthenticationConfiguration validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateURL(t *testing.T) {
|
||||
fldPath := field.NewPath("issuer", "url")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "url is empty",
|
||||
in: "",
|
||||
want: "issuer.url: Required value: URL is required",
|
||||
},
|
||||
{
|
||||
name: "url parse error",
|
||||
in: "https://issuer-url:invalid-port",
|
||||
want: `issuer.url: Invalid value: "https://issuer-url:invalid-port": parse "https://issuer-url:invalid-port": invalid port ":invalid-port" after host`,
|
||||
},
|
||||
{
|
||||
name: "url is not https",
|
||||
in: "http://issuer-url",
|
||||
want: `issuer.url: Invalid value: "http://issuer-url": URL scheme must be https`,
|
||||
},
|
||||
{
|
||||
name: "url user info is not allowed",
|
||||
in: "https://user:pass@issuer-url",
|
||||
want: `issuer.url: Invalid value: "https://user:pass@issuer-url": URL must not contain a username or password`,
|
||||
},
|
||||
{
|
||||
name: "url raw query is not allowed",
|
||||
in: "https://issuer-url?query",
|
||||
want: `issuer.url: Invalid value: "https://issuer-url?query": URL must not contain a query`,
|
||||
},
|
||||
{
|
||||
name: "url fragment is not allowed",
|
||||
in: "https://issuer-url#fragment",
|
||||
want: `issuer.url: Invalid value: "https://issuer-url#fragment": URL must not contain a fragment`,
|
||||
},
|
||||
{
|
||||
name: "valid url",
|
||||
in: "https://issuer-url",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateURL(tt.in, fldPath).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("URL validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAudiences(t *testing.T) {
|
||||
fldPath := field.NewPath("issuer", "audiences")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "audiences is empty",
|
||||
in: []string{},
|
||||
want: "issuer.audiences: Required value: at least one issuer.audiences is required",
|
||||
},
|
||||
{
|
||||
name: "at most one audiences is allowed",
|
||||
in: []string{"audience1", "audience2"},
|
||||
want: "issuer.audiences: Too many: 2: must have at most 1 items",
|
||||
},
|
||||
{
|
||||
name: "audience is empty",
|
||||
in: []string{""},
|
||||
want: "issuer.audiences[0]: Required value: audience can't be empty",
|
||||
},
|
||||
{
|
||||
name: "valid audience",
|
||||
in: []string{"audience"},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateAudiences(tt.in, fldPath).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("Audiences validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCertificateAuthority(t *testing.T) {
|
||||
fldPath := field.NewPath("issuer", "certificateAuthority")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
in func() string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "invalid certificate authority",
|
||||
in: func() string { return "invalid" },
|
||||
want: `issuer.certificateAuthority: Invalid value: "<omitted>": data does not contain any valid RSA or ECDSA certificates`,
|
||||
},
|
||||
{
|
||||
name: "certificate authority is empty",
|
||||
in: func() string { return "" },
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "valid certificate authority",
|
||||
in: func() string {
|
||||
caPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caCert, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: "test-ca"}, caPrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}))
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateCertificateAuthority(tt.in(), fldPath).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("CertificateAuthority validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimValidationRules(t *testing.T) {
|
||||
fldPath := field.NewPath("issuer", "claimValidationRules")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
in []api.ClaimValidationRule
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "claim validation rule claim is empty",
|
||||
in: []api.ClaimValidationRule{{Claim: ""}},
|
||||
want: "issuer.claimValidationRules[0].claim: Required value: claim name is required",
|
||||
},
|
||||
{
|
||||
name: "duplicate claim",
|
||||
in: []api.ClaimValidationRule{{
|
||||
Claim: "claim", RequiredValue: "value1"},
|
||||
{Claim: "claim", RequiredValue: "value2"},
|
||||
},
|
||||
want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`,
|
||||
},
|
||||
{
|
||||
name: "valid claim validation rule",
|
||||
in: []api.ClaimValidationRule{{Claim: "claim", RequiredValue: "value"}},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "valid claim validation rule with multiple rules",
|
||||
in: []api.ClaimValidationRule{
|
||||
{Claim: "claim1", RequiredValue: "value1"},
|
||||
{Claim: "claim2", RequiredValue: "value2"},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateClaimValidationRules(tt.in, fldPath).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("ClaimValidationRules validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClaimMappings(t *testing.T) {
|
||||
fldPath := field.NewPath("issuer", "claimMappings")
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
in api.ClaimMappings
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "username claim is empty",
|
||||
in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "", Prefix: pointer.String("prefix")}},
|
||||
want: "issuer.claimMappings.username.claim: Required value: claim name is required",
|
||||
},
|
||||
{
|
||||
name: "username prefix is empty",
|
||||
in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "claim"}},
|
||||
want: "issuer.claimMappings.username.prefix: Required value: prefix is required",
|
||||
},
|
||||
{
|
||||
name: "groups prefix is empty",
|
||||
in: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
|
||||
Groups: api.PrefixedClaimOrExpression{Claim: "claim"},
|
||||
},
|
||||
want: "issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set",
|
||||
},
|
||||
{
|
||||
name: "groups prefix set but claim is empty",
|
||||
in: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
|
||||
Groups: api.PrefixedClaimOrExpression{Prefix: pointer.String("prefix")},
|
||||
},
|
||||
want: "issuer.claimMappings.groups.claim: Required value: non-empty claim name is required when prefix is set",
|
||||
},
|
||||
{
|
||||
name: "valid claim mappings",
|
||||
in: api.ClaimMappings{
|
||||
Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
|
||||
Groups: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")},
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := validateClaimMappings(tt.in, fldPath).ToAggregate()
|
||||
if d := cmp.Diff(tt.want, errString(got)); d != "" {
|
||||
t.Fatalf("ClaimMappings validation mismatch (-want +got):\n%s", d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func errString(errs errors.Aggregate) string {
|
||||
if errs != nil {
|
||||
return errs.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
|
@ -32,11 +32,9 @@ import (
|
|||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
@ -46,6 +44,8 @@ import (
|
|||
|
||||
"k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/apiserver/pkg/apis/apiserver"
|
||||
apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authentication/user"
|
||||
certutil "k8s.io/client-go/util/cert"
|
||||
|
@ -59,50 +59,17 @@ var (
|
|||
)
|
||||
|
||||
type Options struct {
|
||||
// IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss"
|
||||
// field of all tokens produced by the provider and is used for configuration
|
||||
// discovery.
|
||||
//
|
||||
// The URL is usually the provider's URL without a path, for example
|
||||
// "https://accounts.google.com" or "https://login.salesforce.com".
|
||||
//
|
||||
// The provider must implement configuration discovery.
|
||||
// See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
||||
IssuerURL string
|
||||
|
||||
// JWTAuthenticator is the authenticator that will be used to verify the JWT.
|
||||
JWTAuthenticator apiserver.JWTAuthenticator
|
||||
// Optional KeySet to allow for synchronous initialization instead of fetching from the remote issuer.
|
||||
KeySet oidc.KeySet
|
||||
|
||||
// ClientID the JWT must be issued for, the "sub" field. This plugin only trusts a single
|
||||
// client to ensure the plugin can be used with public providers.
|
||||
//
|
||||
// The plugin supports the "authorized party" OpenID Connect claim, which allows
|
||||
// specialized providers to issue tokens to a client for a different client.
|
||||
// See: https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
ClientID string
|
||||
|
||||
// PEM encoded root certificate contents of the provider. Mutually exclusive with Client.
|
||||
CAContentProvider CAContentProvider
|
||||
|
||||
// Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider.
|
||||
Client *http.Client
|
||||
|
||||
// UsernameClaim is the JWT field to use as the user's username.
|
||||
UsernameClaim string
|
||||
|
||||
// UsernamePrefix, if specified, causes claims mapping to username to be prefix with
|
||||
// the provided value. A value "oidc:" would result in usernames like "oidc:john".
|
||||
UsernamePrefix string
|
||||
|
||||
// GroupsClaim, if specified, causes the OIDCAuthenticator to try to populate the user's
|
||||
// groups with an ID Token field. If the GroupsClaim field is present in an ID Token the value
|
||||
// must be a string or list of strings.
|
||||
GroupsClaim string
|
||||
|
||||
// GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the
|
||||
// value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing".
|
||||
GroupsPrefix string
|
||||
|
||||
// SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that
|
||||
// can be used by the provider to sign tokens.
|
||||
//
|
||||
|
@ -114,10 +81,6 @@ type Options struct {
|
|||
// https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
|
||||
SupportedSigningAlgs []string
|
||||
|
||||
// RequiredClaims, if specified, causes the OIDCAuthenticator to verify that all the
|
||||
// required claims key value pairs are present in the ID Token.
|
||||
RequiredClaims map[string]string
|
||||
|
||||
// now is used for testing. It defaults to time.Now.
|
||||
now func() time.Time
|
||||
}
|
||||
|
@ -192,13 +155,7 @@ func (a *asyncIDTokenVerifier) verifier() *oidc.IDTokenVerifier {
|
|||
}
|
||||
|
||||
type Authenticator struct {
|
||||
issuerURL string
|
||||
|
||||
usernameClaim string
|
||||
usernamePrefix string
|
||||
groupsClaim string
|
||||
groupsPrefix string
|
||||
requiredClaims map[string]string
|
||||
jwtAuthenticator apiserver.JWTAuthenticator
|
||||
|
||||
// Contains an *oidc.IDTokenVerifier. Do not access directly use the
|
||||
// idTokenVerifier method.
|
||||
|
@ -240,19 +197,10 @@ var allowedSigningAlgs = map[string]bool{
|
|||
}
|
||||
|
||||
func New(opts Options) (*Authenticator, error) {
|
||||
url, err := url.Parse(opts.IssuerURL)
|
||||
if err != nil {
|
||||
if err := apiservervalidation.ValidateJWTAuthenticator(opts.JWTAuthenticator).ToAggregate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if url.Scheme != "https" {
|
||||
return nil, fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", opts.IssuerURL, url.Scheme)
|
||||
}
|
||||
|
||||
if opts.UsernameClaim == "" {
|
||||
return nil, errors.New("no username claim provided")
|
||||
}
|
||||
|
||||
supportedSigningAlgs := opts.SupportedSigningAlgs
|
||||
if len(supportedSigningAlgs) == 0 {
|
||||
// RS256 is the default recommended by OpenID Connect and an 'alg' value
|
||||
|
@ -273,6 +221,7 @@ func New(opts Options) (*Authenticator, error) {
|
|||
|
||||
if client == nil {
|
||||
var roots *x509.CertPool
|
||||
var err error
|
||||
if opts.CAContentProvider != nil {
|
||||
// TODO(enj): make this reload CA data dynamically
|
||||
roots, err = certutil.NewPoolFromBytes(opts.CAContentProvider.CurrentCABundleContent())
|
||||
|
@ -302,35 +251,30 @@ func New(opts Options) (*Authenticator, error) {
|
|||
}
|
||||
|
||||
verifierConfig := &oidc.Config{
|
||||
ClientID: opts.ClientID,
|
||||
ClientID: opts.JWTAuthenticator.Issuer.Audiences[0],
|
||||
SupportedSigningAlgs: supportedSigningAlgs,
|
||||
Now: now,
|
||||
}
|
||||
|
||||
var resolver *claimResolver
|
||||
if opts.GroupsClaim != "" {
|
||||
resolver = newClaimResolver(opts.GroupsClaim, client, verifierConfig)
|
||||
if opts.JWTAuthenticator.ClaimMappings.Groups.Claim != "" {
|
||||
resolver = newClaimResolver(opts.JWTAuthenticator.ClaimMappings.Groups.Claim, client, verifierConfig)
|
||||
}
|
||||
|
||||
authenticator := &Authenticator{
|
||||
issuerURL: opts.IssuerURL,
|
||||
usernameClaim: opts.UsernameClaim,
|
||||
usernamePrefix: opts.UsernamePrefix,
|
||||
groupsClaim: opts.GroupsClaim,
|
||||
groupsPrefix: opts.GroupsPrefix,
|
||||
requiredClaims: opts.RequiredClaims,
|
||||
cancel: cancel,
|
||||
resolver: resolver,
|
||||
jwtAuthenticator: opts.JWTAuthenticator,
|
||||
cancel: cancel,
|
||||
resolver: resolver,
|
||||
}
|
||||
|
||||
if opts.KeySet != nil {
|
||||
// We already have a key set, synchronously initialize the verifier.
|
||||
authenticator.setVerifier(oidc.NewVerifier(opts.IssuerURL, opts.KeySet, verifierConfig))
|
||||
authenticator.setVerifier(oidc.NewVerifier(opts.JWTAuthenticator.Issuer.URL, opts.KeySet, verifierConfig))
|
||||
} else {
|
||||
// Asynchronously attempt to initialize the authenticator. This enables
|
||||
// self-hosted providers, providers that run on top of Kubernetes itself.
|
||||
go wait.PollImmediateUntil(10*time.Second, func() (done bool, err error) {
|
||||
provider, err := oidc.NewProvider(ctx, opts.IssuerURL)
|
||||
provider, err := oidc.NewProvider(ctx, opts.JWTAuthenticator.Issuer.URL)
|
||||
if err != nil {
|
||||
klog.Errorf("oidc authenticator: initializing plugin: %v", err)
|
||||
return false, nil
|
||||
|
@ -552,7 +496,7 @@ func (r *claimResolver) resolve(ctx context.Context, endpoint endpoint, allClaim
|
|||
}
|
||||
|
||||
func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
|
||||
if !hasCorrectIssuer(a.issuerURL, token) {
|
||||
if !hasCorrectIssuer(a.jwtAuthenticator.Issuer.URL, token) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
|
@ -577,11 +521,11 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
|
|||
}
|
||||
|
||||
var username string
|
||||
if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil {
|
||||
return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err)
|
||||
if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Username.Claim, &username); err != nil {
|
||||
return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.jwtAuthenticator.ClaimMappings.Username.Claim, err)
|
||||
}
|
||||
|
||||
if a.usernameClaim == "email" {
|
||||
if a.jwtAuthenticator.ClaimMappings.Username.Claim == "email" {
|
||||
// If the email_verified claim is present, ensure the email is valid.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified {
|
||||
|
@ -597,33 +541,36 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
|
|||
}
|
||||
}
|
||||
|
||||
if a.usernamePrefix != "" {
|
||||
username = a.usernamePrefix + username
|
||||
if a.jwtAuthenticator.ClaimMappings.Username.Prefix != nil && *a.jwtAuthenticator.ClaimMappings.Username.Prefix != "" {
|
||||
username = *a.jwtAuthenticator.ClaimMappings.Username.Prefix + username
|
||||
}
|
||||
|
||||
info := &user.DefaultInfo{Name: username}
|
||||
if a.groupsClaim != "" {
|
||||
if _, ok := c[a.groupsClaim]; ok {
|
||||
if a.jwtAuthenticator.ClaimMappings.Groups.Claim != "" {
|
||||
if _, ok := c[a.jwtAuthenticator.ClaimMappings.Groups.Claim]; ok {
|
||||
// Some admins want to use string claims like "role" as the group value.
|
||||
// Allow the group claim to be a single string instead of an array.
|
||||
//
|
||||
// See: https://github.com/kubernetes/kubernetes/issues/33290
|
||||
var groups stringOrArray
|
||||
if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil {
|
||||
return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err)
|
||||
if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Groups.Claim, &groups); err != nil {
|
||||
return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.jwtAuthenticator.ClaimMappings.Groups.Claim, err)
|
||||
}
|
||||
info.Groups = []string(groups)
|
||||
}
|
||||
}
|
||||
|
||||
if a.groupsPrefix != "" {
|
||||
if a.jwtAuthenticator.ClaimMappings.Groups.Prefix != nil && *a.jwtAuthenticator.ClaimMappings.Groups.Prefix != "" {
|
||||
for i, group := range info.Groups {
|
||||
info.Groups[i] = a.groupsPrefix + group
|
||||
info.Groups[i] = *a.jwtAuthenticator.ClaimMappings.Groups.Prefix + group
|
||||
}
|
||||
}
|
||||
|
||||
// check to ensure all required claims are present in the ID token and have matching values.
|
||||
for claim, value := range a.requiredClaims {
|
||||
for _, claimValidationRule := range a.jwtAuthenticator.ClaimValidationRules {
|
||||
claim := claimValidationRule.Claim
|
||||
value := claimValidationRule.RequiredValue
|
||||
|
||||
if !c.hasClaim(claim) {
|
||||
return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue