mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-20 10:20:51 +00:00
Provide OIDC discovery endpoints
- 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 <mtaufen@google.com>
This commit is contained in:
parent
7a506ff342
commit
5a176ac772
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -458,6 +458,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