Merge pull request #80724 from cceckman/provider-info-e2e

Provide OIDC discovery for service account token issuer
This commit is contained in:
Kubernetes Prow Robot 2020-02-13 01:38:35 -08:00 committed by GitHub
commit 8ca96f3e07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1090 additions and 5 deletions

View File

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

View File

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

View File

@ -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, ""+

View File

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

View File

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

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

View File

@ -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",
],

View File

@ -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),

View File

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

View 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")
}
}

View 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)
}
})
}
}

View File

@ -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(),

View File

@ -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, ""+

View File

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

View File

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