diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 5fc80ad46d0..48919432fd9 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -83,6 +83,10 @@ type APIServer struct { BasicAuthFile string ClientCAFile string TokenAuthFile string + OIDCIssuerURL string + OIDCClientID string + OIDCCAFile string + OIDCUsernameClaim string ServiceAccountKeyFile string ServiceAccountLookup bool KeystoneURL string @@ -190,6 +194,12 @@ func (s *APIServer) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&s.BasicAuthFile, "basic-auth-file", s.BasicAuthFile, "If set, the file that will be used to admit requests to the secure port of the API server via http basic authentication.") fs.StringVar(&s.ClientCAFile, "client-ca-file", s.ClientCAFile, "If set, any request presenting a client certificate signed by one of the authorities in the client-ca-file is authenticated with an identity corresponding to the CommonName of the client certificate.") fs.StringVar(&s.TokenAuthFile, "token-auth-file", s.TokenAuthFile, "If set, the file that will be used to secure the secure port of the API server via token authentication.") + fs.StringVar(&s.OIDCIssuerURL, "oidc-issuer-url", s.OIDCIssuerURL, "The URL of the OpenID issuer. If set, it will be used to verify the OIDC JSON Web Token (JWT)") + fs.StringVar(&s.OIDCClientID, "oidc-client-id", s.OIDCClientID, "The client ID for the OpenID Connect client, must be set if oidc-issuer-url is set") + fs.StringVar(&s.OIDCCAFile, "oidc-ca-file", s.OIDCCAFile, "If set, the OpenID server's certificate will be verified by one of the authorities in the oidc-ca-file, otherwise the host's root CA set will be used") + fs.StringVar(&s.OIDCUsernameClaim, "oidc-username-claim", "sub", ""+ + "The OpenID claim to use as the user name. Note that claims other than the default ('sub') is not "+ + "guaranteed to be unique and immutable. This flag is experimental, please see the authentication documentation for further details.") fs.StringVar(&s.ServiceAccountKeyFile, "service-account-key-file", s.ServiceAccountKeyFile, "File containing PEM-encoded x509 RSA private or public key, used to verify ServiceAccount tokens. If unspecified, --tls-private-key-file is used.") fs.BoolVar(&s.ServiceAccountLookup, "service-account-lookup", s.ServiceAccountLookup, "If true, validate ServiceAccount tokens exist in etcd as part of authentication.") fs.StringVar(&s.KeystoneURL, "experimental-keystone-url", s.KeystoneURL, "If passed, activates the keystone authentication plugin") @@ -350,7 +360,20 @@ func (s *APIServer) Run(_ []string) error { glog.Warning("no RSA key provided, service account token authentication disabled") } } - authenticator, err := apiserver.NewAuthenticator(s.BasicAuthFile, s.ClientCAFile, s.TokenAuthFile, s.ServiceAccountKeyFile, s.ServiceAccountLookup, etcdStorage, s.KeystoneURL) + authenticator, err := apiserver.NewAuthenticator(apiserver.AuthenticatorConfig{ + BasicAuthFile: s.BasicAuthFile, + ClientCAFile: s.ClientCAFile, + TokenAuthFile: s.TokenAuthFile, + OIDCIssuerURL: s.OIDCIssuerURL, + OIDCClientID: s.OIDCClientID, + OIDCCAFile: s.OIDCCAFile, + OIDCUsernameClaim: s.OIDCUsernameClaim, + ServiceAccountKeyFile: s.ServiceAccountKeyFile, + ServiceAccountLookup: s.ServiceAccountLookup, + Storage: etcdStorage, + KeystoneURL: s.KeystoneURL, + }) + if err != nil { glog.Fatalf("Invalid Authentication Config: %v", err) } diff --git a/hack/verify-flags/known-flags.txt b/hack/verify-flags/known-flags.txt index 46c3fcbdae8..8a400940a93 100644 --- a/hack/verify-flags/known-flags.txt +++ b/hack/verify-flags/known-flags.txt @@ -169,6 +169,10 @@ node-status-update-frequency node-sync-period no-headers num-nodes +oidc-ca-file +oidc-client-id +oidc-issuer-url +oidc-username-claim oom-score-adj output-version out-version diff --git a/pkg/apiserver/authn.go b/pkg/apiserver/authn.go index fde9c5b8bb8..a586ba9ca08 100644 --- a/pkg/apiserver/authn.go +++ b/pkg/apiserver/authn.go @@ -29,47 +29,70 @@ import ( "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/keystone" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/union" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/request/x509" + "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/oidc" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/tokenfile" ) +type AuthenticatorConfig struct { + BasicAuthFile string + ClientCAFile string + TokenAuthFile string + OIDCIssuerURL string + OIDCClientID string + OIDCCAFile string + OIDCUsernameClaim string + ServiceAccountKeyFile string + ServiceAccountLookup bool + Storage storage.Interface + KeystoneURL string +} + // NewAuthenticator returns an authenticator.Request or an error -func NewAuthenticator(basicAuthFile, clientCAFile, tokenFile, serviceAccountKeyFile string, serviceAccountLookup bool, storage storage.Interface, keystoneURL string) (authenticator.Request, error) { +func NewAuthenticator(config AuthenticatorConfig) (authenticator.Request, error) { var authenticators []authenticator.Request - if len(basicAuthFile) > 0 { - basicAuth, err := newAuthenticatorFromBasicAuthFile(basicAuthFile) + if len(config.BasicAuthFile) > 0 { + basicAuth, err := newAuthenticatorFromBasicAuthFile(config.BasicAuthFile) if err != nil { return nil, err } authenticators = append(authenticators, basicAuth) } - if len(clientCAFile) > 0 { - certAuth, err := newAuthenticatorFromClientCAFile(clientCAFile) + if len(config.ClientCAFile) > 0 { + certAuth, err := newAuthenticatorFromClientCAFile(config.ClientCAFile) if err != nil { return nil, err } authenticators = append(authenticators, certAuth) } - if len(tokenFile) > 0 { - tokenAuth, err := newAuthenticatorFromTokenFile(tokenFile) + if len(config.TokenAuthFile) > 0 { + tokenAuth, err := newAuthenticatorFromTokenFile(config.TokenAuthFile) if err != nil { return nil, err } authenticators = append(authenticators, tokenAuth) } - if len(serviceAccountKeyFile) > 0 { - serviceAccountAuth, err := newServiceAccountAuthenticator(serviceAccountKeyFile, serviceAccountLookup, storage) + if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 { + oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(config.OIDCIssuerURL, config.OIDCClientID, config.OIDCCAFile, config.OIDCUsernameClaim) + if err != nil { + return nil, err + } + authenticators = append(authenticators, oidcAuth) + } + + if len(config.ServiceAccountKeyFile) > 0 { + serviceAccountAuth, err := newServiceAccountAuthenticator(config.ServiceAccountKeyFile, config.ServiceAccountLookup, config.Storage) if err != nil { return nil, err } authenticators = append(authenticators, serviceAccountAuth) } - if len(keystoneURL) > 0 { - keystoneAuth, err := newAuthenticatorFromKeystoneURL(keystoneURL) + if len(config.KeystoneURL) > 0 { + keystoneAuth, err := newAuthenticatorFromKeystoneURL(config.KeystoneURL) if err != nil { return nil, err } @@ -112,6 +135,16 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Request, return bearertoken.New(tokenAuthenticator), nil } +// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Request or an error. +func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim string) (authenticator.Request, error) { + tokenAuthenticator, err := oidc.New(issuerURL, clientID, caFile, usernameClaim) + if err != nil { + return nil, err + } + + return bearertoken.New(tokenAuthenticator), nil +} + // newServiceAccountAuthenticator returns an authenticator.Request or an error func newServiceAccountAuthenticator(keyfile string, lookup bool, storage storage.Interface) (authenticator.Request, error) { publicKey, err := serviceaccount.ReadPublicKey(keyfile) diff --git a/plugin/pkg/auth/authenticator/token/oidc/oidc.go b/plugin/pkg/auth/authenticator/token/oidc/oidc.go new file mode 100644 index 00000000000..6f9f7e226dd --- /dev/null +++ b/plugin/pkg/auth/authenticator/token/oidc/oidc.go @@ -0,0 +1,165 @@ +/* +Copyright 2015 The Kubernetes Authors All rights reserved. + +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. +*/ + +// oidc implements the authenticator.Token interface using the OpenID Connect protocol. +package oidc + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/http" + "net/url" + "time" + + "github.com/coreos/go-oidc/jose" + "github.com/coreos/go-oidc/oidc" + "github.com/golang/glog" + "k8s.io/kubernetes/pkg/auth/user" + "k8s.io/kubernetes/pkg/util" +) + +var ( + maxRetries = 5 + retryBackoff = time.Second * 3 +) + +type OIDCAuthenticator struct { + clientConfig oidc.ClientConfig + client *oidc.Client + usernameClaim string +} + +// New creates a new OpenID Connect client with the given issuerURL and clientID. +// NOTE(yifan): For now we assume the server provides the "jwks_uri" so we don't +// need to manager the key sets by ourselves. +func New(issuerURL, clientID, caFile, usernameClaim string) (*OIDCAuthenticator, error) { + var cfg oidc.ProviderConfig + var err error + var roots *x509.CertPool + + url, err := url.Parse(issuerURL) + if err != nil { + return nil, err + } + + if url.Scheme != "https" { + return nil, fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", issuerURL, url.Scheme) + } + + if caFile != "" { + roots, err = util.CertPoolFromFile(caFile) + if err != nil { + glog.Errorf("Failed to read the CA file: %v", err) + } + } + if roots == nil { + glog.Info("No x509 certificates provided, will use host's root CA set") + } + + // Copied from http.DefaultTransport. + tr := &http.Transport{ + // According to golang's doc, if RootCAs is nil, + // TLS uses the host's root CA set. + TLSClientConfig: &tls.Config{RootCAs: roots}, + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + } + + hc := &http.Client{} + hc.Transport = tr + + for i := 0; i <= maxRetries; i++ { + if i == maxRetries { + return nil, fmt.Errorf("failed to fetch provider config after %v retries", maxRetries) + } + + cfg, err = oidc.FetchProviderConfig(hc, issuerURL) + if err == nil { + break + } + glog.Errorf("Failed to fetch provider config, trying again in %v: %v", retryBackoff, err) + time.Sleep(retryBackoff) + } + + glog.Infof("Fetched provider config from %s: %#v", issuerURL, cfg) + + if cfg.KeysEndpoint == "" { + return nil, fmt.Errorf("OIDC provider must provide 'jwks_uri' for public key discovery") + } + + ccfg := oidc.ClientConfig{ + HTTPClient: hc, + Credentials: oidc.ClientCredentials{ID: clientID}, + ProviderConfig: cfg, + } + + client, err := oidc.NewClient(ccfg) + if err != nil { + return nil, err + } + + // SyncProviderConfig will start a goroutine to periodically synchronize the provider config. + // The synchronization interval is set by the expiration length of the config, and has a mininum + // and maximum threshold. + client.SyncProviderConfig(issuerURL) + + return &OIDCAuthenticator{ccfg, client, usernameClaim}, nil +} + +// AuthenticateToken decodes and verifies a JWT using the OIDC client, if the verification succeeds, +// then it will extract the user info from the JWT claims. +func (a *OIDCAuthenticator) AuthenticateToken(value string) (user.Info, bool, error) { + jwt, err := jose.ParseJWT(value) + if err != nil { + return nil, false, err + } + + if err := a.client.VerifyJWT(jwt); err != nil { + return nil, false, err + } + + claims, err := jwt.Claims() + if err != nil { + return nil, false, err + } + + claim, ok, err := claims.StringClaim(a.usernameClaim) + if err != nil { + return nil, false, err + } + if !ok { + return nil, false, fmt.Errorf("cannot find %q in JWT claims", a.usernameClaim) + } + + var username string + switch a.usernameClaim { + case "email": + // TODO(yifan): Check 'email_verified' to make sure the email is valid. + username = claim + default: + // For all other cases, use issuerURL + claim as the user name. + username = fmt.Sprintf("%s#%s", a.clientConfig.ProviderConfig.Issuer, claim) + } + + // TODO(yifan): Add UID and Group, also populate the issuer to upper layer. + return &user.DefaultInfo{Name: username}, true, nil +}