Skip to content

Commit

Permalink
Provide OIDC discovery endpoints
Browse files Browse the repository at this point in the history
- Add handlers for service account issuer metadata.
- Add option to manually override JWKS URI.
- Add unit and integration tests.
- Add a separate ServiceAccountIssuerDiscovery feature gate.

Additional notes:
- If not explicitly overridden, the JWKS URI will be based on
  the API server's external address and port.

- The metadata server is configured with the validating key set rather
than the signing key set. This allows for key rotation because tokens
can still be validated by the keys exposed in the JWKs URL, even if the
signing key has been rotated (note this may still be a short window if
tokens have short lifetimes).

- The trust model of OIDC discovery requires that the relying party
fetch the issuer metadata via HTTPS; the trust of the issuer metadata
comes from the server presenting a TLS certificate with a trust chain
back to the from the relying party's root(s) of trust. For tests, we use
a local issuer (https://kubernetes.default.svc) for the certificate
so that workloads within the cluster can authenticate it when fetching
OIDC metadata. An API server cannot validly claim https://kubernetes.io,
but within the cluster, it is the authority for kubernetes.default.svc,
according to the in-cluster config.

Co-authored-by: Michael Taufen <[email protected]>
  • Loading branch information
Charles Eckman and mtaufen committed Feb 12, 2020
1 parent 7a506ff commit 5a176ac
Show file tree
Hide file tree
Showing 15 changed files with 1,090 additions and 5 deletions.
16 changes: 16 additions & 0 deletions cmd/kube-apiserver/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,22 @@ func CreateKubeAPIServerConfig(
config.ExtraConfig.KubeletClientConfig.Lookup = config.GenericConfig.EgressSelector.Lookup
}

if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
// Load the public keys.
var pubKeys []interface{}
for _, f := range s.Authentication.ServiceAccounts.KeyFiles {
keys, err := keyutil.PublicKeysFromFile(f)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("failed to parse key file %q: %v", f, err)
}
pubKeys = append(pubKeys, keys...)
}
// Plumb the required metadata through ExtraConfig.
config.ExtraConfig.ServiceAccountIssuerURL = s.Authentication.ServiceAccounts.Issuer
config.ExtraConfig.ServiceAccountJWKSURI = s.Authentication.ServiceAccounts.JWKSURI
config.ExtraConfig.ServiceAccountPublicKeys = pubKeys
}

return config, insecureServingInfo, serviceResolver, pluginInitializers, nil
}

Expand Down
10 changes: 10 additions & 0 deletions pkg/features/kube_features.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,15 @@ const (
// to the API server.
BoundServiceAccountTokenVolume featuregate.Feature = "BoundServiceAccountTokenVolume"

// owner: @mtaufen
// alpha: v1.18
//
// Enable OIDC discovery endpoints (issuer and JWKS URLs) for the service
// account issuer in the API server.
// Note these endpoints serve minimally-compliant discovery docs that are
// intended to be used for service account token verification.
ServiceAccountIssuerDiscovery featuregate.Feature = "ServiceAccountIssuerDiscovery"

// owner: @Random-Liu
// beta: v1.11
//
Expand Down Expand Up @@ -573,6 +582,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
TokenRequest: {Default: true, PreRelease: featuregate.Beta},
TokenRequestProjection: {Default: true, PreRelease: featuregate.Beta},
BoundServiceAccountTokenVolume: {Default: false, PreRelease: featuregate.Alpha},
ServiceAccountIssuerDiscovery: {Default: false, PreRelease: featuregate.Alpha},
CRIContainerLogRotation: {Default: true, PreRelease: featuregate.Beta},
CSIMigration: {Default: true, PreRelease: featuregate.Beta},
CSIMigrationGCE: {Default: false, PreRelease: featuregate.Beta}, // Off by default (requires GCE PD CSI Driver)
Expand Down
32 changes: 31 additions & 1 deletion pkg/kubeapiserver/options/authentication.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ type ServiceAccountAuthenticationOptions struct {
KeyFiles []string
Lookup bool
Issuer string
JWKSURI string
MaxExpiration time.Duration
}

Expand Down Expand Up @@ -188,6 +189,22 @@ func (s *BuiltInAuthenticationOptions) Validate() []error {
}
}

if s.ServiceAccounts != nil {
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
// Validate the JWKS URI when it is explicitly set.
// When unset, it is later derived from ExternalHost.
if s.ServiceAccounts.JWKSURI != "" {
if u, err := url.Parse(s.ServiceAccounts.JWKSURI); err != nil {
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri must be a valid URL: %v", err))
} else if u.Scheme != "https" {
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri requires https scheme, parsed as: %v", u.String()))
}
}
} else if len(s.ServiceAccounts.JWKSURI) > 0 {
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri may only be set when the ServiceAccountIssuerDiscovery feature gate is enabled"))
}
}

return allErrors
}

Expand Down Expand Up @@ -281,7 +298,20 @@ func (s *BuiltInAuthenticationOptions) AddFlags(fs *pflag.FlagSet) {

fs.StringVar(&s.ServiceAccounts.Issuer, "service-account-issuer", s.ServiceAccounts.Issuer, ""+
"Identifier of the service account token issuer. The issuer will assert this identifier "+
"in \"iss\" claim of issued tokens. This value is a string or URI.")
"in \"iss\" claim of issued tokens. This value is a string or URI. If this option is not "+
"a valid URI per the OpenID Discovery 1.0 spec, the ServiceAccountIssuerDiscovery feature "+
"will remain disabled, even if the feature gate is set to true. It is highly recommended "+
"that this value comply with the OpenID spec: https://openid.net/specs/openid-connect-discovery-1_0.html. "+
"In practice, this means that service-account-issuer must be an https URL. It is also highly "+
"recommended that this URL be capable of serving OpenID discovery documents at "+
"`{service-account-issuer}/.well-known/openid-configuration`.")

fs.StringVar(&s.ServiceAccounts.JWKSURI, "service-account-jwks-uri", s.ServiceAccounts.JWKSURI, ""+
"Overrides the URI for the JSON Web Key Set in the discovery doc served at "+
"/.well-known/openid-configuration. This flag is useful if the discovery doc"+
"and key set are served to relying parties from a URL other than the "+
"API server's external (as auto-detected or overridden with external-hostname). "+
"Only valid if the ServiceAccountIssuerDiscovery feature gate is enabled.")

// Deprecated in 1.13
fs.StringSliceVar(&s.APIAudiences, "service-account-api-audiences", s.APIAudiences, ""+
Expand Down
38 changes: 38 additions & 0 deletions pkg/master/master.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ type ExtraConfig struct {
ServiceAccountIssuer serviceaccount.TokenGenerator
ServiceAccountMaxExpiration time.Duration

// ServiceAccountIssuerDiscovery
ServiceAccountIssuerURL string
ServiceAccountJWKSURI string
ServiceAccountPublicKeys []interface{}

VersionedInformers informers.SharedInformerFactory
}

Expand Down Expand Up @@ -342,6 +347,39 @@ func (c completedConfig) New(delegationTarget genericapiserver.DelegationTarget)
routes.Logs{}.Install(s.Handler.GoRestfulContainer)
}

if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
// Metadata and keys are expected to only change across restarts at present,
// so we just marshal immediately and serve the cached JSON bytes.
md, err := serviceaccount.NewOpenIDMetadata(
c.ExtraConfig.ServiceAccountIssuerURL,
c.ExtraConfig.ServiceAccountJWKSURI,
c.GenericConfig.ExternalAddress,
c.ExtraConfig.ServiceAccountPublicKeys,
)
if err != nil {
// If there was an error, skip installing the endpoints and log the
// error, but continue on. We don't return the error because the
// metadata responses require additional, backwards incompatible
// validation of command-line options.
msg := fmt.Sprintf("Could not construct pre-rendered responses for"+
" ServiceAccountIssuerDiscovery endpoints. Endpoints will not be"+
" enabled. Error: %v", err)
if c.ExtraConfig.ServiceAccountIssuerURL != "" {
// The user likely expects this feature to be enabled if issuer URL is
// set and the feature gate is enabled. In the future, if there is no
// longer a feature gate and issuer URL is not set, the user may not
// expect this feature to be enabled. We log the former case as an Error
// and the latter case as an Info.
klog.Error(msg)
} else {
klog.Info(msg)
}
} else {
routes.NewOpenIDMetadataServer(md.ConfigJSON, md.PublicKeysetJSON).
Install(s.Handler.GoRestfulContainer)
}
}

m := &Master{
GenericAPIServer: s,
ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo,
Expand Down
7 changes: 6 additions & 1 deletion pkg/routes/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ go_library(
srcs = [
"doc.go",
"logs.go",
"openidmetadata.go",
],
importpath = "k8s.io/kubernetes/pkg/routes",
deps = ["//vendor/github.com/emicklei/go-restful:go_default_library"],
deps = [
"//pkg/serviceaccount:go_default_library",
"//vendor/github.com/emicklei/go-restful:go_default_library",
"//vendor/k8s.io/klog:go_default_library",
],
)

filegroup(
Expand Down
114 changes: 114 additions & 0 deletions pkg/routes/openidmetadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
Copyright 2019 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 routes

import (
"net/http"

restful "github.com/emicklei/go-restful"

"k8s.io/klog"
"k8s.io/kubernetes/pkg/serviceaccount"
)

// This code is in package routes because many controllers import
// pkg/serviceaccount, but are not allowed by import-boss to depend on
// go-restful. All logic that deals with keys is kept in pkg/serviceaccount,
// and only the rendered JSON is passed into this server.

const (
// cacheControl is the value of the Cache-Control header. Overrides the
// global `private, no-cache` setting.
headerCacheControl = "Cache-Control"
cacheControl = "public, max-age=3600" // 1 hour

// mimeJWKS is the content type of the keyset response
mimeJWKS = "application/jwk-set+json"
)

// OpenIDMetadataServer is an HTTP server for metadata of the KSA token issuer.
type OpenIDMetadataServer struct {
configJSON []byte
keysetJSON []byte
}

// NewOpenIDMetadataServer creates a new OpenIDMetadataServer.
// The issuer is the OIDC issuer; keys are the keys that may be used to sign
// KSA tokens.
func NewOpenIDMetadataServer(configJSON, keysetJSON []byte) *OpenIDMetadataServer {
return &OpenIDMetadataServer{
configJSON: configJSON,
keysetJSON: keysetJSON,
}
}

// Install adds this server to the request router c.
func (s *OpenIDMetadataServer) Install(c *restful.Container) {
// Configuration WebService
// Container.Add "will detect duplicate root paths and exit in that case",
// so we need a root for /.well-known/openid-configuration to avoid conflicts.
cfg := new(restful.WebService).
Produces(restful.MIME_JSON)

cfg.Path(serviceaccount.OpenIDConfigPath).Route(
cfg.GET("").
To(fromStandard(s.serveConfiguration)).
Doc("get service account issuer OpenID configuration, also known as the 'OIDC discovery doc'").
Operation("getServiceAccountIssuerOpenIDConfiguration").
// Just include the OK, doesn't look like we include Internal Error in our openapi-spec.
Returns(http.StatusOK, "OK", ""))
c.Add(cfg)

// JWKS WebService
jwks := new(restful.WebService).
Produces(mimeJWKS)

jwks.Path(serviceaccount.JWKSPath).Route(
jwks.GET("").
To(fromStandard(s.serveKeys)).
Doc("get service account issuer OpenID JSON Web Key Set (contains public token verification keys)").
Operation("getServiceAccountIssuerOpenIDKeyset").
// Just include the OK, doesn't look like we include Internal Error in our openapi-spec.
Returns(http.StatusOK, "OK", ""))
c.Add(jwks)
}

// fromStandard provides compatibility between the standard (net/http) handler signature and the restful signature.
func fromStandard(h http.HandlerFunc) restful.RouteFunction {
return func(req *restful.Request, resp *restful.Response) {
h(resp, req.Request)
}
}

func (s *OpenIDMetadataServer) serveConfiguration(w http.ResponseWriter, req *http.Request) {
w.Header().Set(restful.HEADER_ContentType, restful.MIME_JSON)
w.Header().Set(headerCacheControl, cacheControl)
if _, err := w.Write(s.configJSON); err != nil {
klog.Errorf("failed to write service account issuer metadata response: %v", err)
return
}
}

func (s *OpenIDMetadataServer) serveKeys(w http.ResponseWriter, req *http.Request) {
// Per RFC7517 : https://tools.ietf.org/html/rfc7517#section-8.5.1
w.Header().Set(restful.HEADER_ContentType, mimeJWKS)
w.Header().Set(headerCacheControl, cacheControl)
if _, err := w.Write(s.keysetJSON); err != nil {
klog.Errorf("failed to write service account issuer JWKS response: %v", err)
return
}
}
6 changes: 6 additions & 0 deletions pkg/serviceaccount/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ go_library(
"claims.go",
"jwt.go",
"legacy.go",
"openidmetadata.go",
"util.go",
],
importpath = "k8s.io/kubernetes/pkg/serviceaccount",
deps = [
"//pkg/apis/core:go_default_library",
"//staging/src/k8s.io/api/core/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/errors:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library",
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
"//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount:go_default_library",
"//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library",
Expand Down Expand Up @@ -46,12 +48,14 @@ go_test(
srcs = [
"claims_test.go",
"jwt_test.go",
"openidmetadata_test.go",
"util_test.go",
],
embed = [":go_default_library"],
deps = [
"//pkg/apis/core:go_default_library",
"//pkg/controller/serviceaccount:go_default_library",
"//pkg/routes:go_default_library",
"//staging/src/k8s.io/api/core/v1:go_default_library",
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
Expand All @@ -60,6 +64,8 @@ go_test(
"//staging/src/k8s.io/client-go/listers/core/v1:go_default_library",
"//staging/src/k8s.io/client-go/tools/cache:go_default_library",
"//staging/src/k8s.io/client-go/util/keyutil:go_default_library",
"//vendor/github.com/emicklei/go-restful:go_default_library",
"//vendor/github.com/google/go-cmp/cmp:go_default_library",
"//vendor/gopkg.in/square/go-jose.v2:go_default_library",
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
],
Expand Down
4 changes: 4 additions & 0 deletions pkg/serviceaccount/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) {
return nil, fmt.Errorf("failed to derive keyID: %v", err)
}

// IMPORTANT: If this function is updated to support additional key sizes,
// algorithmForPublicKey in serviceaccount/openidmetadata.go must also be
// updated to support the same key sizes. Today we only support RS256.

// Wrap the RSA keypair in a JOSE JWK with the designated key ID.
privateJWK := &jose.JSONWebKey{
Algorithm: string(jose.RS256),
Expand Down
11 changes: 9 additions & 2 deletions pkg/serviceaccount/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package serviceaccount_test

import (
"context"
"fmt"
"reflect"
"strings"
"testing"
Expand Down Expand Up @@ -116,12 +117,18 @@ X2i8uIp/C/ASqiIGUeeKQtX0/IR3qCXyThP/dbCiHrF3v1cuhBOHY8CLVg==
const ecdsaKeyID = "SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc"

func getPrivateKey(data string) interface{} {
key, _ := keyutil.ParsePrivateKeyPEM([]byte(data))
key, err := keyutil.ParsePrivateKeyPEM([]byte(data))
if err != nil {
panic(fmt.Errorf("unexpected error parsing private key: %v", err))
}
return key
}

func getPublicKey(data string) interface{} {
keys, _ := keyutil.ParsePublicKeysPEM([]byte(data))
keys, err := keyutil.ParsePublicKeysPEM([]byte(data))
if err != nil {
panic(fmt.Errorf("unexpected error parsing public key: %v", err))
}
return keys[0]
}

Expand Down
Loading

0 comments on commit 5a176ac

Please sign in to comment.