mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-08-05 18:24:07 +00:00
wiring existing oidc flags with internal API struct
Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
This commit is contained in:
parent
f909eb630c
commit
1bad3cbbf5
@ -19,11 +19,11 @@ package authenticator
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
utilnet "k8s.io/apimachinery/pkg/util/net"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/apiserver/pkg/apis/apiserver"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticatorfactory"
|
||||
"k8s.io/apiserver/pkg/authentication/group"
|
||||
@ -55,15 +55,8 @@ type Config struct {
|
||||
BootstrapToken bool
|
||||
|
||||
TokenAuthFile string
|
||||
OIDCIssuerURL string
|
||||
OIDCClientID string
|
||||
OIDCCAFile string
|
||||
OIDCUsernameClaim string
|
||||
OIDCUsernamePrefix string
|
||||
OIDCGroupsClaim string
|
||||
OIDCGroupsPrefix string
|
||||
AuthenticationConfig *apiserver.AuthenticationConfiguration
|
||||
OIDCSigningAlgs []string
|
||||
OIDCRequiredClaims map[string]string
|
||||
ServiceAccountKeyFiles []string
|
||||
ServiceAccountLookup bool
|
||||
ServiceAccountIssuers []string
|
||||
@ -153,33 +146,28 @@ func (config Config) New() (authenticator.Request, *spec.SecurityDefinitions, er
|
||||
// cache misses for all requests using the other. While the service account plugin
|
||||
// simply returns an error, the OpenID Connect plugin may query the provider to
|
||||
// update the keys, causing performance hits.
|
||||
if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 {
|
||||
// TODO(enj): wire up the Notifier and ControllerRunner bits when OIDC supports CA reload
|
||||
var oidcCAContent oidc.CAContentProvider
|
||||
if len(config.OIDCCAFile) != 0 {
|
||||
var oidcCAErr error
|
||||
oidcCAContent, oidcCAErr = staticCAContentProviderFromFile("oidc-authenticator", config.OIDCCAFile)
|
||||
if oidcCAErr != nil {
|
||||
return nil, nil, oidcCAErr
|
||||
if config.AuthenticationConfig != nil {
|
||||
for _, jwtAuthenticator := range config.AuthenticationConfig.JWT {
|
||||
var oidcCAContent oidc.CAContentProvider
|
||||
if len(jwtAuthenticator.Issuer.CertificateAuthority) > 0 {
|
||||
var oidcCAError error
|
||||
oidcCAContent, oidcCAError = dynamiccertificates.NewStaticCAContent("oidc-authenticator", []byte(jwtAuthenticator.Issuer.CertificateAuthority))
|
||||
if oidcCAError != nil {
|
||||
return nil, nil, oidcCAError
|
||||
}
|
||||
}
|
||||
oidcAuth, err := oidc.New(oidc.Options{
|
||||
JWTAuthenticator: jwtAuthenticator,
|
||||
CAContentProvider: oidcCAContent,
|
||||
SupportedSigningAlgs: config.OIDCSigningAlgs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tokenAuthenticators = append(tokenAuthenticators, authenticator.WrapAudienceAgnosticToken(config.APIAudiences, oidcAuth))
|
||||
}
|
||||
|
||||
oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(oidc.Options{
|
||||
IssuerURL: config.OIDCIssuerURL,
|
||||
ClientID: config.OIDCClientID,
|
||||
CAContentProvider: oidcCAContent,
|
||||
UsernameClaim: config.OIDCUsernameClaim,
|
||||
UsernamePrefix: config.OIDCUsernamePrefix,
|
||||
GroupsClaim: config.OIDCGroupsClaim,
|
||||
GroupsPrefix: config.OIDCGroupsPrefix,
|
||||
SupportedSigningAlgs: config.OIDCSigningAlgs,
|
||||
RequiredClaims: config.OIDCRequiredClaims,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tokenAuthenticators = append(tokenAuthenticators, authenticator.WrapAudienceAgnosticToken(config.APIAudiences, oidcAuth))
|
||||
}
|
||||
|
||||
if len(config.WebhookTokenAuthnConfigFile) > 0 {
|
||||
webhookTokenAuth, err := newWebhookTokenAuthenticator(config)
|
||||
if err != nil {
|
||||
@ -243,31 +231,6 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e
|
||||
return tokenAuthenticator, nil
|
||||
}
|
||||
|
||||
// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Token or an error.
|
||||
func newAuthenticatorFromOIDCIssuerURL(opts oidc.Options) (authenticator.Token, error) {
|
||||
const noUsernamePrefix = "-"
|
||||
|
||||
if opts.UsernamePrefix == "" && opts.UsernameClaim != "email" {
|
||||
// Old behavior. If a usernamePrefix isn't provided, prefix all claims other than "email"
|
||||
// with the issuerURL.
|
||||
//
|
||||
// See https://github.com/kubernetes/kubernetes/issues/31380
|
||||
opts.UsernamePrefix = opts.IssuerURL + "#"
|
||||
}
|
||||
|
||||
if opts.UsernamePrefix == noUsernamePrefix {
|
||||
// Special value indicating usernames shouldn't be prefixed.
|
||||
opts.UsernamePrefix = ""
|
||||
}
|
||||
|
||||
tokenAuthenticator, err := oidc.New(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenAuthenticator, nil
|
||||
}
|
||||
|
||||
// newLegacyServiceAccountAuthenticator returns an authenticator.Token or an error
|
||||
func newLegacyServiceAccountAuthenticator(keyfiles []string, lookup bool, apiAudiences authenticator.Audiences, serviceAccountGetter serviceaccount.ServiceAccountTokenGetter, secretsWriter typedv1core.SecretsGetter) (authenticator.Token, error) {
|
||||
allPublicKeys := []interface{}{}
|
||||
@ -318,12 +281,3 @@ func newWebhookTokenAuthenticator(config Config) (authenticator.Token, error) {
|
||||
|
||||
return tokencache.New(webhookTokenAuthenticator, false, config.WebhookTokenAuthnCacheTTL, config.WebhookTokenAuthnCacheTTL), nil
|
||||
}
|
||||
|
||||
func staticCAContentProviderFromFile(purpose, filename string) (dynamiccertificates.CAContentProvider, error) {
|
||||
fileBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dynamiccertificates.NewStaticCAContent(purpose, fileBytes)
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -28,6 +29,8 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"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"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/apiserver/pkg/server/egressselector"
|
||||
@ -41,6 +44,7 @@ import (
|
||||
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
|
||||
authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes"
|
||||
"k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap"
|
||||
"k8s.io/utils/pointer"
|
||||
)
|
||||
|
||||
// BuiltInAuthenticationOptions contains all build-in authentication options for API Server
|
||||
@ -397,16 +401,68 @@ func (o *BuiltInAuthenticationOptions) ToAuthenticationConfig() (kubeauthenticat
|
||||
}
|
||||
}
|
||||
|
||||
if o.OIDC != nil {
|
||||
ret.OIDCCAFile = o.OIDC.CAFile
|
||||
ret.OIDCClientID = o.OIDC.ClientID
|
||||
ret.OIDCGroupsClaim = o.OIDC.GroupsClaim
|
||||
ret.OIDCGroupsPrefix = o.OIDC.GroupsPrefix
|
||||
ret.OIDCIssuerURL = o.OIDC.IssuerURL
|
||||
ret.OIDCUsernameClaim = o.OIDC.UsernameClaim
|
||||
ret.OIDCUsernamePrefix = o.OIDC.UsernamePrefix
|
||||
if o.OIDC != nil && len(o.OIDC.IssuerURL) > 0 && len(o.OIDC.ClientID) > 0 {
|
||||
usernamePrefix := o.OIDC.UsernamePrefix
|
||||
|
||||
if o.OIDC.UsernamePrefix == "" && o.OIDC.UsernameClaim != "email" {
|
||||
// Legacy CLI flag behavior. If a usernamePrefix isn't provided, prefix all claims other than "email"
|
||||
// with the issuerURL.
|
||||
//
|
||||
// See https://github.com/kubernetes/kubernetes/issues/31380
|
||||
usernamePrefix = o.OIDC.IssuerURL + "#"
|
||||
}
|
||||
if o.OIDC.UsernamePrefix == "-" {
|
||||
// Special value indicating usernames shouldn't be prefixed.
|
||||
usernamePrefix = ""
|
||||
}
|
||||
|
||||
jwtAuthenticator := apiserver.JWTAuthenticator{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: o.OIDC.IssuerURL,
|
||||
Audiences: []string{o.OIDC.ClientID},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Prefix: pointer.String(usernamePrefix),
|
||||
Claim: o.OIDC.UsernameClaim,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if len(o.OIDC.GroupsClaim) > 0 {
|
||||
jwtAuthenticator.ClaimMappings.Groups = apiserver.PrefixedClaimOrExpression{
|
||||
Prefix: pointer.String(o.OIDC.GroupsPrefix),
|
||||
Claim: o.OIDC.GroupsClaim,
|
||||
}
|
||||
}
|
||||
|
||||
if len(o.OIDC.CAFile) != 0 {
|
||||
caContent, err := os.ReadFile(o.OIDC.CAFile)
|
||||
if err != nil {
|
||||
return kubeauthenticator.Config{}, err
|
||||
}
|
||||
jwtAuthenticator.Issuer.CertificateAuthority = string(caContent)
|
||||
}
|
||||
|
||||
if len(o.OIDC.RequiredClaims) > 0 {
|
||||
claimValidationRules := make([]apiserver.ClaimValidationRule, 0, len(o.OIDC.RequiredClaims))
|
||||
for claim, value := range o.OIDC.RequiredClaims {
|
||||
claimValidationRules = append(claimValidationRules, apiserver.ClaimValidationRule{
|
||||
Claim: claim,
|
||||
RequiredValue: value,
|
||||
})
|
||||
}
|
||||
jwtAuthenticator.ClaimValidationRules = claimValidationRules
|
||||
}
|
||||
|
||||
authConfig := &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{jwtAuthenticator},
|
||||
}
|
||||
if err := apiservervalidation.ValidateAuthenticationConfiguration(authConfig).ToAggregate(); err != nil {
|
||||
return kubeauthenticator.Config{}, err
|
||||
}
|
||||
ret.AuthenticationConfig = authConfig
|
||||
ret.OIDCSigningAlgs = o.OIDC.SigningAlgs
|
||||
ret.OIDCRequiredClaims = o.OIDC.RequiredClaims
|
||||
}
|
||||
|
||||
if o.RequestHeader != nil {
|
||||
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||
package options
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -27,11 +28,13 @@ import (
|
||||
|
||||
utilerrors "k8s.io/apimachinery/pkg/util/errors"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/apiserver/pkg/apis/apiserver"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||
"k8s.io/apiserver/pkg/authentication/authenticatorfactory"
|
||||
"k8s.io/apiserver/pkg/authentication/request/headerrequest"
|
||||
apiserveroptions "k8s.io/apiserver/pkg/server/options"
|
||||
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
|
||||
"k8s.io/utils/pointer"
|
||||
)
|
||||
|
||||
func TestAuthenticationValidate(t *testing.T) {
|
||||
@ -50,7 +53,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -63,7 +66,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
Issuers: []string{"http://foo.bar.com"},
|
||||
@ -76,7 +79,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -89,7 +92,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -102,7 +105,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -115,7 +118,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -128,7 +131,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -141,7 +144,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -156,7 +159,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -171,7 +174,7 @@ func TestAuthenticationValidate(t *testing.T) {
|
||||
testOIDC: &OIDCAuthenticationOptions{
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
testSA: &ServiceAccountAuthenticationOptions{
|
||||
@ -228,10 +231,10 @@ func TestToAuthenticationConfig(t *testing.T) {
|
||||
Enable: false,
|
||||
},
|
||||
OIDC: &OIDCAuthenticationOptions{
|
||||
CAFile: "/testCAFile",
|
||||
CAFile: "testdata/root.pem",
|
||||
UsernameClaim: "sub",
|
||||
SigningAlgs: []string{"RS256"},
|
||||
IssuerURL: "testIssuerURL",
|
||||
IssuerURL: "https://testIssuerURL",
|
||||
ClientID: "testClientID",
|
||||
},
|
||||
RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{
|
||||
@ -253,15 +256,27 @@ func TestToAuthenticationConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
expectConfig := kubeauthenticator.Config{
|
||||
APIAudiences: authenticator.Audiences{"http://foo.bar.com"},
|
||||
Anonymous: false,
|
||||
BootstrapToken: false,
|
||||
ClientCAContentProvider: nil, // this is nil because you can't compare functions
|
||||
TokenAuthFile: "/testTokenFile",
|
||||
OIDCIssuerURL: "testIssuerURL",
|
||||
OIDCClientID: "testClientID",
|
||||
OIDCCAFile: "/testCAFile",
|
||||
OIDCUsernameClaim: "sub",
|
||||
APIAudiences: authenticator.Audiences{"http://foo.bar.com"},
|
||||
Anonymous: false,
|
||||
BootstrapToken: false,
|
||||
ClientCAContentProvider: nil, // this is nil because you can't compare functions
|
||||
TokenAuthFile: "/testTokenFile",
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String("https://testIssuerURL#"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
ServiceAccountLookup: true,
|
||||
ServiceAccountIssuers: []string{"http://foo.bar.com"},
|
||||
@ -280,6 +295,12 @@ func TestToAuthenticationConfig(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile("testdata/root.pem")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectConfig.AuthenticationConfig.JWT[0].Issuer.CertificateAuthority = string(fileBytes)
|
||||
|
||||
resultConfig, err := testOptions.ToAuthenticationConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -385,3 +406,221 @@ func TestBuiltInAuthenticationOptionsAddFlags(t *testing.T) {
|
||||
t.Error(cmp.Diff(opts, expected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestToAuthenticationConfig_OIDC(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
expectConfig kubeauthenticator.Config
|
||||
}{
|
||||
{
|
||||
name: "username prefix is '-'",
|
||||
args: []string{
|
||||
"--oidc-issuer-url=https://testIssuerURL",
|
||||
"--oidc-client-id=testClientID",
|
||||
"--oidc-username-claim=sub",
|
||||
"--oidc-username-prefix=-",
|
||||
"--oidc-signing-algs=RS256",
|
||||
"--oidc-required-claim=foo=bar",
|
||||
},
|
||||
expectConfig: kubeauthenticator.Config{
|
||||
TokenSuccessCacheTTL: 10 * time.Second,
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String(""),
|
||||
},
|
||||
},
|
||||
ClaimValidationRules: []apiserver.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--oidc-username-prefix is empty, --oidc-username-claim is not email",
|
||||
args: []string{
|
||||
"--oidc-issuer-url=https://testIssuerURL",
|
||||
"--oidc-client-id=testClientID",
|
||||
"--oidc-username-claim=sub",
|
||||
"--oidc-signing-algs=RS256",
|
||||
"--oidc-required-claim=foo=bar",
|
||||
},
|
||||
expectConfig: kubeauthenticator.Config{
|
||||
TokenSuccessCacheTTL: 10 * time.Second,
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String("https://testIssuerURL#"),
|
||||
},
|
||||
},
|
||||
ClaimValidationRules: []apiserver.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--oidc-username-prefix is empty, --oidc-username-claim is email",
|
||||
args: []string{
|
||||
"--oidc-issuer-url=https://testIssuerURL",
|
||||
"--oidc-client-id=testClientID",
|
||||
"--oidc-username-claim=email",
|
||||
"--oidc-signing-algs=RS256",
|
||||
"--oidc-required-claim=foo=bar",
|
||||
},
|
||||
expectConfig: kubeauthenticator.Config{
|
||||
TokenSuccessCacheTTL: 10 * time.Second,
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "email",
|
||||
Prefix: pointer.String(""),
|
||||
},
|
||||
},
|
||||
ClaimValidationRules: []apiserver.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non empty username prefix",
|
||||
args: []string{
|
||||
"--oidc-issuer-url=https://testIssuerURL",
|
||||
"--oidc-client-id=testClientID",
|
||||
"--oidc-username-claim=sub",
|
||||
"--oidc-username-prefix=k8s-",
|
||||
"--oidc-signing-algs=RS256",
|
||||
"--oidc-required-claim=foo=bar",
|
||||
},
|
||||
expectConfig: kubeauthenticator.Config{
|
||||
TokenSuccessCacheTTL: 10 * time.Second,
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String("k8s-"),
|
||||
},
|
||||
},
|
||||
ClaimValidationRules: []apiserver.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "groups claim exists",
|
||||
args: []string{
|
||||
"--oidc-issuer-url=https://testIssuerURL",
|
||||
"--oidc-client-id=testClientID",
|
||||
"--oidc-username-claim=sub",
|
||||
"--oidc-username-prefix=-",
|
||||
"--oidc-groups-claim=groups",
|
||||
"--oidc-groups-prefix=oidc:",
|
||||
"--oidc-signing-algs=RS256",
|
||||
"--oidc-required-claim=foo=bar",
|
||||
},
|
||||
expectConfig: kubeauthenticator.Config{
|
||||
TokenSuccessCacheTTL: 10 * time.Second,
|
||||
AuthenticationConfig: &apiserver.AuthenticationConfiguration{
|
||||
JWT: []apiserver.JWTAuthenticator{
|
||||
{
|
||||
Issuer: apiserver.Issuer{
|
||||
URL: "https://testIssuerURL",
|
||||
Audiences: []string{"testClientID"},
|
||||
},
|
||||
ClaimMappings: apiserver.ClaimMappings{
|
||||
Username: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "sub",
|
||||
Prefix: pointer.String(""),
|
||||
},
|
||||
Groups: apiserver.PrefixedClaimOrExpression{
|
||||
Claim: "groups",
|
||||
Prefix: pointer.String("oidc:"),
|
||||
},
|
||||
},
|
||||
ClaimValidationRules: []apiserver.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDCSigningAlgs: []string{"RS256"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testcase := range testCases {
|
||||
t.Run(testcase.name, func(t *testing.T) {
|
||||
opts := NewBuiltInAuthenticationOptions().WithOIDC()
|
||||
pf := pflag.NewFlagSet("test-builtin-authentication-opts", pflag.ContinueOnError)
|
||||
opts.AddFlags(pf)
|
||||
|
||||
if err := pf.Parse(testcase.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resultConfig, err := opts.ToAuthenticationConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(resultConfig, testcase.expectConfig) {
|
||||
t.Error(cmp.Diff(resultConfig, testcase.expectConfig))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
1
vendor/modules.txt
vendored
1
vendor/modules.txt
vendored
@ -1455,6 +1455,7 @@ k8s.io/apiserver/pkg/apis/apiserver/install
|
||||
k8s.io/apiserver/pkg/apis/apiserver/v1
|
||||
k8s.io/apiserver/pkg/apis/apiserver/v1alpha1
|
||||
k8s.io/apiserver/pkg/apis/apiserver/v1beta1
|
||||
k8s.io/apiserver/pkg/apis/apiserver/validation
|
||||
k8s.io/apiserver/pkg/apis/audit
|
||||
k8s.io/apiserver/pkg/apis/audit/install
|
||||
k8s.io/apiserver/pkg/apis/audit/v1
|
||||
|
Loading…
Reference in New Issue
Block a user