wiring existing oidc flags with internal API struct

Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
This commit is contained in:
Anish Ramasekar 2023-06-28 06:04:45 +00:00
parent f909eb630c
commit 1bad3cbbf5
No known key found for this signature in database
GPG Key ID: F1F7F3518F1ECB0C
8 changed files with 1626 additions and 373 deletions

View File

@ -19,11 +19,11 @@ package authenticator
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"time" "time"
utilnet "k8s.io/apimachinery/pkg/util/net" utilnet "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/authenticatorfactory" "k8s.io/apiserver/pkg/authentication/authenticatorfactory"
"k8s.io/apiserver/pkg/authentication/group" "k8s.io/apiserver/pkg/authentication/group"
@ -55,15 +55,8 @@ type Config struct {
BootstrapToken bool BootstrapToken bool
TokenAuthFile string TokenAuthFile string
OIDCIssuerURL string AuthenticationConfig *apiserver.AuthenticationConfiguration
OIDCClientID string
OIDCCAFile string
OIDCUsernameClaim string
OIDCUsernamePrefix string
OIDCGroupsClaim string
OIDCGroupsPrefix string
OIDCSigningAlgs []string OIDCSigningAlgs []string
OIDCRequiredClaims map[string]string
ServiceAccountKeyFiles []string ServiceAccountKeyFiles []string
ServiceAccountLookup bool ServiceAccountLookup bool
ServiceAccountIssuers []string 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 // 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 // simply returns an error, the OpenID Connect plugin may query the provider to
// update the keys, causing performance hits. // update the keys, causing performance hits.
if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 { if config.AuthenticationConfig != nil {
// TODO(enj): wire up the Notifier and ControllerRunner bits when OIDC supports CA reload for _, jwtAuthenticator := range config.AuthenticationConfig.JWT {
var oidcCAContent oidc.CAContentProvider var oidcCAContent oidc.CAContentProvider
if len(config.OIDCCAFile) != 0 { if len(jwtAuthenticator.Issuer.CertificateAuthority) > 0 {
var oidcCAErr error var oidcCAError error
oidcCAContent, oidcCAErr = staticCAContentProviderFromFile("oidc-authenticator", config.OIDCCAFile) oidcCAContent, oidcCAError = dynamiccertificates.NewStaticCAContent("oidc-authenticator", []byte(jwtAuthenticator.Issuer.CertificateAuthority))
if oidcCAErr != nil { if oidcCAError != nil {
return nil, nil, oidcCAErr 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 { if len(config.WebhookTokenAuthnConfigFile) > 0 {
webhookTokenAuth, err := newWebhookTokenAuthenticator(config) webhookTokenAuth, err := newWebhookTokenAuthenticator(config)
if err != nil { if err != nil {
@ -243,31 +231,6 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e
return tokenAuthenticator, nil 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 // 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) { func newLegacyServiceAccountAuthenticator(keyfiles []string, lookup bool, apiAudiences authenticator.Audiences, serviceAccountGetter serviceaccount.ServiceAccountTokenGetter, secretsWriter typedv1core.SecretsGetter) (authenticator.Token, error) {
allPublicKeys := []interface{}{} allPublicKeys := []interface{}{}
@ -318,12 +281,3 @@ func newWebhookTokenAuthenticator(config Config) (authenticator.Token, error) {
return tokencache.New(webhookTokenAuthenticator, false, config.WebhookTokenAuthnCacheTTL, config.WebhookTokenAuthnCacheTTL), nil 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)
}

View File

@ -20,6 +20,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"os"
"strings" "strings"
"time" "time"
@ -28,6 +29,8 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/wait" "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/authenticator"
genericapiserver "k8s.io/apiserver/pkg/server" genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/apiserver/pkg/server/egressselector" "k8s.io/apiserver/pkg/server/egressselector"
@ -41,6 +44,7 @@ import (
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes" authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes"
"k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap"
"k8s.io/utils/pointer"
) )
// BuiltInAuthenticationOptions contains all build-in authentication options for API Server // BuiltInAuthenticationOptions contains all build-in authentication options for API Server
@ -397,16 +401,68 @@ func (o *BuiltInAuthenticationOptions) ToAuthenticationConfig() (kubeauthenticat
} }
} }
if o.OIDC != nil { if o.OIDC != nil && len(o.OIDC.IssuerURL) > 0 && len(o.OIDC.ClientID) > 0 {
ret.OIDCCAFile = o.OIDC.CAFile usernamePrefix := o.OIDC.UsernamePrefix
ret.OIDCClientID = o.OIDC.ClientID
ret.OIDCGroupsClaim = o.OIDC.GroupsClaim if o.OIDC.UsernamePrefix == "" && o.OIDC.UsernameClaim != "email" {
ret.OIDCGroupsPrefix = o.OIDC.GroupsPrefix // Legacy CLI flag behavior. If a usernamePrefix isn't provided, prefix all claims other than "email"
ret.OIDCIssuerURL = o.OIDC.IssuerURL // with the issuerURL.
ret.OIDCUsernameClaim = o.OIDC.UsernameClaim //
ret.OIDCUsernamePrefix = o.OIDC.UsernamePrefix // 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.OIDCSigningAlgs = o.OIDC.SigningAlgs
ret.OIDCRequiredClaims = o.OIDC.RequiredClaims
} }
if o.RequestHeader != nil { if o.RequestHeader != nil {

View File

@ -17,6 +17,7 @@ limitations under the License.
package options package options
import ( import (
"os"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
@ -27,11 +28,13 @@ import (
utilerrors "k8s.io/apimachinery/pkg/util/errors" utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apimachinery/pkg/util/wait" "k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/authenticatorfactory" "k8s.io/apiserver/pkg/authentication/authenticatorfactory"
"k8s.io/apiserver/pkg/authentication/request/headerrequest" "k8s.io/apiserver/pkg/authentication/request/headerrequest"
apiserveroptions "k8s.io/apiserver/pkg/server/options" apiserveroptions "k8s.io/apiserver/pkg/server/options"
kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator"
"k8s.io/utils/pointer"
) )
func TestAuthenticationValidate(t *testing.T) { func TestAuthenticationValidate(t *testing.T) {
@ -50,7 +53,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -63,7 +66,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
Issuers: []string{"http://foo.bar.com"}, Issuers: []string{"http://foo.bar.com"},
@ -76,7 +79,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -89,7 +92,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -102,7 +105,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -115,7 +118,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -128,7 +131,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -141,7 +144,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -156,7 +159,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -171,7 +174,7 @@ func TestAuthenticationValidate(t *testing.T) {
testOIDC: &OIDCAuthenticationOptions{ testOIDC: &OIDCAuthenticationOptions{
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
testSA: &ServiceAccountAuthenticationOptions{ testSA: &ServiceAccountAuthenticationOptions{
@ -228,10 +231,10 @@ func TestToAuthenticationConfig(t *testing.T) {
Enable: false, Enable: false,
}, },
OIDC: &OIDCAuthenticationOptions{ OIDC: &OIDCAuthenticationOptions{
CAFile: "/testCAFile", CAFile: "testdata/root.pem",
UsernameClaim: "sub", UsernameClaim: "sub",
SigningAlgs: []string{"RS256"}, SigningAlgs: []string{"RS256"},
IssuerURL: "testIssuerURL", IssuerURL: "https://testIssuerURL",
ClientID: "testClientID", ClientID: "testClientID",
}, },
RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{ RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{
@ -253,15 +256,27 @@ func TestToAuthenticationConfig(t *testing.T) {
} }
expectConfig := kubeauthenticator.Config{ expectConfig := kubeauthenticator.Config{
APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, APIAudiences: authenticator.Audiences{"http://foo.bar.com"},
Anonymous: false, Anonymous: false,
BootstrapToken: false, BootstrapToken: false,
ClientCAContentProvider: nil, // this is nil because you can't compare functions ClientCAContentProvider: nil, // this is nil because you can't compare functions
TokenAuthFile: "/testTokenFile", TokenAuthFile: "/testTokenFile",
OIDCIssuerURL: "testIssuerURL", AuthenticationConfig: &apiserver.AuthenticationConfiguration{
OIDCClientID: "testClientID", JWT: []apiserver.JWTAuthenticator{
OIDCCAFile: "/testCAFile", {
OIDCUsernameClaim: "sub", 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"}, OIDCSigningAlgs: []string{"RS256"},
ServiceAccountLookup: true, ServiceAccountLookup: true,
ServiceAccountIssuers: []string{"http://foo.bar.com"}, 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() resultConfig, err := testOptions.ToAuthenticationConfig()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@ -385,3 +406,221 @@ func TestBuiltInAuthenticationOptionsAddFlags(t *testing.T) {
t.Error(cmp.Diff(opts, expected)) 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))
}
})
}
}

View File

@ -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
}

View File

@ -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 ""
}

View File

@ -32,11 +32,9 @@ import (
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -46,6 +44,8 @@ import (
"k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/wait" "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/authenticator"
"k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authentication/user"
certutil "k8s.io/client-go/util/cert" certutil "k8s.io/client-go/util/cert"
@ -59,50 +59,17 @@ var (
) )
type Options struct { type Options struct {
// IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss" // JWTAuthenticator is the authenticator that will be used to verify the JWT.
// field of all tokens produced by the provider and is used for configuration JWTAuthenticator apiserver.JWTAuthenticator
// 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
// Optional KeySet to allow for synchronous initialization instead of fetching from the remote issuer. // Optional KeySet to allow for synchronous initialization instead of fetching from the remote issuer.
KeySet oidc.KeySet 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. // PEM encoded root certificate contents of the provider. Mutually exclusive with Client.
CAContentProvider CAContentProvider CAContentProvider CAContentProvider
// Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider. // Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider.
Client *http.Client 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 // SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that
// can be used by the provider to sign tokens. // 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 // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
SupportedSigningAlgs []string 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 is used for testing. It defaults to time.Now.
now func() time.Time now func() time.Time
} }
@ -192,13 +155,7 @@ func (a *asyncIDTokenVerifier) verifier() *oidc.IDTokenVerifier {
} }
type Authenticator struct { type Authenticator struct {
issuerURL string jwtAuthenticator apiserver.JWTAuthenticator
usernameClaim string
usernamePrefix string
groupsClaim string
groupsPrefix string
requiredClaims map[string]string
// Contains an *oidc.IDTokenVerifier. Do not access directly use the // Contains an *oidc.IDTokenVerifier. Do not access directly use the
// idTokenVerifier method. // idTokenVerifier method.
@ -240,19 +197,10 @@ var allowedSigningAlgs = map[string]bool{
} }
func New(opts Options) (*Authenticator, error) { func New(opts Options) (*Authenticator, error) {
url, err := url.Parse(opts.IssuerURL) if err := apiservervalidation.ValidateJWTAuthenticator(opts.JWTAuthenticator).ToAggregate(); err != nil {
if err != nil {
return nil, err 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 supportedSigningAlgs := opts.SupportedSigningAlgs
if len(supportedSigningAlgs) == 0 { if len(supportedSigningAlgs) == 0 {
// RS256 is the default recommended by OpenID Connect and an 'alg' value // 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 { if client == nil {
var roots *x509.CertPool var roots *x509.CertPool
var err error
if opts.CAContentProvider != nil { if opts.CAContentProvider != nil {
// TODO(enj): make this reload CA data dynamically // TODO(enj): make this reload CA data dynamically
roots, err = certutil.NewPoolFromBytes(opts.CAContentProvider.CurrentCABundleContent()) roots, err = certutil.NewPoolFromBytes(opts.CAContentProvider.CurrentCABundleContent())
@ -302,35 +251,30 @@ func New(opts Options) (*Authenticator, error) {
} }
verifierConfig := &oidc.Config{ verifierConfig := &oidc.Config{
ClientID: opts.ClientID, ClientID: opts.JWTAuthenticator.Issuer.Audiences[0],
SupportedSigningAlgs: supportedSigningAlgs, SupportedSigningAlgs: supportedSigningAlgs,
Now: now, Now: now,
} }
var resolver *claimResolver var resolver *claimResolver
if opts.GroupsClaim != "" { if opts.JWTAuthenticator.ClaimMappings.Groups.Claim != "" {
resolver = newClaimResolver(opts.GroupsClaim, client, verifierConfig) resolver = newClaimResolver(opts.JWTAuthenticator.ClaimMappings.Groups.Claim, client, verifierConfig)
} }
authenticator := &Authenticator{ authenticator := &Authenticator{
issuerURL: opts.IssuerURL, jwtAuthenticator: opts.JWTAuthenticator,
usernameClaim: opts.UsernameClaim, cancel: cancel,
usernamePrefix: opts.UsernamePrefix, resolver: resolver,
groupsClaim: opts.GroupsClaim,
groupsPrefix: opts.GroupsPrefix,
requiredClaims: opts.RequiredClaims,
cancel: cancel,
resolver: resolver,
} }
if opts.KeySet != nil { if opts.KeySet != nil {
// We already have a key set, synchronously initialize the verifier. // 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 { } else {
// Asynchronously attempt to initialize the authenticator. This enables // Asynchronously attempt to initialize the authenticator. This enables
// self-hosted providers, providers that run on top of Kubernetes itself. // self-hosted providers, providers that run on top of Kubernetes itself.
go wait.PollImmediateUntil(10*time.Second, func() (done bool, err error) { 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 { if err != nil {
klog.Errorf("oidc authenticator: initializing plugin: %v", err) klog.Errorf("oidc authenticator: initializing plugin: %v", err)
return false, nil 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) { 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 return nil, false, nil
} }
@ -577,11 +521,11 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
} }
var username string var username string
if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil { if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Username.Claim, &username); err != nil {
return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err) 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. // If the email_verified claim is present, ensure the email is valid.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified { if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified {
@ -597,33 +541,36 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a
} }
} }
if a.usernamePrefix != "" { if a.jwtAuthenticator.ClaimMappings.Username.Prefix != nil && *a.jwtAuthenticator.ClaimMappings.Username.Prefix != "" {
username = a.usernamePrefix + username username = *a.jwtAuthenticator.ClaimMappings.Username.Prefix + username
} }
info := &user.DefaultInfo{Name: username} info := &user.DefaultInfo{Name: username}
if a.groupsClaim != "" { if a.jwtAuthenticator.ClaimMappings.Groups.Claim != "" {
if _, ok := c[a.groupsClaim]; ok { if _, ok := c[a.jwtAuthenticator.ClaimMappings.Groups.Claim]; ok {
// Some admins want to use string claims like "role" as the group value. // 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. // Allow the group claim to be a single string instead of an array.
// //
// See: https://github.com/kubernetes/kubernetes/issues/33290 // See: https://github.com/kubernetes/kubernetes/issues/33290
var groups stringOrArray var groups stringOrArray
if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil { if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Groups.Claim, &groups); err != nil {
return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err) return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.jwtAuthenticator.ClaimMappings.Groups.Claim, err)
} }
info.Groups = []string(groups) 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 { 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. // 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) { if !c.hasClaim(claim) {
return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim) return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim)
} }

1
vendor/modules.txt vendored
View File

@ -1455,6 +1455,7 @@ k8s.io/apiserver/pkg/apis/apiserver/install
k8s.io/apiserver/pkg/apis/apiserver/v1 k8s.io/apiserver/pkg/apis/apiserver/v1
k8s.io/apiserver/pkg/apis/apiserver/v1alpha1 k8s.io/apiserver/pkg/apis/apiserver/v1alpha1
k8s.io/apiserver/pkg/apis/apiserver/v1beta1 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
k8s.io/apiserver/pkg/apis/audit/install k8s.io/apiserver/pkg/apis/audit/install
k8s.io/apiserver/pkg/apis/audit/v1 k8s.io/apiserver/pkg/apis/audit/v1