Skip to content

Commit 5a176ac

Browse files
Charles Eckmanmtaufen
andcommitted
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 <[email protected]>
1 parent 7a506ff commit 5a176ac

File tree

15 files changed

+1090
-5
lines changed

15 files changed

+1090
-5
lines changed

cmd/kube-apiserver/app/server.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,22 @@ func CreateKubeAPIServerConfig(
381381
config.ExtraConfig.KubeletClientConfig.Lookup = config.GenericConfig.EgressSelector.Lookup
382382
}
383383

384+
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
385+
// Load the public keys.
386+
var pubKeys []interface{}
387+
for _, f := range s.Authentication.ServiceAccounts.KeyFiles {
388+
keys, err := keyutil.PublicKeysFromFile(f)
389+
if err != nil {
390+
return nil, nil, nil, nil, fmt.Errorf("failed to parse key file %q: %v", f, err)
391+
}
392+
pubKeys = append(pubKeys, keys...)
393+
}
394+
// Plumb the required metadata through ExtraConfig.
395+
config.ExtraConfig.ServiceAccountIssuerURL = s.Authentication.ServiceAccounts.Issuer
396+
config.ExtraConfig.ServiceAccountJWKSURI = s.Authentication.ServiceAccounts.JWKSURI
397+
config.ExtraConfig.ServiceAccountPublicKeys = pubKeys
398+
}
399+
384400
return config, insecureServingInfo, serviceResolver, pluginInitializers, nil
385401
}
386402

pkg/features/kube_features.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ const (
239239
// to the API server.
240240
BoundServiceAccountTokenVolume featuregate.Feature = "BoundServiceAccountTokenVolume"
241241

242+
// owner: @mtaufen
243+
// alpha: v1.18
244+
//
245+
// Enable OIDC discovery endpoints (issuer and JWKS URLs) for the service
246+
// account issuer in the API server.
247+
// Note these endpoints serve minimally-compliant discovery docs that are
248+
// intended to be used for service account token verification.
249+
ServiceAccountIssuerDiscovery featuregate.Feature = "ServiceAccountIssuerDiscovery"
250+
242251
// owner: @Random-Liu
243252
// beta: v1.11
244253
//
@@ -573,6 +582,7 @@ var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureS
573582
TokenRequest: {Default: true, PreRelease: featuregate.Beta},
574583
TokenRequestProjection: {Default: true, PreRelease: featuregate.Beta},
575584
BoundServiceAccountTokenVolume: {Default: false, PreRelease: featuregate.Alpha},
585+
ServiceAccountIssuerDiscovery: {Default: false, PreRelease: featuregate.Alpha},
576586
CRIContainerLogRotation: {Default: true, PreRelease: featuregate.Beta},
577587
CSIMigration: {Default: true, PreRelease: featuregate.Beta},
578588
CSIMigrationGCE: {Default: false, PreRelease: featuregate.Beta}, // Off by default (requires GCE PD CSI Driver)

pkg/kubeapiserver/options/authentication.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ type ServiceAccountAuthenticationOptions struct {
8181
KeyFiles []string
8282
Lookup bool
8383
Issuer string
84+
JWKSURI string
8485
MaxExpiration time.Duration
8586
}
8687

@@ -188,6 +189,22 @@ func (s *BuiltInAuthenticationOptions) Validate() []error {
188189
}
189190
}
190191

192+
if s.ServiceAccounts != nil {
193+
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
194+
// Validate the JWKS URI when it is explicitly set.
195+
// When unset, it is later derived from ExternalHost.
196+
if s.ServiceAccounts.JWKSURI != "" {
197+
if u, err := url.Parse(s.ServiceAccounts.JWKSURI); err != nil {
198+
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri must be a valid URL: %v", err))
199+
} else if u.Scheme != "https" {
200+
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri requires https scheme, parsed as: %v", u.String()))
201+
}
202+
}
203+
} else if len(s.ServiceAccounts.JWKSURI) > 0 {
204+
allErrors = append(allErrors, fmt.Errorf("service-account-jwks-uri may only be set when the ServiceAccountIssuerDiscovery feature gate is enabled"))
205+
}
206+
}
207+
191208
return allErrors
192209
}
193210

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

282299
fs.StringVar(&s.ServiceAccounts.Issuer, "service-account-issuer", s.ServiceAccounts.Issuer, ""+
283300
"Identifier of the service account token issuer. The issuer will assert this identifier "+
284-
"in \"iss\" claim of issued tokens. This value is a string or URI.")
301+
"in \"iss\" claim of issued tokens. This value is a string or URI. If this option is not "+
302+
"a valid URI per the OpenID Discovery 1.0 spec, the ServiceAccountIssuerDiscovery feature "+
303+
"will remain disabled, even if the feature gate is set to true. It is highly recommended "+
304+
"that this value comply with the OpenID spec: https://openid.net/specs/openid-connect-discovery-1_0.html. "+
305+
"In practice, this means that service-account-issuer must be an https URL. It is also highly "+
306+
"recommended that this URL be capable of serving OpenID discovery documents at "+
307+
"`{service-account-issuer}/.well-known/openid-configuration`.")
308+
309+
fs.StringVar(&s.ServiceAccounts.JWKSURI, "service-account-jwks-uri", s.ServiceAccounts.JWKSURI, ""+
310+
"Overrides the URI for the JSON Web Key Set in the discovery doc served at "+
311+
"/.well-known/openid-configuration. This flag is useful if the discovery doc"+
312+
"and key set are served to relying parties from a URL other than the "+
313+
"API server's external (as auto-detected or overridden with external-hostname). "+
314+
"Only valid if the ServiceAccountIssuerDiscovery feature gate is enabled.")
285315

286316
// Deprecated in 1.13
287317
fs.StringSliceVar(&s.APIAudiences, "service-account-api-audiences", s.APIAudiences, ""+

pkg/master/master.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,11 @@ type ExtraConfig struct {
191191
ServiceAccountIssuer serviceaccount.TokenGenerator
192192
ServiceAccountMaxExpiration time.Duration
193193

194+
// ServiceAccountIssuerDiscovery
195+
ServiceAccountIssuerURL string
196+
ServiceAccountJWKSURI string
197+
ServiceAccountPublicKeys []interface{}
198+
194199
VersionedInformers informers.SharedInformerFactory
195200
}
196201

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

350+
if utilfeature.DefaultFeatureGate.Enabled(features.ServiceAccountIssuerDiscovery) {
351+
// Metadata and keys are expected to only change across restarts at present,
352+
// so we just marshal immediately and serve the cached JSON bytes.
353+
md, err := serviceaccount.NewOpenIDMetadata(
354+
c.ExtraConfig.ServiceAccountIssuerURL,
355+
c.ExtraConfig.ServiceAccountJWKSURI,
356+
c.GenericConfig.ExternalAddress,
357+
c.ExtraConfig.ServiceAccountPublicKeys,
358+
)
359+
if err != nil {
360+
// If there was an error, skip installing the endpoints and log the
361+
// error, but continue on. We don't return the error because the
362+
// metadata responses require additional, backwards incompatible
363+
// validation of command-line options.
364+
msg := fmt.Sprintf("Could not construct pre-rendered responses for"+
365+
" ServiceAccountIssuerDiscovery endpoints. Endpoints will not be"+
366+
" enabled. Error: %v", err)
367+
if c.ExtraConfig.ServiceAccountIssuerURL != "" {
368+
// The user likely expects this feature to be enabled if issuer URL is
369+
// set and the feature gate is enabled. In the future, if there is no
370+
// longer a feature gate and issuer URL is not set, the user may not
371+
// expect this feature to be enabled. We log the former case as an Error
372+
// and the latter case as an Info.
373+
klog.Error(msg)
374+
} else {
375+
klog.Info(msg)
376+
}
377+
} else {
378+
routes.NewOpenIDMetadataServer(md.ConfigJSON, md.PublicKeysetJSON).
379+
Install(s.Handler.GoRestfulContainer)
380+
}
381+
}
382+
345383
m := &Master{
346384
GenericAPIServer: s,
347385
ClusterAuthenticationInfo: c.ExtraConfig.ClusterAuthenticationInfo,

pkg/routes/BUILD

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,14 @@ go_library(
1010
srcs = [
1111
"doc.go",
1212
"logs.go",
13+
"openidmetadata.go",
1314
],
1415
importpath = "k8s.io/kubernetes/pkg/routes",
15-
deps = ["//vendor/github.com/emicklei/go-restful:go_default_library"],
16+
deps = [
17+
"//pkg/serviceaccount:go_default_library",
18+
"//vendor/github.com/emicklei/go-restful:go_default_library",
19+
"//vendor/k8s.io/klog:go_default_library",
20+
],
1621
)
1722

1823
filegroup(

pkg/routes/openidmetadata.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
Copyright 2019 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package routes
18+
19+
import (
20+
"net/http"
21+
22+
restful "github.com/emicklei/go-restful"
23+
24+
"k8s.io/klog"
25+
"k8s.io/kubernetes/pkg/serviceaccount"
26+
)
27+
28+
// This code is in package routes because many controllers import
29+
// pkg/serviceaccount, but are not allowed by import-boss to depend on
30+
// go-restful. All logic that deals with keys is kept in pkg/serviceaccount,
31+
// and only the rendered JSON is passed into this server.
32+
33+
const (
34+
// cacheControl is the value of the Cache-Control header. Overrides the
35+
// global `private, no-cache` setting.
36+
headerCacheControl = "Cache-Control"
37+
cacheControl = "public, max-age=3600" // 1 hour
38+
39+
// mimeJWKS is the content type of the keyset response
40+
mimeJWKS = "application/jwk-set+json"
41+
)
42+
43+
// OpenIDMetadataServer is an HTTP server for metadata of the KSA token issuer.
44+
type OpenIDMetadataServer struct {
45+
configJSON []byte
46+
keysetJSON []byte
47+
}
48+
49+
// NewOpenIDMetadataServer creates a new OpenIDMetadataServer.
50+
// The issuer is the OIDC issuer; keys are the keys that may be used to sign
51+
// KSA tokens.
52+
func NewOpenIDMetadataServer(configJSON, keysetJSON []byte) *OpenIDMetadataServer {
53+
return &OpenIDMetadataServer{
54+
configJSON: configJSON,
55+
keysetJSON: keysetJSON,
56+
}
57+
}
58+
59+
// Install adds this server to the request router c.
60+
func (s *OpenIDMetadataServer) Install(c *restful.Container) {
61+
// Configuration WebService
62+
// Container.Add "will detect duplicate root paths and exit in that case",
63+
// so we need a root for /.well-known/openid-configuration to avoid conflicts.
64+
cfg := new(restful.WebService).
65+
Produces(restful.MIME_JSON)
66+
67+
cfg.Path(serviceaccount.OpenIDConfigPath).Route(
68+
cfg.GET("").
69+
To(fromStandard(s.serveConfiguration)).
70+
Doc("get service account issuer OpenID configuration, also known as the 'OIDC discovery doc'").
71+
Operation("getServiceAccountIssuerOpenIDConfiguration").
72+
// Just include the OK, doesn't look like we include Internal Error in our openapi-spec.
73+
Returns(http.StatusOK, "OK", ""))
74+
c.Add(cfg)
75+
76+
// JWKS WebService
77+
jwks := new(restful.WebService).
78+
Produces(mimeJWKS)
79+
80+
jwks.Path(serviceaccount.JWKSPath).Route(
81+
jwks.GET("").
82+
To(fromStandard(s.serveKeys)).
83+
Doc("get service account issuer OpenID JSON Web Key Set (contains public token verification keys)").
84+
Operation("getServiceAccountIssuerOpenIDKeyset").
85+
// Just include the OK, doesn't look like we include Internal Error in our openapi-spec.
86+
Returns(http.StatusOK, "OK", ""))
87+
c.Add(jwks)
88+
}
89+
90+
// fromStandard provides compatibility between the standard (net/http) handler signature and the restful signature.
91+
func fromStandard(h http.HandlerFunc) restful.RouteFunction {
92+
return func(req *restful.Request, resp *restful.Response) {
93+
h(resp, req.Request)
94+
}
95+
}
96+
97+
func (s *OpenIDMetadataServer) serveConfiguration(w http.ResponseWriter, req *http.Request) {
98+
w.Header().Set(restful.HEADER_ContentType, restful.MIME_JSON)
99+
w.Header().Set(headerCacheControl, cacheControl)
100+
if _, err := w.Write(s.configJSON); err != nil {
101+
klog.Errorf("failed to write service account issuer metadata response: %v", err)
102+
return
103+
}
104+
}
105+
106+
func (s *OpenIDMetadataServer) serveKeys(w http.ResponseWriter, req *http.Request) {
107+
// Per RFC7517 : https://tools.ietf.org/html/rfc7517#section-8.5.1
108+
w.Header().Set(restful.HEADER_ContentType, mimeJWKS)
109+
w.Header().Set(headerCacheControl, cacheControl)
110+
if _, err := w.Write(s.keysetJSON); err != nil {
111+
klog.Errorf("failed to write service account issuer JWKS response: %v", err)
112+
return
113+
}
114+
}

pkg/serviceaccount/BUILD

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ go_library(
1212
"claims.go",
1313
"jwt.go",
1414
"legacy.go",
15+
"openidmetadata.go",
1516
"util.go",
1617
],
1718
importpath = "k8s.io/kubernetes/pkg/serviceaccount",
1819
deps = [
1920
"//pkg/apis/core:go_default_library",
2021
"//staging/src/k8s.io/api/core/v1:go_default_library",
2122
"//staging/src/k8s.io/apimachinery/pkg/util/errors:go_default_library",
23+
"//staging/src/k8s.io/apimachinery/pkg/util/sets:go_default_library",
2224
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
2325
"//staging/src/k8s.io/apiserver/pkg/authentication/serviceaccount:go_default_library",
2426
"//staging/src/k8s.io/apiserver/pkg/authentication/user:go_default_library",
@@ -46,12 +48,14 @@ go_test(
4648
srcs = [
4749
"claims_test.go",
4850
"jwt_test.go",
51+
"openidmetadata_test.go",
4952
"util_test.go",
5053
],
5154
embed = [":go_default_library"],
5255
deps = [
5356
"//pkg/apis/core:go_default_library",
5457
"//pkg/controller/serviceaccount:go_default_library",
58+
"//pkg/routes:go_default_library",
5559
"//staging/src/k8s.io/api/core/v1:go_default_library",
5660
"//staging/src/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
5761
"//staging/src/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
@@ -60,6 +64,8 @@ go_test(
6064
"//staging/src/k8s.io/client-go/listers/core/v1:go_default_library",
6165
"//staging/src/k8s.io/client-go/tools/cache:go_default_library",
6266
"//staging/src/k8s.io/client-go/util/keyutil:go_default_library",
67+
"//vendor/github.com/emicklei/go-restful:go_default_library",
68+
"//vendor/github.com/google/go-cmp/cmp:go_default_library",
6369
"//vendor/gopkg.in/square/go-jose.v2:go_default_library",
6470
"//vendor/gopkg.in/square/go-jose.v2/jwt:go_default_library",
6571
],

pkg/serviceaccount/jwt.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ func signerFromRSAPrivateKey(keyPair *rsa.PrivateKey) (jose.Signer, error) {
114114
return nil, fmt.Errorf("failed to derive keyID: %v", err)
115115
}
116116

117+
// IMPORTANT: If this function is updated to support additional key sizes,
118+
// algorithmForPublicKey in serviceaccount/openidmetadata.go must also be
119+
// updated to support the same key sizes. Today we only support RS256.
120+
117121
// Wrap the RSA keypair in a JOSE JWK with the designated key ID.
118122
privateJWK := &jose.JSONWebKey{
119123
Algorithm: string(jose.RS256),

pkg/serviceaccount/jwt_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package serviceaccount_test
1818

1919
import (
2020
"context"
21+
"fmt"
2122
"reflect"
2223
"strings"
2324
"testing"
@@ -116,12 +117,18 @@ X2i8uIp/C/ASqiIGUeeKQtX0/IR3qCXyThP/dbCiHrF3v1cuhBOHY8CLVg==
116117
const ecdsaKeyID = "SoABiieYuNx4UdqYvZRVeuC6SihxgLrhLy9peHMHpTc"
117118

118119
func getPrivateKey(data string) interface{} {
119-
key, _ := keyutil.ParsePrivateKeyPEM([]byte(data))
120+
key, err := keyutil.ParsePrivateKeyPEM([]byte(data))
121+
if err != nil {
122+
panic(fmt.Errorf("unexpected error parsing private key: %v", err))
123+
}
120124
return key
121125
}
122126

123127
func getPublicKey(data string) interface{} {
124-
keys, _ := keyutil.ParsePublicKeysPEM([]byte(data))
128+
keys, err := keyutil.ParsePublicKeysPEM([]byte(data))
129+
if err != nil {
130+
panic(fmt.Errorf("unexpected error parsing public key: %v", err))
131+
}
125132
return keys[0]
126133
}
127134

0 commit comments

Comments
 (0)