mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-20 18:31:15 +00:00
Merge pull request #80724 from cceckman/provider-info-e2e
Provide OIDC discovery for service account token issuer
This commit is contained in:
commit
8ca96f3e07
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
//
|
||||
@ -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)
|
||||
|
@ -81,6 +81,7 @@ type ServiceAccountAuthenticationOptions struct {
|
||||
KeyFiles []string
|
||||
Lookup bool
|
||||
Issuer string
|
||||
JWKSURI string
|
||||
MaxExpiration time.Duration
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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, ""+
|
||||
|
@ -191,6 +191,11 @@ type ExtraConfig struct {
|
||||
ServiceAccountIssuer serviceaccount.TokenGenerator
|
||||
ServiceAccountMaxExpiration time.Duration
|
||||
|
||||
// ServiceAccountIssuerDiscovery
|
||||
ServiceAccountIssuerURL string
|
||||
ServiceAccountJWKSURI string
|
||||
ServiceAccountPublicKeys []interface{}
|
||||
|
||||
VersionedInformers informers.SharedInformerFactory
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
@ -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(
|
||||
|
114
pkg/routes/openidmetadata.go
Normal file
114
pkg/routes/openidmetadata.go
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ go_library(
|
||||
"claims.go",
|
||||
"jwt.go",
|
||||
"legacy.go",
|
||||
"openidmetadata.go",
|
||||
"util.go",
|
||||
],
|
||||
importpath = "k8s.io/kubernetes/pkg/serviceaccount",
|
||||
@ -19,6 +20,7 @@ go_library(
|
||||
"//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",
|
||||
@ -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",
|
||||
@ -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",
|
||||
],
|
||||
|
@ -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),
|
||||
|
@ -18,6 +18,7 @@ package serviceaccount_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -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]
|
||||
}
|
||||
|
||||
|
295
pkg/serviceaccount/openidmetadata.go
Normal file
295
pkg/serviceaccount/openidmetadata.go
Normal file
@ -0,0 +1,295 @@
|
||||
/*
|
||||
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 serviceaccount
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
jose "gopkg.in/square/go-jose.v2"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/errors"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
)
|
||||
|
||||
const (
|
||||
// OpenIDConfigPath is the URL path at which the API server serves
|
||||
// an OIDC Provider Configuration Information document, corresponding
|
||||
// to the Kubernetes Service Account key issuer.
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html
|
||||
OpenIDConfigPath = "/.well-known/openid-configuration"
|
||||
|
||||
// JWKSPath is the URL path at which the API server serves a JWKS
|
||||
// containing the public keys that may be used to sign Kubernetes
|
||||
// Service Account keys.
|
||||
JWKSPath = "/openid/v1/jwks"
|
||||
)
|
||||
|
||||
// OpenIDMetadata contains the pre-rendered responses for OIDC discovery endpoints.
|
||||
type OpenIDMetadata struct {
|
||||
ConfigJSON []byte
|
||||
PublicKeysetJSON []byte
|
||||
}
|
||||
|
||||
// NewOpenIDMetadata returns the pre-rendered JSON responses for the OIDC discovery
|
||||
// endpoints, or an error if they could not be constructed. Callers should note
|
||||
// that this function may perform additional validation on inputs that is not
|
||||
// backwards-compatible with all command-line validation. The recommendation is
|
||||
// to log the error and skip installing the OIDC discovery endpoints.
|
||||
func NewOpenIDMetadata(issuerURL, jwksURI, defaultExternalAddress string, pubKeys []interface{}) (*OpenIDMetadata, error) {
|
||||
if issuerURL == "" {
|
||||
return nil, fmt.Errorf("empty issuer URL")
|
||||
}
|
||||
if jwksURI == "" && defaultExternalAddress == "" {
|
||||
return nil, fmt.Errorf("either the JWKS URI or the default external address, or both, must be set")
|
||||
}
|
||||
if len(pubKeys) == 0 {
|
||||
return nil, fmt.Errorf("no keys provided for validating keyset")
|
||||
}
|
||||
|
||||
// Ensure the issuer URL meets the OIDC spec (this is the additional
|
||||
// validation the doc comment warns about).
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
iss, err := url.Parse(issuerURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if iss.Scheme != "https" {
|
||||
return nil, fmt.Errorf("issuer URL must use https scheme, got: %s", issuerURL)
|
||||
}
|
||||
if iss.RawQuery != "" {
|
||||
return nil, fmt.Errorf("issuer URL may not include a query, got: %s", issuerURL)
|
||||
}
|
||||
if iss.Fragment != "" {
|
||||
return nil, fmt.Errorf("issuer URL may not include a fragment, got: %s", issuerURL)
|
||||
}
|
||||
|
||||
// Either use the provided JWKS URI or default to ExternalAddress plus
|
||||
// the JWKS path.
|
||||
if jwksURI == "" {
|
||||
const msg = "attempted to build jwks_uri from external " +
|
||||
"address %s, but could not construct a valid URL. Error: %v"
|
||||
|
||||
if defaultExternalAddress == "" {
|
||||
return nil, fmt.Errorf(msg, defaultExternalAddress,
|
||||
fmt.Errorf("empty address"))
|
||||
}
|
||||
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: defaultExternalAddress,
|
||||
Path: JWKSPath,
|
||||
}
|
||||
jwksURI = u.String()
|
||||
|
||||
// TODO(mtaufen): I think we can probably expect ExternalAddress is
|
||||
// at most just host + port and skip the sanity check, but want to be
|
||||
// careful until that is confirmed.
|
||||
|
||||
// Sanity check that the jwksURI we produced is the valid URL we expect.
|
||||
// This is just in case ExternalAddress came in as something weird,
|
||||
// like a scheme + host + port, instead of just host + port.
|
||||
parsed, err := url.Parse(jwksURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(msg, defaultExternalAddress, err)
|
||||
} else if u.Scheme != parsed.Scheme ||
|
||||
u.Host != parsed.Host ||
|
||||
u.Path != parsed.Path {
|
||||
return nil, fmt.Errorf(msg, defaultExternalAddress,
|
||||
fmt.Errorf("got %v, expected %v", parsed, u))
|
||||
}
|
||||
} else {
|
||||
// Double-check that jwksURI is an https URL
|
||||
if u, err := url.Parse(jwksURI); err != nil {
|
||||
return nil, err
|
||||
} else if u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("jwksURI requires https scheme, parsed as: %v", u.String())
|
||||
}
|
||||
}
|
||||
|
||||
configJSON, err := openIDConfigJSON(issuerURL, jwksURI, pubKeys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not marshal issuer discovery JSON, error: %v", err)
|
||||
}
|
||||
|
||||
keysetJSON, err := openIDKeysetJSON(pubKeys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not marshal issuer keys JSON, error: %v", err)
|
||||
}
|
||||
|
||||
return &OpenIDMetadata{
|
||||
ConfigJSON: configJSON,
|
||||
PublicKeysetJSON: keysetJSON,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// openIDMetadata provides a minimal subset of OIDC provider metadata:
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
type openIDMetadata struct {
|
||||
Issuer string `json:"issuer"` // REQUIRED in OIDC; meaningful to relying parties.
|
||||
// TODO(mtaufen): Since our goal is compatibility for relying parties that
|
||||
// need to validate ID tokens, but do not need to initiate login flows,
|
||||
// and since we aren't sure what to put in authorization_endpoint yet,
|
||||
// we will omit this field until someone files a bug.
|
||||
// AuthzEndpoint string `json:"authorization_endpoint"` // REQUIRED in OIDC; but useless to relying parties.
|
||||
JWKSURI string `json:"jwks_uri"` // REQUIRED in OIDC; meaningful to relying parties.
|
||||
ResponseTypes []string `json:"response_types_supported"` // REQUIRED in OIDC
|
||||
SubjectTypes []string `json:"subject_types_supported"` // REQUIRED in OIDC
|
||||
SigningAlgs []string `json:"id_token_signing_alg_values_supported"` // REQUIRED in OIDC
|
||||
}
|
||||
|
||||
// openIDConfigJSON returns the JSON OIDC Discovery Doc for the service
|
||||
// account issuer.
|
||||
func openIDConfigJSON(iss, jwksURI string, keys []interface{}) ([]byte, error) {
|
||||
keyset, errs := publicJWKSFromKeys(keys)
|
||||
if errs != nil {
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
metadata := openIDMetadata{
|
||||
Issuer: iss,
|
||||
JWKSURI: jwksURI,
|
||||
ResponseTypes: []string{"id_token"}, // Kubernetes only produces ID tokens
|
||||
SubjectTypes: []string{"public"}, // https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
SigningAlgs: getAlgs(keyset), // REQUIRED by OIDC
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal service account issuer metadata: %v", err)
|
||||
}
|
||||
|
||||
return metadataJSON, nil
|
||||
}
|
||||
|
||||
// openIDKeysetJSON returns the JSON Web Key Set for the service account
|
||||
// issuer's keys.
|
||||
func openIDKeysetJSON(keys []interface{}) ([]byte, error) {
|
||||
keyset, errs := publicJWKSFromKeys(keys)
|
||||
if errs != nil {
|
||||
return nil, errs
|
||||
}
|
||||
|
||||
keysetJSON, err := json.Marshal(keyset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal service account issuer JWKS: %v", err)
|
||||
}
|
||||
|
||||
return keysetJSON, nil
|
||||
}
|
||||
|
||||
func getAlgs(keys *jose.JSONWebKeySet) []string {
|
||||
algs := sets.NewString()
|
||||
for _, k := range keys.Keys {
|
||||
algs.Insert(k.Algorithm)
|
||||
}
|
||||
// Note: List returns a sorted slice.
|
||||
return algs.List()
|
||||
}
|
||||
|
||||
type publicKeyGetter interface {
|
||||
Public() crypto.PublicKey
|
||||
}
|
||||
|
||||
// publicJWKSFromKeys constructs a JSONWebKeySet from a list of keys. The key
|
||||
// set will only contain the public keys associated with the input keys.
|
||||
func publicJWKSFromKeys(in []interface{}) (*jose.JSONWebKeySet, errors.Aggregate) {
|
||||
// Decode keys into a JWKS.
|
||||
var keys jose.JSONWebKeySet
|
||||
var errs []error
|
||||
for i, key := range in {
|
||||
var pubkey *jose.JSONWebKey
|
||||
var err error
|
||||
|
||||
switch k := key.(type) {
|
||||
case publicKeyGetter:
|
||||
// This is a private key. Get its public key
|
||||
pubkey, err = jwkFromPublicKey(k.Public())
|
||||
default:
|
||||
pubkey, err = jwkFromPublicKey(k)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("error constructing JWK for key #%d: %v", i, err))
|
||||
continue
|
||||
}
|
||||
|
||||
if !pubkey.Valid() {
|
||||
errs = append(errs, fmt.Errorf("key #%d not valid", i))
|
||||
continue
|
||||
}
|
||||
keys.Keys = append(keys.Keys, *pubkey)
|
||||
}
|
||||
if len(errs) != 0 {
|
||||
return nil, errors.NewAggregate(errs)
|
||||
}
|
||||
return &keys, nil
|
||||
}
|
||||
|
||||
func jwkFromPublicKey(publicKey crypto.PublicKey) (*jose.JSONWebKey, error) {
|
||||
alg, err := algorithmFromPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keyID, err := keyIDFromPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jwk := &jose.JSONWebKey{
|
||||
Algorithm: string(alg),
|
||||
Key: publicKey,
|
||||
KeyID: keyID,
|
||||
Use: "sig",
|
||||
}
|
||||
|
||||
if !jwk.IsPublic() {
|
||||
return nil, fmt.Errorf("JWK was not a public key! JWK: %v", jwk)
|
||||
}
|
||||
|
||||
return jwk, nil
|
||||
}
|
||||
|
||||
func algorithmFromPublicKey(publicKey crypto.PublicKey) (jose.SignatureAlgorithm, error) {
|
||||
switch pk := publicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
// IMPORTANT: If this function is updated to support additional key sizes,
|
||||
// signerFromRSAPrivateKey in serviceaccount/jwt.go must also be
|
||||
// updated to support the same key sizes. Today we only support RS256.
|
||||
return jose.RS256, nil
|
||||
case *ecdsa.PublicKey:
|
||||
switch pk.Curve {
|
||||
case elliptic.P256():
|
||||
return jose.ES256, nil
|
||||
case elliptic.P384():
|
||||
return jose.ES384, nil
|
||||
case elliptic.P521():
|
||||
return jose.ES512, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown private key curve, must be 256, 384, or 521")
|
||||
}
|
||||
case jose.OpaqueSigner:
|
||||
return jose.SignatureAlgorithm(pk.Public().Algorithm), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown public key type, must be *rsa.PublicKey, *ecdsa.PublicKey, or jose.OpaqueSigner")
|
||||
}
|
||||
}
|
395
pkg/serviceaccount/openidmetadata_test.go
Normal file
395
pkg/serviceaccount/openidmetadata_test.go
Normal file
@ -0,0 +1,395 @@
|
||||
/*
|
||||
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 serviceaccount_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
restful "github.com/emicklei/go-restful"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
jose "gopkg.in/square/go-jose.v2"
|
||||
|
||||
"k8s.io/kubernetes/pkg/routes"
|
||||
"k8s.io/kubernetes/pkg/serviceaccount"
|
||||
)
|
||||
|
||||
const (
|
||||
exampleIssuer = "https://issuer.example.com"
|
||||
)
|
||||
|
||||
func setupServer(t *testing.T, iss string, keys []interface{}) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
|
||||
c := restful.NewContainer()
|
||||
s := httptest.NewServer(c)
|
||||
|
||||
// JWKS needs to be https, so swap that for the test
|
||||
jwksURI, err := url.Parse(s.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jwksURI.Scheme = "https"
|
||||
jwksURI.Path = serviceaccount.JWKSPath
|
||||
|
||||
md, err := serviceaccount.NewOpenIDMetadata(
|
||||
iss, jwksURI.String(), "", keys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
srv := routes.NewOpenIDMetadataServer(md.ConfigJSON, md.PublicKeysetJSON)
|
||||
srv.Install(c)
|
||||
|
||||
return s, jwksURI.String()
|
||||
}
|
||||
|
||||
var defaultKeys = []interface{}{getPublicKey(rsaPublicKey), getPublicKey(ecdsaPublicKey)}
|
||||
|
||||
// Configuration is an OIDC configuration, including most but not all required fields.
|
||||
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
type Configuration struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
ResponseTypes []string `json:"response_types_supported"`
|
||||
SigningAlgs []string `json:"id_token_signing_alg_values_supported"`
|
||||
SubjectTypes []string `json:"subject_types_supported"`
|
||||
}
|
||||
|
||||
func TestServeConfiguration(t *testing.T) {
|
||||
s, jwksURI := setupServer(t, exampleIssuer, defaultKeys)
|
||||
defer s.Close()
|
||||
|
||||
want := Configuration{
|
||||
Issuer: exampleIssuer,
|
||||
JWKSURI: jwksURI,
|
||||
ResponseTypes: []string{"id_token"},
|
||||
SubjectTypes: []string{"public"},
|
||||
SigningAlgs: []string{"ES256", "RS256"},
|
||||
}
|
||||
|
||||
reqURL := s.URL + "/.well-known/openid-configuration"
|
||||
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%s) = %v, %v want: <response>, <nil>", reqURL, resp, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Get(%s) = %v, _ want: %v, _", reqURL, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
if got, want := resp.Header.Get("Content-Type"), "application/json"; got != want {
|
||||
t.Errorf("Get(%s) Content-Type = %q, _ want: %q, _", reqURL, got, want)
|
||||
}
|
||||
if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want {
|
||||
t.Errorf("Get(%s) Cache-Control = %q, _ want: %q, _", reqURL, got, want)
|
||||
}
|
||||
|
||||
var got Configuration
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("Decode(_) = %v, want: <nil>", err)
|
||||
}
|
||||
|
||||
if !cmp.Equal(want, got) {
|
||||
t.Errorf("unexpected diff in received configuration (-want, +got):%s",
|
||||
cmp.Diff(want, got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKeys(t *testing.T) {
|
||||
wantPubRSA := getPublicKey(rsaPublicKey).(*rsa.PublicKey)
|
||||
wantPubECDSA := getPublicKey(ecdsaPublicKey).(*ecdsa.PublicKey)
|
||||
var serveKeysTests = []struct {
|
||||
Name string
|
||||
Keys []interface{}
|
||||
WantKeys []jose.JSONWebKey
|
||||
}{
|
||||
{
|
||||
Name: "configured public keys",
|
||||
Keys: []interface{}{
|
||||
getPublicKey(rsaPublicKey),
|
||||
getPublicKey(ecdsaPublicKey),
|
||||
},
|
||||
WantKeys: []jose.JSONWebKey{
|
||||
{
|
||||
Algorithm: "RS256",
|
||||
Key: wantPubRSA,
|
||||
KeyID: rsaKeyID,
|
||||
Use: "sig",
|
||||
Certificates: []*x509.Certificate{},
|
||||
},
|
||||
{
|
||||
Algorithm: "ES256",
|
||||
Key: wantPubECDSA,
|
||||
KeyID: ecdsaKeyID,
|
||||
Use: "sig",
|
||||
Certificates: []*x509.Certificate{},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "only publishes public keys",
|
||||
Keys: []interface{}{
|
||||
getPrivateKey(rsaPrivateKey),
|
||||
getPrivateKey(ecdsaPrivateKey),
|
||||
},
|
||||
WantKeys: []jose.JSONWebKey{
|
||||
{
|
||||
Algorithm: "RS256",
|
||||
Key: wantPubRSA,
|
||||
KeyID: rsaKeyID,
|
||||
Use: "sig",
|
||||
Certificates: []*x509.Certificate{},
|
||||
},
|
||||
{
|
||||
Algorithm: "ES256",
|
||||
Key: wantPubECDSA,
|
||||
KeyID: ecdsaKeyID,
|
||||
Use: "sig",
|
||||
Certificates: []*x509.Certificate{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range serveKeysTests {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
s, _ := setupServer(t, exampleIssuer, tt.Keys)
|
||||
defer s.Close()
|
||||
|
||||
reqURL := s.URL + "/openid/v1/jwks"
|
||||
|
||||
resp, err := http.Get(reqURL)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(%s) = %v, %v want: <response>, <nil>", reqURL, resp, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Get(%s) = %v, _ want: %v, _", reqURL, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
if got, want := resp.Header.Get("Content-Type"), "application/jwk-set+json"; got != want {
|
||||
t.Errorf("Get(%s) Content-Type = %q, _ want: %q, _", reqURL, got, want)
|
||||
}
|
||||
if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want {
|
||||
t.Errorf("Get(%s) Cache-Control = %q, _ want: %q, _", reqURL, got, want)
|
||||
}
|
||||
|
||||
ks := &jose.JSONWebKeySet{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(ks); err != nil {
|
||||
t.Fatalf("Decode(_) = %v, want: <nil>", err)
|
||||
}
|
||||
|
||||
bigIntComparer := cmp.Comparer(
|
||||
func(x, y *big.Int) bool {
|
||||
return x.Cmp(y) == 0
|
||||
})
|
||||
if !cmp.Equal(tt.WantKeys, ks.Keys, bigIntComparer) {
|
||||
t.Errorf("unexpected diff in JWKS keys (-want, +got): %v",
|
||||
cmp.Diff(tt.WantKeys, ks.Keys, bigIntComparer))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLBoundaries(t *testing.T) {
|
||||
s, _ := setupServer(t, exampleIssuer, defaultKeys)
|
||||
defer s.Close()
|
||||
|
||||
for _, tt := range []struct {
|
||||
Name string
|
||||
Path string
|
||||
WantOK bool
|
||||
}{
|
||||
{"OIDC config path", "/.well-known/openid-configuration", true},
|
||||
{"JWKS path", "/openid/v1/jwks", true},
|
||||
{"well-known", "/.well-known", false},
|
||||
{"subpath", "/openid/v1/jwks/foo", false},
|
||||
{"query", "/openid/v1/jwks?format=yaml", true},
|
||||
{"fragment", "/openid/v1/jwks#issuer", true},
|
||||
} {
|
||||
t.Run(tt.Name, func(t *testing.T) {
|
||||
resp, err := http.Get(s.URL + tt.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if tt.WantOK && (resp.StatusCode != http.StatusOK) {
|
||||
t.Errorf("Get(%v)= %v, want %v", tt.Path, resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if !tt.WantOK && (resp.StatusCode != http.StatusNotFound) {
|
||||
t.Errorf("Get(%v)= %v, want %v", tt.Path, resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewOpenIDMetadata(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issuerURL string
|
||||
jwksURI string
|
||||
externalAddress string
|
||||
keys []interface{}
|
||||
wantConfig string
|
||||
wantKeyset string
|
||||
err bool
|
||||
}{
|
||||
{
|
||||
name: "valid inputs",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://issuer.example.com/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`,
|
||||
wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`,
|
||||
},
|
||||
{
|
||||
name: "valid inputs, default JWKSURI to external address",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: "",
|
||||
// We expect host + port, no scheme, when API server calculates ExternalAddress.
|
||||
externalAddress: "192.0.2.1:80",
|
||||
keys: defaultKeys,
|
||||
wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://192.0.2.1:80/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`,
|
||||
wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`,
|
||||
},
|
||||
{
|
||||
name: "valid inputs, IP addresses instead of domains",
|
||||
issuerURL: "https://192.0.2.1:80",
|
||||
jwksURI: "https://192.0.2.1:80" + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
wantConfig: `{"issuer":"https://192.0.2.1:80","jwks_uri":"https://192.0.2.1:80/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`,
|
||||
wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`,
|
||||
},
|
||||
{
|
||||
name: "response only contains public keys, even when private keys are provided",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: []interface{}{getPrivateKey(rsaPrivateKey), getPrivateKey(ecdsaPrivateKey)},
|
||||
wantConfig: `{"issuer":"https://issuer.example.com","jwks_uri":"https://issuer.example.com/openid/v1/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256","RS256"]}`,
|
||||
wantKeyset: `{"keys":[{"use":"sig","kty":"RSA","kid":"JHJehTTTZlsspKHT-GaJxK7Kd1NQgZJu3fyK6K_QDYU","alg":"RS256","n":"249XwEo9k4tM8fMxV7zxOhcrP-WvXn917koM5Qr2ZXs4vo26e4ytdlrV0bQ9SlcLpQVSYjIxNfhTZdDt-ecIzshKuv1gKIxbbLQMOuK1eA_4HALyEkFgmS_tleLJrhc65tKPMGD-pKQ_xhmzRuCG51RoiMgbQxaCyYxGfNLpLAZK9L0Tctv9a0mJmGIYnIOQM4kC1A1I1n3EsXMWmeJUj7OTh_AjjCnMnkgvKT2tpKxYQ59PgDgU8Ssc7RDSmSkLxnrv-OrN80j6xrw0OjEiB4Ycr0PqfzZcvy8efTtFQ_Jnc4Bp1zUtFXt7-QeevePtQ2EcyELXE0i63T1CujRMWw","e":"AQAB"},{"use":"sig","kty":"EC","kid":"SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc","crv":"P-256","alg":"ES256","x":"H6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp_C_ASqiI","y":"BlHnikLV9PyEd6gl8k4T_3Wwoh6xd79XLoQTh2PAi1Y"}]}`,
|
||||
},
|
||||
{
|
||||
name: "issuer missing https",
|
||||
issuerURL: "http://issuer.example.com",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "issuer missing scheme",
|
||||
issuerURL: "issuer.example.com",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "issuer includes query",
|
||||
issuerURL: "https://issuer.example.com?foo=bar",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "issuer includes fragment",
|
||||
issuerURL: "https://issuer.example.com#baz",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "issuer includes query and fragment",
|
||||
issuerURL: "https://issuer.example.com?foo=bar#baz",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "issuer is not a valid URL",
|
||||
issuerURL: "issuer",
|
||||
jwksURI: exampleIssuer + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "jwks missing https",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: "http://issuer.example.com" + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "jwks missing scheme",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: "issuer.example.com" + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "jwks is not a valid URL",
|
||||
issuerURL: exampleIssuer,
|
||||
jwksURI: "issuer" + serviceaccount.JWKSPath,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "external address also has a scheme",
|
||||
issuerURL: exampleIssuer,
|
||||
externalAddress: "https://192.0.2.1:80",
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
{
|
||||
name: "missing external address and jwks",
|
||||
issuerURL: exampleIssuer,
|
||||
keys: defaultKeys,
|
||||
err: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
md, err := serviceaccount.NewOpenIDMetadata(tc.issuerURL, tc.jwksURI, tc.externalAddress, tc.keys)
|
||||
if tc.err {
|
||||
if err == nil {
|
||||
t.Fatalf("got <nil>, want error")
|
||||
}
|
||||
return
|
||||
} else if !tc.err && err != nil {
|
||||
t.Fatalf("got error %v, want <nil>", err)
|
||||
}
|
||||
|
||||
config := string(md.ConfigJSON)
|
||||
keyset := string(md.PublicKeysetJSON)
|
||||
if config != tc.wantConfig {
|
||||
t.Errorf("got metadata %s, want %s", config, tc.wantConfig)
|
||||
}
|
||||
if keyset != tc.wantKeyset {
|
||||
t.Errorf("got keyset %s, want %s", keyset, tc.wantKeyset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -461,6 +461,21 @@ func ClusterRoles() []rbacv1.ClusterRole {
|
||||
},
|
||||
}
|
||||
|
||||
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
|
||||
// Add the cluster role for reading the ServiceAccountIssuerDiscovery endpoints
|
||||
// but do not bind it explicitly. Leave the decision of who can read it up
|
||||
// to cluster admins.
|
||||
roles = append(roles, rbacv1.ClusterRole{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "system:service-account-issuer-discovery"},
|
||||
Rules: []rbacv1.PolicyRule{
|
||||
rbacv1helpers.NewRule("get").URLs(
|
||||
"/.well-known/openid-configuration",
|
||||
"/openid/v1/jwks",
|
||||
).RuleOrDie(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// node-proxier role is used by kube-proxy.
|
||||
nodeProxierRules := []rbacv1.PolicyRule{
|
||||
rbacv1helpers.NewRule("list", "watch").Groups(legacyGroup).Resources("services", "endpoints").RuleOrDie(),
|
||||
|
@ -180,7 +180,7 @@ func (s *ServerRunOptions) AddUniversalFlags(fs *pflag.FlagSet) {
|
||||
"Memory limit for apiserver in MB (used to configure sizes of caches, etc.)")
|
||||
|
||||
fs.StringVar(&s.ExternalHost, "external-hostname", s.ExternalHost,
|
||||
"The hostname to use when generating externalized URLs for this master (e.g. Swagger API Docs).")
|
||||
"The hostname to use when generating externalized URLs for this master (e.g. Swagger API Docs or OpenID Discovery).")
|
||||
|
||||
deprecatedMasterServiceNamespace := metav1.NamespaceDefault
|
||||
fs.StringVar(&deprecatedMasterServiceNamespace, "master-service-namespace", deprecatedMasterServiceNamespace, ""+
|
||||
|
@ -88,6 +88,7 @@ go_test(
|
||||
"//test/e2e/lifecycle/bootstrap:go_default_library",
|
||||
"//test/integration:go_default_library",
|
||||
"//test/integration/framework:go_default_library",
|
||||
"//vendor/gopkg.in/square/go-jose.v2:go_default_library",
|
||||
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
|
||||
"//vendor/k8s.io/klog:go_default_library",
|
||||
"//vendor/k8s.io/utils/pointer:go_default_library",
|
||||
|
@ -17,16 +17,21 @@ limitations under the License.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jose "gopkg.in/square/go-jose.v2"
|
||||
"gopkg.in/square/go-jose.v2/jwt"
|
||||
|
||||
authenticationv1 "k8s.io/api/authentication/v1"
|
||||
@ -40,6 +45,7 @@ import (
|
||||
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
||||
clientset "k8s.io/client-go/kubernetes"
|
||||
v1listers "k8s.io/client-go/listers/core/v1"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
"k8s.io/client-go/util/keyutil"
|
||||
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
||||
@ -58,12 +64,14 @@ AwEHoUQDQgAEH6cuzP8XuD5wal6wf9M6xDljTOPLX2i8uIp/C/ASqiIGUeeKQtX0
|
||||
|
||||
func TestServiceAccountTokenCreate(t *testing.T) {
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.TokenRequest, true)()
|
||||
defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.ServiceAccountIssuerDiscovery, true)()
|
||||
|
||||
// Build client config, clientset, and informers
|
||||
sk, err := keyutil.ParsePrivateKeyPEM([]byte(ecdsaPrivateKey))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
|
||||
pk := sk.(*ecdsa.PrivateKey).PublicKey
|
||||
|
||||
const iss = "https://foo.bar.example.com"
|
||||
@ -100,6 +108,7 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
||||
)),
|
||||
),
|
||||
)
|
||||
|
||||
tokenGenerator, err := serviceaccount.JWTTokenGenerator(iss, sk)
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
@ -108,6 +117,10 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
||||
masterConfig.ExtraConfig.ServiceAccountMaxExpiration = maxExpirationDuration
|
||||
masterConfig.GenericConfig.Authentication.APIAudiences = aud
|
||||
|
||||
masterConfig.ExtraConfig.ServiceAccountIssuerURL = iss
|
||||
masterConfig.ExtraConfig.ServiceAccountJWKSURI = ""
|
||||
masterConfig.ExtraConfig.ServiceAccountPublicKeys = []interface{}{&pk}
|
||||
|
||||
master, _, closeFn := framework.RunAMaster(masterConfig)
|
||||
defer closeFn()
|
||||
|
||||
@ -117,6 +130,11 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
||||
}
|
||||
*gcs = *cs
|
||||
|
||||
rc, err := rest.UnversionedRESTClientFor(master.GenericAPIServer.LoopbackClientConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var (
|
||||
sa = &v1.ServiceAccount{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
@ -568,6 +586,137 @@ func TestServiceAccountTokenCreate(t *testing.T) {
|
||||
|
||||
doTokenReview(t, cs, treq, true)
|
||||
})
|
||||
|
||||
t.Run("a token is valid against the HTTP-provided service account issuer metadata", func(t *testing.T) {
|
||||
sa, del := createDeleteSvcAcct(t, cs, sa)
|
||||
defer del()
|
||||
|
||||
t.Log("get token")
|
||||
tokenRequest, err := cs.CoreV1().ServiceAccounts(sa.Namespace).CreateToken(
|
||||
context.TODO(),
|
||||
sa.Name,
|
||||
&authenticationv1.TokenRequest{
|
||||
Spec: authenticationv1.TokenRequestSpec{
|
||||
Audiences: []string{"api"},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error creating token: %v", err)
|
||||
}
|
||||
token := tokenRequest.Status.Token
|
||||
if token == "" {
|
||||
t.Fatal("no token")
|
||||
}
|
||||
|
||||
t.Log("get discovery doc")
|
||||
discoveryDoc := struct {
|
||||
Issuer string `json:"issuer"`
|
||||
JWKS string `json:"jwks_uri"`
|
||||
}{}
|
||||
|
||||
// A little convoluted, but the base path is hidden inside the RESTClient.
|
||||
// We can't just use the RESTClient, because it throws away the headers
|
||||
// before returning a result, and we need to check the headers.
|
||||
discoveryURL := rc.Get().AbsPath("/.well-known/openid-configuration").URL().String()
|
||||
resp, err := rc.Client.Get(discoveryURL)
|
||||
if err != nil {
|
||||
t.Fatalf("error getting metadata: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("got status: %v, want: %v", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got, want := resp.Header.Get("Content-Type"), "application/json"; got != want {
|
||||
t.Errorf("got Content-Type: %v, want: %v", got, want)
|
||||
}
|
||||
if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want {
|
||||
t.Errorf("got Cache-Control: %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
md := bytes.NewBuffer(b)
|
||||
t.Logf("raw discovery doc response:\n---%s\n---", md.String())
|
||||
if md.Len() == 0 {
|
||||
t.Fatal("empty response for discovery doc")
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(md).Decode(&discoveryDoc); err != nil {
|
||||
t.Fatalf("could not decode metadata: %v", err)
|
||||
}
|
||||
if discoveryDoc.Issuer != iss {
|
||||
t.Fatalf("invalid issuer in discovery doc: got %s, want %s",
|
||||
discoveryDoc.Issuer, iss)
|
||||
}
|
||||
// Parse the JWKSURI see if the path is what we expect. Since the
|
||||
// integration test framework hardcodes 192.168.10.4 as the PublicAddress,
|
||||
// which results in the same for ExternalAddress, we expect the JWKS URI
|
||||
// to be 192.168.10.4:443, even if that's not necessarily the external
|
||||
// IP of the test machine.
|
||||
expectJWKSURI := (&url.URL{
|
||||
Scheme: "https",
|
||||
Host: "192.168.10.4:443",
|
||||
Path: serviceaccount.JWKSPath,
|
||||
}).String()
|
||||
if discoveryDoc.JWKS != expectJWKSURI {
|
||||
t.Fatalf("unexpected jwks_uri in discovery doc: got %s, want %s",
|
||||
discoveryDoc.JWKS, expectJWKSURI)
|
||||
}
|
||||
|
||||
// Since the test framework hardcodes the host, we combine our client's
|
||||
// scheme and host with serviceaccount.JWKSPath. We know that this is what was
|
||||
// in the discovery doc because we checked that it matched above.
|
||||
jwksURI := rc.Get().AbsPath(serviceaccount.JWKSPath).URL().String()
|
||||
t.Log("get jwks from", jwksURI)
|
||||
resp, err = rc.Client.Get(jwksURI)
|
||||
if err != nil {
|
||||
t.Fatalf("error getting jwks: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("got status: %v, want: %v", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
if got, want := resp.Header.Get("Content-Type"), "application/jwk-set+json"; got != want {
|
||||
t.Errorf("got Content-Type: %v, want: %v", got, want)
|
||||
}
|
||||
if got, want := resp.Header.Get("Cache-Control"), "public, max-age=3600"; got != want {
|
||||
t.Errorf("got Cache-Control: %v, want: %v", got, want)
|
||||
}
|
||||
|
||||
b, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ks := bytes.NewBuffer(b)
|
||||
if ks.Len() == 0 {
|
||||
t.Fatal("empty jwks")
|
||||
}
|
||||
t.Logf("raw JWKS: \n---\n%s\n---", ks.String())
|
||||
|
||||
jwks := jose.JSONWebKeySet{}
|
||||
if err := json.NewDecoder(ks).Decode(&jwks); err != nil {
|
||||
t.Fatalf("could not decode JWKS: %v", err)
|
||||
}
|
||||
if len(jwks.Keys) != 1 {
|
||||
t.Fatalf("len(jwks.Keys) = %d, want 1", len(jwks.Keys))
|
||||
}
|
||||
key := jwks.Keys[0]
|
||||
tok, err := jwt.ParseSigned(token)
|
||||
if err != nil {
|
||||
t.Fatalf("could not parse token %q: %v", token, err)
|
||||
}
|
||||
var claims jwt.Claims
|
||||
if err := tok.Claims(key, &claims); err != nil {
|
||||
t.Fatalf("could not validate claims on token: %v", err)
|
||||
}
|
||||
if err := claims.Validate(jwt.Expected{Issuer: discoveryDoc.Issuer}); err != nil {
|
||||
t.Fatalf("invalid claims: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func doTokenReview(t *testing.T, cs clientset.Interface, treq *authenticationv1.TokenRequest, expectErr bool) authenticationv1.UserInfo {
|
||||
|
Loading…
Reference in New Issue
Block a user