From 8a7da8333809f4962ea24e71e25c3318cecd4ed8 Mon Sep 17 00:00:00 2001 From: Hidetake Iwata Date: Mon, 20 Aug 2018 23:53:39 +0900 Subject: [PATCH] Add integration test --- .circleci/config.yml | 1 + cli/cli.go | 83 ++++++++++++++ integration-test/auth_handler.go | 103 ++++++++++++++++++ integration-test/integration_test.go | 81 ++++++++++++++ integration-test/testdata/kubeconfig.yaml | 22 ++++ integration-test/testdata/oidc-discovery.json | 85 +++++++++++++++ integration-test/testdata/oidc-jwks.json | 12 ++ integration-test/testdata/oidc-token.json | 7 ++ main.go | 72 +----------- 9 files changed, 398 insertions(+), 68 deletions(-) create mode 100644 cli/cli.go create mode 100644 integration-test/auth_handler.go create mode 100644 integration-test/integration_test.go create mode 100644 integration-test/testdata/kubeconfig.yaml create mode 100644 integration-test/testdata/oidc-discovery.json create mode 100644 integration-test/testdata/oidc-jwks.json create mode 100644 integration-test/testdata/oidc-token.json diff --git a/.circleci/config.yml b/.circleci/config.yml index df3e165..9b65219 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: go test -v ./... release: docker: diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..e2620c3 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,83 @@ +package cli + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net/http" + + "github.com/int128/kubelogin/authn" + "github.com/int128/kubelogin/kubeconfig" + flags "github.com/jessevdk/go-flags" + homedir "github.com/mitchellh/go-homedir" + "golang.org/x/oauth2" +) + +// Parse parses command line arguments and returns a CLI instance. +func Parse(args []string) (*CLI, error) { + var cli CLI + parser := flags.NewParser(&cli, flags.HelpFlag) + args, err := parser.Parse() + if err != nil { + return nil, err + } + if len(args) > 0 { + return nil, fmt.Errorf("Too many argument") + } + return &cli, nil +} + +// CLI represents an interface of this command. +type CLI struct { + KubeConfig string `long:"kubeconfig" default:"~/.kube/config" env:"KUBECONFIG" description:"Path to the kubeconfig file"` + SkipTLSVerify bool `long:"insecure-skip-tls-verify" env:"KUBELOGIN_INSECURE_SKIP_TLS_VERIFY" description:"If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure"` + // CertificateAuthority string `long:"certificate-authority" env:"KUBELOGIN_CERTIFICATE_AUTHORITY" description:"Path to a cert file for the certificate authority"` +} + +// ExpandKubeConfig returns an expanded KubeConfig path. +func (c *CLI) ExpandKubeConfig() (string, error) { + d, err := homedir.Expand(c.KubeConfig) + if err != nil { + return "", fmt.Errorf("Could not expand %s", c.KubeConfig) + } + return d, nil +} + +// Run performs this command. +func (c *CLI) Run() error { + path, err := c.ExpandKubeConfig() + if err != nil { + return err + } + log.Printf("Reading %s", path) + cfg, err := kubeconfig.Load(path) + if err != nil { + return fmt.Errorf("Could not load kubeconfig: %s", err) + } + log.Printf("Using current context: %s", cfg.CurrentContext) + authInfo := kubeconfig.FindCurrentAuthInfo(cfg) + if authInfo == nil { + return fmt.Errorf("Could not find current context: %s", cfg.CurrentContext) + } + authProvider, err := kubeconfig.ToOIDCAuthProviderConfig(authInfo) + 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() + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + token, err := authn.GetTokenSet(ctx, authProvider.IDPIssuerURL(), authProvider.ClientID(), authProvider.ClientSecret()) + if err != nil { + return fmt.Errorf("Authentication error: %s", err) + } + + authProvider.SetIDToken(token.IDToken) + authProvider.SetRefreshToken(token.RefreshToken) + kubeconfig.Write(cfg, path) + log.Printf("Updated %s", path) + return nil +} diff --git a/integration-test/auth_handler.go b/integration-test/auth_handler.go new file mode 100644 index 0000000..89a0573 --- /dev/null +++ b/integration-test/auth_handler.go @@ -0,0 +1,103 @@ +package integration + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "fmt" + "html/template" + "log" + "math/big" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" +) + +// AuthHandler provides the stub handler for OIDC authentication. +type AuthHandler struct { + // Values in templates + Issuer string + AuthCode string + IDToken string + PrivateKey struct{ N, E string } + + // Response templates + discoveryJSON *template.Template + tokenJSON *template.Template + jwksJSON *template.Template +} + +// NewAuthHandler returns a new AuthHandler. +func NewAuthHandler(issuer string) *AuthHandler { + h := &AuthHandler{ + Issuer: issuer, + AuthCode: "0b70006b-f62a-4438-aba5-c0b96775d8e5", + discoveryJSON: template.Must(template.ParseFiles("testdata/oidc-discovery.json")), + tokenJSON: template.Must(template.ParseFiles("testdata/oidc-token.json")), + jwksJSON: template.Must(template.ParseFiles("testdata/oidc-jwks.json")), + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.StandardClaims{ + Issuer: h.Issuer, + Audience: "kubernetes", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }) + k, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + log.Fatal(err) + } + h.IDToken, err = token.SignedString(k) + if err != nil { + log.Fatal(err) + } + h.PrivateKey.E = base64.RawURLEncoding.EncodeToString(big.NewInt(int64(k.E)).Bytes()) + h.PrivateKey.N = base64.RawURLEncoding.EncodeToString(k.N.Bytes()) + return h +} + +func (s *AuthHandler) serveHTTP(w http.ResponseWriter, r *http.Request) error { + m := r.Method + p := r.URL.Path + log.Printf("[auth-server] %s %s", m, r.RequestURI) + switch { + case m == "GET" && p == "/.well-known/openid-configuration": + w.Header().Add("Content-Type", "application/json") + if err := s.discoveryJSON.Execute(w, s); err != nil { + return err + } + case m == "GET" && p == "/protocol/openid-connect/auth": + // Authentication Response + // http://openid.net/specs/openid-connect-core-1_0.html#AuthResponse + q := r.URL.Query() + to := fmt.Sprintf("%s?state=%s&code=%s", q.Get("redirect_uri"), q.Get("state"), s.AuthCode) + http.Redirect(w, r, to, 302) + case m == "POST" && p == "/protocol/openid-connect/token": + // Token Response + // http://openid.net/specs/openid-connect-core-1_0.html#TokenResponse + if err := r.ParseForm(); err != nil { + return err + } + if s.AuthCode != r.Form.Get("code") { + return fmt.Errorf("code wants %s but %s", s.AuthCode, r.Form.Get("code")) + } + w.Header().Add("Content-Type", "application/json") + if err := s.tokenJSON.Execute(w, s); err != nil { + return err + } + case m == "GET" && p == "/protocol/openid-connect/certs": + w.Header().Add("Content-Type", "application/json") + if err := s.jwksJSON.Execute(w, s); err != nil { + return err + } + default: + http.Error(w, "Not Found", 404) + } + return nil +} + +func (s *AuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if err := s.serveHTTP(w, r); err != nil { + log.Printf("[auth-server] Error: %s", err) + w.WriteHeader(500) + } +} diff --git a/integration-test/integration_test.go b/integration-test/integration_test.go new file mode 100644 index 0000000..62ffe5a --- /dev/null +++ b/integration-test/integration_test.go @@ -0,0 +1,81 @@ +package integration + +import ( + "context" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "testing" + "text/template" + "time" + + "github.com/int128/kubelogin/cli" +) + +type configuration struct { + Issuer string +} + +func Test(t *testing.T) { + conf := configuration{ + Issuer: "http://localhost:9000", + } + authServer := &http.Server{ + Addr: "localhost:9000", + Handler: NewAuthHandler(conf.Issuer), + } + defer authServer.Shutdown(context.Background()) + kubeconfig := createKubeconfig(t, conf) + 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) + 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)) + } +} + +func createKubeconfig(t *testing.T, conf configuration) string { + t.Helper() + f, err := ioutil.TempFile("", "kubeconfig") + if err != nil { + t.Fatal(err) + } + defer f.Close() + tpl, err := template.ParseFiles("testdata/kubeconfig.yaml") + if err != nil { + t.Fatal(err) + } + if err := tpl.Execute(f, conf); err != nil { + t.Fatal(err) + } + log.Printf("Created %s", f.Name()) + return f.Name() +} diff --git a/integration-test/testdata/kubeconfig.yaml b/integration-test/testdata/kubeconfig.yaml new file mode 100644 index 0000000..245ec41 --- /dev/null +++ b/integration-test/testdata/kubeconfig.yaml @@ -0,0 +1,22 @@ +apiVersion: v1 +kind: Config +clusters: + - cluster: + server: https://api.hello.k8s.example.com + name: hello.k8s.local +contexts: +- context: + cluster: hello.k8s.local + user: hello.k8s.local + name: hello.k8s.local +current-context: hello.k8s.local +preferences: {} +users: +- name: hello.k8s.local + user: + auth-provider: + config: + client-id: kubernetes + client-secret: a3c508c3-73c9-42e2-ab14-487a1bf67c33 + idp-issuer-url: {{ .Issuer }} + name: oidc diff --git a/integration-test/testdata/oidc-discovery.json b/integration-test/testdata/oidc-discovery.json new file mode 100644 index 0000000..5a4727e --- /dev/null +++ b/integration-test/testdata/oidc-discovery.json @@ -0,0 +1,85 @@ +{ + "issuer": "{{ .Issuer }}", + "authorization_endpoint": "{{ .Issuer }}/protocol/openid-connect/auth", + "token_endpoint": "{{ .Issuer }}/protocol/openid-connect/token", + "token_introspection_endpoint": "{{ .Issuer }}/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "{{ .Issuer }}/protocol/openid-connect/userinfo", + "end_session_endpoint": "{{ .Issuer }}/protocol/openid-connect/logout", + "jwks_uri": "{{ .Issuer }}/protocol/openid-connect/certs", + "check_session_iframe": "{{ .Issuer }}/protocol/openid-connect/login-status-iframe.html", + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "client_credentials" + ], + "response_types_supported": [ + "code", + "none", + "id_token", + "token", + "id_token token", + "code id_token", + "code token", + "code id_token token" + ], + "subject_types_supported": [ + "public", + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "RS256" + ], + "userinfo_signing_alg_values_supported": [ + "RS256" + ], + "request_object_signing_alg_values_supported": [ + "none", + "RS256" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post" + ], + "registration_endpoint": "{{ .Issuer }}/clients-registrations/openid-connect", + "token_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "RS256" + ], + "claims_supported": [ + "sub", + "iss", + "auth_time", + "name", + "given_name", + "family_name", + "preferred_username", + "email" + ], + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": false, + "scopes_supported": [ + "openid", + "offline_access", + "phone", + "address", + "email", + "profile" + ], + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "tls_client_certificate_bound_access_tokens": true +} diff --git a/integration-test/testdata/oidc-jwks.json b/integration-test/testdata/oidc-jwks.json new file mode 100644 index 0000000..2dcb092 --- /dev/null +++ b/integration-test/testdata/oidc-jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "xxx", + "n": "{{ .PrivateKey.N }}", + "e": "{{ .PrivateKey.E }}" + } + ] +} diff --git a/integration-test/testdata/oidc-token.json b/integration-test/testdata/oidc-token.json new file mode 100644 index 0000000..46a9dc6 --- /dev/null +++ b/integration-test/testdata/oidc-token.json @@ -0,0 +1,7 @@ +{ + "access_token": "7eaae8ab-8f69-45d9-ab7c-73560cd9444d", + "token_type": "Bearer", + "refresh_token": "44df4c82-5ce7-4260-b54d-1da0d396ef2a", + "expires_in": 3600, + "id_token": "{{ .IDToken }}" +} diff --git a/main.go b/main.go index 3dc70d6..54343b8 100644 --- a/main.go +++ b/main.go @@ -1,82 +1,18 @@ package main import ( - "context" - "crypto/tls" - "fmt" "log" - "net/http" + "os" - "github.com/int128/kubelogin/authn" - "github.com/int128/kubelogin/kubeconfig" - flags "github.com/jessevdk/go-flags" - homedir "github.com/mitchellh/go-homedir" - "golang.org/x/oauth2" + "github.com/int128/kubelogin/cli" ) -type options struct { - KubeConfig string `long:"kubeconfig" default:"~/.kube/config" env:"KUBECONFIG" description:"Path to the kubeconfig file"` - SkipTLSVerify bool `long:"insecure-skip-tls-verify" env:"KUBELOGIN_INSECURE_SKIP_TLS_VERIFY" description:"If set, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure"` - // CertificateAuthority string `long:"certificate-authority" env:"KUBELOGIN_CERTIFICATE_AUTHORITY" description:"Path to a cert file for the certificate authority"` -} - -func (o *options) ExpandKubeConfig() (string, error) { - d, err := homedir.Expand(o.KubeConfig) - if err != nil { - return "", fmt.Errorf("Could not expand %s", o.KubeConfig) - } - return d, nil -} - -func parseOptions() (*options, error) { - var o options - parser := flags.NewParser(&o, flags.HelpFlag) - args, err := parser.Parse() - if err != nil { - return nil, err - } - if len(args) > 0 { - return nil, fmt.Errorf("Too many argument") - } - return &o, nil -} - func main() { - opts, err := parseOptions() + c, err := cli.Parse(os.Args) if err != nil { log.Fatal(err) } - path, err := opts.ExpandKubeConfig() - if err != nil { + if err := c.Run(); err != nil { log.Fatal(err) } - log.Printf("Reading %s", path) - cfg, err := kubeconfig.Load(path) - if err != nil { - log.Fatalf("Could not load kubeconfig: %s", err) - } - log.Printf("Using current context: %s", cfg.CurrentContext) - authInfo := kubeconfig.FindCurrentAuthInfo(cfg) - if authInfo == nil { - log.Fatalf("Could not find current context: %s", cfg.CurrentContext) - } - authProvider, err := kubeconfig.ToOIDCAuthProviderConfig(authInfo) - if err != nil { - log.Fatalf("Could not find auth-provider: %s", err) - } - - client := &http.Client{Transport: &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: opts.SkipTLSVerify}, - }} - ctx := context.Background() - ctx = context.WithValue(ctx, oauth2.HTTPClient, client) - token, err := authn.GetTokenSet(ctx, authProvider.IDPIssuerURL(), authProvider.ClientID(), authProvider.ClientSecret()) - if err != nil { - log.Fatalf("Authentication error: %s", err) - } - - authProvider.SetIDToken(token.IDToken) - authProvider.SetRefreshToken(token.RefreshToken) - kubeconfig.Write(cfg, path) - log.Printf("Updated %s", path) }