mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-09-03 10:17:46 +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:
committed by
Michael Taufen
parent
7a506ff342
commit
5a176ac772
@@ -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 {
|
||||
|
Reference in New Issue
Block a user