diff --git a/.circleci/config.yml b/.circleci/config.yml index 9b652199..f521cc19 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -10,6 +10,7 @@ jobs: - run: go get github.com/golang/lint/golint - run: golint - run: go build -v + - run: make -C integration-test/testdata - run: go test -v ./... release: diff --git a/.editorconfig b/.editorconfig index 2850444c..03e05d5e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,3 +9,7 @@ indent_size = 2 [*.go] indent_style = tab indent_size = 4 + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/README.md b/README.md index 240ff077..0e7941f0 100644 --- a/README.md +++ b/README.md @@ -173,9 +173,9 @@ Run `kubelogin` and make sure you can access to the cluster. See the previous section for details. -## Tips +## Configuration -### Config file +### Kubeconfig You can set the environment variable `KUBECONFIG` to point the config file. Default to `~/.kube/config`. @@ -184,6 +184,15 @@ Default to `~/.kube/config`. export KUBECONFIG="$PWD/.kubeconfig" ``` +### OpenID Connect Provider CA Certificate + +You can specify the CA certificate of your OpenID Connect provider by [`idp-certificate-authority` or `idp-certificate-authority-data` in the kubeconfig](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#using-kubectl). + +```sh +kubectl config set-credentials CLUSTER_NAME \ + --auth-provider-arg idp-certificate-authority=$PWD/ca.crt +``` + ### Setup script In actual team operation, you can share the following script to your team members for easy setup. diff --git a/cli/cli.go b/cli/cli.go index cfe78ca8..3d2cad12 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -3,7 +3,10 @@ package cli import ( "context" "crypto/tls" + "crypto/x509" + "encoding/base64" "fmt" + "io/ioutil" "log" "net/http" @@ -45,7 +48,7 @@ func (c *CLI) ExpandKubeConfig() (string, error) { } // Run performs this command. -func (c *CLI) Run() error { +func (c *CLI) Run(ctx context.Context) error { path, err := c.ExpandKubeConfig() if err != nil { return err @@ -64,11 +67,11 @@ func (c *CLI) Run() error { if err != nil { return fmt.Errorf("Could not find auth-provider: %s", err) } - - client := &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: c.SkipTLSVerify}, - }} - ctx := context.Background() + tlsConfig, err := c.tlsConfig(authProvider) + if err != nil { + return fmt.Errorf("Could not configure TLS: %s", err) + } + client := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}} ctx = context.WithValue(ctx, oauth2.HTTPClient, client) token, err := auth.GetTokenSet(ctx, authProvider.IDPIssuerURL(), authProvider.ClientID(), authProvider.ClientSecret()) if err != nil { @@ -81,3 +84,32 @@ func (c *CLI) Run() error { log.Printf("Updated %s", path) return nil } + +func (c *CLI) tlsConfig(authProvider *kubeconfig.OIDCAuthProviderConfig) (*tls.Config, error) { + p := x509.NewCertPool() + if authProvider.IDPCertificateAuthority() != "" { + b, err := ioutil.ReadFile(authProvider.IDPCertificateAuthority()) + if err != nil { + return nil, fmt.Errorf("Could not read idp-certificate-authority: %s", err) + } + if p.AppendCertsFromPEM(b) != true { + return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority: %s", err) + } + log.Printf("Using CA certificate: %s", authProvider.IDPCertificateAuthority()) + } + if authProvider.IDPCertificateAuthorityData() != "" { + b, err := base64.StdEncoding.DecodeString(authProvider.IDPCertificateAuthorityData()) + if err != nil { + return nil, fmt.Errorf("Could not decode idp-certificate-authority-data: %s", err) + } + if p.AppendCertsFromPEM(b) != true { + return nil, fmt.Errorf("Could not load CA certificate from idp-certificate-authority-data: %s", err) + } + log.Printf("Using CA certificate of idp-certificate-authority-data") + } + cfg := &tls.Config{InsecureSkipVerify: c.SkipTLSVerify} + if len(p.Subjects()) > 0 { + cfg.RootCAs = p + } + return cfg, nil +} diff --git a/integration-test/auth.go b/integration-test/auth.go index 89a05732..d84c72cd 100644 --- a/integration-test/auth.go +++ b/integration-test/auth.go @@ -9,6 +9,7 @@ import ( "log" "math/big" "net/http" + "testing" "time" jwt "github.com/dgrijalva/jwt-go" @@ -29,7 +30,7 @@ type AuthHandler struct { } // NewAuthHandler returns a new AuthHandler. -func NewAuthHandler(issuer string) *AuthHandler { +func NewAuthHandler(t *testing.T, issuer string) *AuthHandler { h := &AuthHandler{ Issuer: issuer, AuthCode: "0b70006b-f62a-4438-aba5-c0b96775d8e5", @@ -44,11 +45,11 @@ func NewAuthHandler(issuer string) *AuthHandler { }) k, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { - log.Fatal(err) + t.Fatalf("Could not generate a key pair: %s", err) } h.IDToken, err = token.SignedString(k) if err != nil { - log.Fatal(err) + t.Fatalf("Could not generate an ID token: %s", err) } h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()) h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(k.N.Bytes()) diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go index b0c6cabe..ef86c832 100644 --- a/integration-test/integration_test.go +++ b/integration-test/integration_test.go @@ -2,60 +2,159 @@ package integration import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "io/ioutil" "net/http" "os" - "strings" "testing" "time" "github.com/int128/kubelogin/cli" ) -type configuration struct { - Issuer string -} +const caCert = "testdata/authserver-ca.crt" +const tlsCert = "testdata/authserver.crt" +const tlsKey = "testdata/authserver.key" func Test(t *testing.T) { - conf := configuration{ + ctx := context.Background() + authServer := &http.Server{ + Addr: "localhost:9000", + Handler: NewAuthHandler(t, "http://localhost:9000"), + } + defer authServer.Shutdown(ctx) + kubeconfig := createKubeconfig(t, &kubeconfigValues{ Issuer: "http://localhost:9000", + }) + defer os.Remove(kubeconfig) + + go listenAndServe(t, authServer) + go authenticate(t, &tls.Config{}) + + c := cli.CLI{ + KubeConfig: kubeconfig, + } + if err := c.Run(ctx); err != nil { + t.Fatal(err) + } + verifyKubeconfig(t, kubeconfig) +} + +func TestWithSkipTLSVerify(t *testing.T) { + ctx := context.Background() + authServer := &http.Server{ + Addr: "localhost:9000", + Handler: NewAuthHandler(t, "https://localhost:9000"), + } + defer authServer.Shutdown(ctx) + kubeconfig := createKubeconfig(t, &kubeconfigValues{ + Issuer: "https://localhost:9000", + }) + defer os.Remove(kubeconfig) + + go listenAndServeTLS(t, authServer) + go authenticate(t, &tls.Config{InsecureSkipVerify: true}) + + c := cli.CLI{ + KubeConfig: kubeconfig, + SkipTLSVerify: true, + } + if err := c.Run(ctx); err != nil { + t.Fatal(err) + } + verifyKubeconfig(t, kubeconfig) +} + +func TestWithCACert(t *testing.T) { + ctx := context.Background() + authServer := &http.Server{ + Addr: "localhost:9000", + Handler: NewAuthHandler(t, "https://localhost:9000"), + } + defer authServer.Shutdown(ctx) + kubeconfig := createKubeconfig(t, &kubeconfigValues{ + Issuer: "https://localhost:9000", + IDPCertificateAuthority: caCert, + }) + defer os.Remove(kubeconfig) + + go listenAndServeTLS(t, authServer) + go authenticate(t, &tls.Config{RootCAs: loadCACert(t)}) + + c := cli.CLI{ + KubeConfig: kubeconfig, + } + if err := c.Run(ctx); err != nil { + t.Fatal(err) } + verifyKubeconfig(t, kubeconfig) +} + +func TestWithCACertData(t *testing.T) { + ctx := context.Background() authServer := &http.Server{ Addr: "localhost:9000", - Handler: NewAuthHandler(conf.Issuer), + Handler: NewAuthHandler(t, "https://localhost:9000"), + } + defer authServer.Shutdown(ctx) + b, err := ioutil.ReadFile(caCert) + if err != nil { + t.Fatal(err) } - defer authServer.Shutdown(context.Background()) - kubeconfig := createKubeconfig(t, conf.Issuer) + kubeconfig := createKubeconfig(t, &kubeconfigValues{ + Issuer: "https://localhost:9000", + IDPCertificateAuthorityData: base64.StdEncoding.EncodeToString(b), + }) defer os.Remove(kubeconfig) - go func() { - if err := authServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - t.Error(err) - } - }() - go func() { - time.Sleep(100 * time.Millisecond) - res, err := http.Get("http://localhost:8000/") - if err != nil { - t.Error(err) - } - if res.StatusCode != 200 { - t.Errorf("StatusCode wants 200 but %d: res=%+v", res.StatusCode, res) - } - }() - c := cli.CLI{KubeConfig: kubeconfig} - if err := c.Run(); err != nil { - t.Fatal(err) - } - - b, err := ioutil.ReadFile(kubeconfig) + go listenAndServeTLS(t, authServer) + go authenticate(t, &tls.Config{RootCAs: loadCACert(t)}) + + c := cli.CLI{ + KubeConfig: kubeconfig, + } + if err := c.Run(ctx); err != nil { + t.Fatal(err) + } + verifyKubeconfig(t, kubeconfig) +} + +func authenticate(t *testing.T, tlsConfig *tls.Config) { + t.Helper() + time.Sleep(100 * time.Millisecond) + client := http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}} + res, err := client.Get("http://localhost:8000/") + if err != nil { + t.Error(err) + return + } + if res.StatusCode != 200 { + t.Errorf("StatusCode wants 200 but %d: res=%+v", res.StatusCode, res) + } +} + +func loadCACert(t *testing.T) *x509.CertPool { + p := x509.NewCertPool() + b, err := ioutil.ReadFile(caCert) if err != nil { t.Fatal(err) } - if strings.Index(string(b), "id-token: ey") == -1 { - t.Errorf("kubeconfig wants id-token but %s", string(b)) + if !p.AppendCertsFromPEM(b) { + t.Fatalf("Could not AppendCertsFromPEM") } - if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 { - t.Errorf("kubeconfig wants refresh-token but %s", string(b)) + return p +} + +func listenAndServe(t *testing.T, s *http.Server) { + if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Fatal(err) + } +} + +func listenAndServeTLS(t *testing.T, s *http.Server) { + if err := s.ListenAndServeTLS(tlsCert, tlsKey); err != nil && err != http.ErrServerClosed { + t.Fatal(err) } } diff --git a/integration-test/kubeconfig.go b/integration-test/kubeconfig.go index be11bd3f..4948840e 100644 --- a/integration-test/kubeconfig.go +++ b/integration-test/kubeconfig.go @@ -3,11 +3,17 @@ package integration import ( "html/template" "io/ioutil" - "log" + "strings" "testing" ) -func createKubeconfig(t *testing.T, issuer string) string { +type kubeconfigValues struct { + Issuer string + IDPCertificateAuthority string + IDPCertificateAuthorityData string +} + +func createKubeconfig(t *testing.T, v *kubeconfigValues) string { t.Helper() f, err := ioutil.TempFile("", "kubeconfig") if err != nil { @@ -18,9 +24,21 @@ func createKubeconfig(t *testing.T, issuer string) string { if err != nil { t.Fatal(err) } - if err := tpl.Execute(f, struct{ Issuer string }{issuer}); err != nil { + if err := tpl.Execute(f, v); err != nil { t.Fatal(err) } - log.Printf("Created %s", f.Name()) return f.Name() } + +func verifyKubeconfig(t *testing.T, kubeconfig string) { + b, err := ioutil.ReadFile(kubeconfig) + if err != nil { + t.Fatal(err) + } + if strings.Index(string(b), "id-token: ey") == -1 { + t.Errorf("kubeconfig wants id-token but %s", string(b)) + } + if strings.Index(string(b), "refresh-token: 44df4c82-5ce7-4260-b54d-1da0d396ef2a") == -1 { + t.Errorf("kubeconfig wants refresh-token but %s", string(b)) + } +} diff --git a/integration-test/testdata/.gitignore b/integration-test/testdata/.gitignore new file mode 100644 index 00000000..3856ac81 --- /dev/null +++ b/integration-test/testdata/.gitignore @@ -0,0 +1,4 @@ +/CA +*.key +*.csr +*.crt diff --git a/integration-test/testdata/Makefile b/integration-test/testdata/Makefile new file mode 100644 index 00000000..9d214351 --- /dev/null +++ b/integration-test/testdata/Makefile @@ -0,0 +1,50 @@ +.PHONY: clean + +all: authserver.crt authserver-ca.crt + +clean: + rm -v authserver* + +authserver-ca.key: + openssl genrsa -out $@ 1024 + +authserver-ca.csr: openssl.cnf authserver-ca.key + openssl req -config openssl.cnf \ + -new \ + -key authserver-ca.key \ + -subj "/CN=Hello CA" \ + -out $@ + openssl req -noout -text -in $@ + +authserver-ca.crt: authserver-ca.csr authserver-ca.key + openssl x509 -req \ + -signkey authserver-ca.key \ + -in authserver-ca.csr \ + -out $@ + openssl x509 -text -in $@ + +authserver.key: + openssl genrsa -out $@ 1024 + +authserver.csr: openssl.cnf authserver.key + openssl req -config openssl.cnf \ + -new \ + -key authserver.key \ + -subj "/CN=localhost" \ + -out $@ + openssl req -noout -text -in $@ + +authserver.crt: openssl.cnf authserver.csr authserver-ca.key authserver-ca.crt + rm -fr ./CA + mkdir -p ./CA + touch CA/index.txt + touch CA/index.txt.attr + echo 00 > CA/serial + openssl ca -config openssl.cnf \ + -extensions v3_req \ + -batch \ + -cert authserver-ca.crt \ + -keyfile authserver-ca.key \ + -in authserver.csr \ + -out $@ + openssl x509 -text -in $@ diff --git a/integration-test/testdata/kubeconfig.yaml b/integration-test/testdata/kubeconfig.yaml index 245ec41c..75a2d730 100644 --- a/integration-test/testdata/kubeconfig.yaml +++ b/integration-test/testdata/kubeconfig.yaml @@ -19,4 +19,10 @@ users: client-id: kubernetes client-secret: a3c508c3-73c9-42e2-ab14-487a1bf67c33 idp-issuer-url: {{ .Issuer }} +#{{ if .IDPCertificateAuthority }} + idp-certificate-authority: {{ .IDPCertificateAuthority }} +#{{ end }} +#{{ if .IDPCertificateAuthorityData }} + idp-certificate-authority-data: {{ .IDPCertificateAuthorityData }} +#{{ end }} name: oidc diff --git a/integration-test/testdata/openssl.cnf b/integration-test/testdata/openssl.cnf new file mode 100644 index 00000000..161d7bec --- /dev/null +++ b/integration-test/testdata/openssl.cnf @@ -0,0 +1,37 @@ +[ ca ] +default_ca = CA_default + +[ CA_default ] +dir = ./CA +certs = $dir +crl_dir = $dir +database = $dir/index.txt +new_certs_dir = $dir +default_md = sha256 +policy = policy_match +serial = $dir/serial +default_days = 365 + +[ policy_match ] +countryName = optional +stateOrProvinceName = optional +organizationName = optional +organizationalUnitName = optional +commonName = supplied +emailAddress = optional + +[ req ] +distinguished_name = req_distinguished_name +req_extensions = v3_req +x509_extensions = v3_ca + +[ req_distinguished_name ] +commonName = Common Name (e.g. server FQDN or YOUR name) + +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = DNS:localhost + +[ v3_ca ] +basicConstraints = CA:true diff --git a/kubeconfig/auth.go b/kubeconfig/auth.go index 86425cf7..828595a4 100644 --- a/kubeconfig/auth.go +++ b/kubeconfig/auth.go @@ -16,6 +16,7 @@ func FindCurrentAuthInfo(config *api.Config) *api.AuthInfo { return config.AuthInfos[context.AuthInfo] } +// ToOIDCAuthProviderConfig converts from api.AuthInfo to OIDCAuthProviderConfig. func ToOIDCAuthProviderConfig(authInfo *api.AuthInfo) (*OIDCAuthProviderConfig, error) { if authInfo.AuthProvider == nil { return nil, fmt.Errorf("auth-provider is not set, did you setup kubectl as listed here: https://github.com/int128/kubelogin#3-setup-kubectl") @@ -26,6 +27,7 @@ func ToOIDCAuthProviderConfig(authInfo *api.AuthInfo) (*OIDCAuthProviderConfig, return (*OIDCAuthProviderConfig)(authInfo.AuthProvider), nil } +// OIDCAuthProviderConfig represents OIDC configuration in the kubeconfig. type OIDCAuthProviderConfig api.AuthProviderConfig // IDPIssuerURL returns the idp-issuer-url. @@ -43,10 +45,22 @@ func (c *OIDCAuthProviderConfig) ClientSecret() string { return c.Config["client-secret"] } +// IDPCertificateAuthority returns the idp-certificate-authority. +func (c *OIDCAuthProviderConfig) IDPCertificateAuthority() string { + return c.Config["idp-certificate-authority"] +} + +// IDPCertificateAuthorityData returns the idp-certificate-authority-data. +func (c *OIDCAuthProviderConfig) IDPCertificateAuthorityData() string { + return c.Config["idp-certificate-authority-data"] +} + +// SetIDToken replaces the id-token. func (c *OIDCAuthProviderConfig) SetIDToken(idToken string) { c.Config["id-token"] = idToken } +// SetRefreshToken replaces the refresh-token. func (c *OIDCAuthProviderConfig) SetRefreshToken(refreshToken string) { c.Config["refresh-token"] = refreshToken } diff --git a/main.go b/main.go index 54343b81..e89499d9 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "os" @@ -12,7 +13,8 @@ func main() { if err != nil { log.Fatal(err) } - if err := c.Run(); err != nil { + ctx := context.Background() + if err := c.Run(ctx); err != nil { log.Fatal(err) } }