diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 89c22a00..3dfa1f06 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,3 +1,4 @@ + --- Please delete this line and all lines above this line before submitting the PR. Thanks! -- + -**Things to consider while reviewing this PR** + -**Suggested release note for the first release which contains this PR** +**Release note**: + + +```release-note ``` -release-note here -``` diff --git a/.gitignore b/.gitignore index 2aaba826..3a5c3201 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ # goland .idea + +# Intermediate files used by Tilt +/hack/lib/tilt/build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4712345..50581ddd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,4 @@ -exclude: '^generated/' +exclude: '^(generated|hack/lib/tilt/tilt_modules)/' repos: - repo: git://github.com/pre-commit/pre-commit-hooks rev: v3.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 191520fd..29e4d7ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,12 +93,41 @@ docker build . ### Running Integration Tests -```bash -./hack/prepare-for-integration-tests.sh && source /tmp/integration-test-env && go test -v -count 1 ./test/... -``` +1. Install dependencies: -The `./hack/prepare-for-integration-tests.sh` script will create a local -[`kind`](https://kind.sigs.k8s.io/) cluster on which the integration tests will run. + - [`kind`](https://kind.sigs.k8s.io/docs/user/quick-start) + - [`tilt`](https://docs.tilt.dev/install.html) + - [`ytt`](https://carvel.dev/#getting-started) + - [`kubectl`](https://kubernetes.io/docs/tasks/tools/install-kubectl/) + + On macOS, these tools can be installed with [Homebrew](https://brew.sh/): + + ```bash + brew install kind tilt-dev/tap/tilt k14s/tap/ytt kubectl + ``` + +1. Create a local Kubernetes cluster using `kind`: + + ```bash + kind create cluster --image kindest/node:v1.18.8 + ``` + +1. Install Pinniped and supporting dependencies using `tilt`: + + ```bash + ./hack/tilt-up.sh + ``` + + Tilt will continue running and live-updating the Pinniped deployment whenever the code changes. + +1. Run the Pinniped integration tests: + + ```bash + source ./hack/lib/tilt/integration-test.env && go test -v -count 1 ./test/integration + ``` + +To uninstall the test environment, run `./hack/tilt-down.sh`. +To destroy the local Kubernetes cluster, run `kind delete cluster`. ### Observing Tests on the Continuous Integration Environment diff --git a/cmd/pinniped-concierge/main.go b/cmd/pinniped-concierge/main.go index 8ca7bf44..e6fdf991 100644 --- a/cmd/pinniped-concierge/main.go +++ b/cmd/pinniped-concierge/main.go @@ -5,6 +5,7 @@ package main import ( "os" + "time" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/client-go/pkg/version" @@ -19,7 +20,12 @@ func main() { logs.InitLogs() defer logs.FlushLogs() - klog.Infof("Running %s at %#v", rest.DefaultKubernetesUserAgent(), version.Get()) + // Dump out the time since compile (mostly useful for benchmarking our local development cycle latency). + var timeSinceCompile time.Duration + if buildDate, err := time.Parse(time.RFC3339, version.Get().BuildDate); err == nil { + timeSinceCompile = time.Since(buildDate).Round(time.Second) + } + klog.Infof("Running %s at %#v (%s since build)", rest.DefaultKubernetesUserAgent(), version.Get(), timeSinceCompile) ctx := genericapiserver.SetupSignalContext() diff --git a/cmd/pinniped/cmd/alpha.go b/cmd/pinniped/cmd/alpha.go new file mode 100644 index 00000000..db27150f --- /dev/null +++ b/cmd/pinniped/cmd/alpha.go @@ -0,0 +1,22 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +//nolint: gochecknoglobals +var alphaCmd = &cobra.Command{ + Use: "alpha", + Short: "alpha", + Long: "alpha subcommands (syntax or flags are still subject to change)", + SilenceUsage: true, // do not print usage message when commands fail + Hidden: true, +} + +//nolint: gochecknoinits +func init() { + rootCmd.AddCommand(alphaCmd) +} diff --git a/cmd/pinniped/cmd/cobra_util.go b/cmd/pinniped/cmd/cobra_util.go new file mode 100644 index 00000000..9b153a72 --- /dev/null +++ b/cmd/pinniped/cmd/cobra_util.go @@ -0,0 +1,15 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import "github.com/spf13/cobra" + +// mustMarkRequired marks the given flags as required on the provided cobra.Command. If any of the names are wrong, it panics. +func mustMarkRequired(cmd *cobra.Command, flags ...string) { + for _, flag := range flags { + if err := cmd.MarkFlagRequired(flag); err != nil { + panic(err) + } + } +} diff --git a/cmd/pinniped/cmd/cobra_util_test.go b/cmd/pinniped/cmd/cobra_util_test.go new file mode 100644 index 00000000..b44e8550 --- /dev/null +++ b/cmd/pinniped/cmd/cobra_util_test.go @@ -0,0 +1,21 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestMustMarkRequired(t *testing.T) { + require.NotPanics(t, func() { mustMarkRequired(&cobra.Command{}) }) + require.NotPanics(t, func() { + cmd := &cobra.Command{} + cmd.Flags().String("known-flag", "", "") + mustMarkRequired(cmd, "known-flag") + }) + require.Panics(t, func() { mustMarkRequired(&cobra.Command{}, "unknown-flag") }) +} diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go index 0660c556..8ed99b0d 100644 --- a/cmd/pinniped/cmd/get_kubeconfig.go +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -85,15 +85,12 @@ func (c *getKubeConfigCommand) Command() *cobra.Command { `), } cmd.Flags().StringVar(&c.flags.token, "token", "", "Credential to include in the resulting kubeconfig output (Required)") - err := cmd.MarkFlagRequired("token") - if err != nil { - panic(err) - } cmd.Flags().StringVar(&c.flags.kubeconfig, "kubeconfig", c.flags.kubeconfig, "Path to the kubeconfig file") cmd.Flags().StringVar(&c.flags.contextOverride, "kubeconfig-context", c.flags.contextOverride, "Kubeconfig context override") cmd.Flags().StringVar(&c.flags.namespace, "pinniped-namespace", c.flags.namespace, "Namespace in which Pinniped was installed") cmd.Flags().StringVar(&c.flags.idpType, "idp-type", c.flags.idpType, "Identity provider type (e.g., 'webhook')") cmd.Flags().StringVar(&c.flags.idpName, "idp-name", c.flags.idpType, "Identity provider name") + mustMarkRequired(cmd, "token") return cmd } diff --git a/cmd/pinniped/cmd/login.go b/cmd/pinniped/cmd/login.go new file mode 100644 index 00000000..2c0ad082 --- /dev/null +++ b/cmd/pinniped/cmd/login.go @@ -0,0 +1,21 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +//nolint: gochecknoglobals +var loginCmd = &cobra.Command{ + Use: "login", + Short: "login", + Long: "Login to a Pinniped server", + SilenceUsage: true, // do not print usage message when commands fail +} + +//nolint: gochecknoinits +func init() { + alphaCmd.AddCommand(loginCmd) +} diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go new file mode 100644 index 00000000..e9287d43 --- /dev/null +++ b/cmd/pinniped/cmd/login_oidc.go @@ -0,0 +1,126 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + "github.com/coreos/go-oidc" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/oidc/pkce" + "go.pinniped.dev/internal/oidc/state" +) + +//nolint: gochecknoinits +func init() { + loginCmd.AddCommand((&oidcLoginParams{ + generateState: state.Generate, + generatePKCE: pkce.Generate, + openURL: browser.OpenURL, + }).cmd()) +} + +type oidcLoginParams struct { + // These parameters capture CLI flags. + issuer string + clientID string + listenPort uint16 + scopes []string + skipBrowser bool + usePKCE bool + debugAuthCode bool + + // These parameters capture dependencies that we want to mock during testing. + generateState func() (state.State, error) + generatePKCE func() (pkce.Code, error) + openURL func(string) error +} + +func (o *oidcLoginParams) cmd() *cobra.Command { + cmd := cobra.Command{ + Args: cobra.NoArgs, + Use: "oidc --issuer ISSUER --client-id CLIENT_ID", + Short: "Login using an OpenID Connect provider", + RunE: o.runE, + SilenceUsage: true, + } + cmd.Flags().StringVar(&o.issuer, "issuer", "", "OpenID Connect issuer URL.") + cmd.Flags().StringVar(&o.clientID, "client-id", "", "OpenID Connect client ID.") + cmd.Flags().Uint16Var(&o.listenPort, "listen-port", 48095, "TCP port for localhost listener (authorization code flow only).") + cmd.Flags().StringSliceVar(&o.scopes, "scopes", []string{"offline_access", "openid", "email", "profile"}, "OIDC scopes to request during login.") + cmd.Flags().BoolVar(&o.skipBrowser, "skip-browser", false, "Skip opening the browser (just print the URL).") + cmd.Flags().BoolVar(&o.usePKCE, "use-pkce", true, "Use Proof Key for Code Exchange (RFC 7636) during login.") + mustMarkRequired(&cmd, "issuer", "client-id") + + // TODO: temporary + cmd.Flags().BoolVar(&o.debugAuthCode, "debug-auth-code-exchange", true, "Debug the authorization code exchange (temporary).") + _ = cmd.Flags().MarkHidden("debug-auth-code-exchange") + + return &cmd +} + +func (o *oidcLoginParams) runE(cmd *cobra.Command, args []string) error { + metadata, err := oidc.NewProvider(cmd.Context(), o.issuer) + if err != nil { + return fmt.Errorf("could not perform OIDC discovery for %q: %w", o.issuer, err) + } + + cfg := oauth2.Config{ + ClientID: o.clientID, + Endpoint: metadata.Endpoint(), + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", o.listenPort), + Scopes: o.scopes, + } + + authCodeOptions := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} + + stateParam, err := o.generateState() + if err != nil { + return fmt.Errorf("could not generate OIDC state parameter: %w", err) + } + + var pkceCode pkce.Code + if o.usePKCE { + pkceCode, err = o.generatePKCE() + if err != nil { + return fmt.Errorf("could not generate OIDC PKCE parameter: %w", err) + } + authCodeOptions = append(authCodeOptions, pkceCode.Challenge(), pkceCode.Method()) + } + + // If --skip-browser was passed, override the default browser open function with a Printf() call. + openURL := o.openURL + if o.skipBrowser { + openURL = func(s string) error { + cmd.PrintErr("Please log in: ", s, "\n") + return nil + } + } + + authorizeURL := cfg.AuthCodeURL(stateParam.String(), authCodeOptions...) + if err := openURL(authorizeURL); err != nil { + return fmt.Errorf("could not open browser (run again with --skip-browser?): %w", err) + } + + // TODO: this temporary so we can complete the auth code exchange manually + + if o.debugAuthCode { + cmd.PrintErr(here.Docf(` + DEBUG INFO: + Token URL: %s + State: %s + PKCE: %s + `, + cfg.Endpoint.TokenURL, + stateParam, + pkceCode.Verifier(), + )) + } + + return nil +} diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go new file mode 100644 index 00000000..9258cc30 --- /dev/null +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -0,0 +1,220 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/oidc/pkce" + "go.pinniped.dev/internal/oidc/state" +) + +func TestLoginOIDCCommand(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + wantError bool + wantStdout string + wantStderr string + }{ + { + name: "help flag passed", + args: []string{"--help"}, + wantStdout: here.Doc(` + Login using an OpenID Connect provider + + Usage: + oidc --issuer ISSUER --client-id CLIENT_ID [flags] + + Flags: + --client-id string OpenID Connect client ID. + -h, --help help for oidc + --issuer string OpenID Connect issuer URL. + --listen-port uint16 TCP port for localhost listener (authorization code flow only). (default 48095) + --scopes strings OIDC scopes to request during login. (default [offline_access,openid,email,profile]) + --skip-browser Skip opening the browser (just print the URL). + --use-pkce Use Proof Key for Code Exchange (RFC 7636) during login. (default true) + `), + }, + { + name: "missing required flags", + args: []string{}, + wantError: true, + wantStdout: here.Doc(` + Error: required flag(s) "client-id", "issuer" not set + `), + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cmd := (&oidcLoginParams{}).cmd() + require.NotNil(t, cmd) + + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(tt.args) + err := cmd.Execute() + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +} + +func TestOIDCLoginRunE(t *testing.T) { + t.Parallel() + + // Start a server that returns 500 errors. + brokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + })) + t.Cleanup(brokenServer.Close) + + // Start a server that returns successfully. + var validResponse string + validServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(validResponse)) + })) + t.Cleanup(validServer.Close) + validResponse = strings.ReplaceAll(here.Docf(` + { + "issuer": "${ISSUER}", + "authorization_endpoint": "${ISSUER}/auth", + "token_endpoint": "${ISSUER}/token", + "jwks_uri": "${ISSUER}/keys", + "userinfo_endpoint": "${ISSUER}/userinfo", + "grant_types_supported": ["authorization_code","refresh_token"], + "response_types_supported": ["code"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid","email","groups","profile","offline_access"], + "token_endpoint_auth_methods_supported": ["client_secret_basic"], + "claims_supported": ["aud","email","email_verified","exp","iat","iss","locale","name","sub"] + } + `), "${ISSUER}", validServer.URL) + validServerURL, err := url.Parse(validServer.URL) + require.NoError(t, err) + + tests := []struct { + name string + params oidcLoginParams + wantError string + wantStdout string + wantStderr string + wantStderrAuthURL func(*testing.T, *url.URL) + }{ + { + name: "broken discovery", + params: oidcLoginParams{ + issuer: brokenServer.URL, + }, + wantError: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: Internal Server Error\n", brokenServer.URL), + }, + { + name: "broken state generation", + params: oidcLoginParams{ + issuer: validServer.URL, + generateState: func() (state.State, error) { return "", fmt.Errorf("some error generating a state value") }, + }, + wantError: "could not generate OIDC state parameter: some error generating a state value", + }, + { + name: "broken PKCE generation", + params: oidcLoginParams{ + issuer: validServer.URL, + generateState: func() (state.State, error) { return "test-state", nil }, + usePKCE: true, + generatePKCE: func() (pkce.Code, error) { return "", fmt.Errorf("some error generating a PKCE code") }, + }, + wantError: "could not generate OIDC PKCE parameter: some error generating a PKCE code", + }, + { + name: "broken browser open", + params: oidcLoginParams{ + issuer: validServer.URL, + generateState: func() (state.State, error) { return "test-state", nil }, + usePKCE: true, + generatePKCE: func() (pkce.Code, error) { return "test-pkce", nil }, + openURL: func(_ string) error { return fmt.Errorf("some browser open error") }, + }, + wantError: "could not open browser (run again with --skip-browser?): some browser open error", + }, + { + name: "success without PKCE", + params: oidcLoginParams{ + issuer: validServer.URL, + clientID: "test-client-id", + generateState: func() (state.State, error) { return "test-state", nil }, + usePKCE: false, + listenPort: 12345, + skipBrowser: true, + }, + wantStderrAuthURL: func(t *testing.T, actual *url.URL) { + require.Equal(t, validServerURL.Host, actual.Host) + require.Equal(t, "/auth", actual.Path) + require.Equal(t, "", actual.Fragment) + require.Equal(t, url.Values{ + "access_type": []string{"offline"}, + "client_id": []string{"test-client-id"}, + "redirect_uri": []string{"http://localhost:12345/callback"}, + "response_type": []string{"code"}, + "state": []string{"test-state"}, + }, actual.Query()) + }, + wantStderr: "Please log in: \n", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + cmd := cobra.Command{RunE: tt.params.runE, SilenceUsage: true, SilenceErrors: true} + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + err := cmd.Execute() + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + } else { + require.NoError(t, err) + } + + if tt.wantStderrAuthURL != nil { + var urls []string + redacted := regexp.MustCompile(`http://\S+`).ReplaceAllStringFunc(stderr.String(), func(url string) string { + urls = append(urls, url) + return "" + }) + require.Lenf(t, urls, 1, "expected to find authorization URL in stderr:\n%s", stderr.String()) + authURL, err := url.Parse(urls[0]) + require.NoError(t, err, "invalid authorization URL") + tt.wantStderrAuthURL(t, authURL) + + // Replace the stderr buffer with the redacted version. + stderr.Reset() + stderr.WriteString(redacted) + } + + require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") + require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") + }) + } +} diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 2a1d5e9e..b946ca7c 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -40,11 +40,15 @@ data: apiService: (@= data.values.app_name + "-api" @) kubeCertAgent: namePrefix: (@= data.values.app_name + "-kube-cert-agent-" @) + (@ if data.values.kube_cert_agent_image: @) + image: (@= data.values.kube_cert_agent_image @) + (@ else: @) (@ if data.values.image_digest: @) image: (@= data.values.image_repo + "@" + data.values.image_digest @) (@ else: @) image: (@= data.values.image_repo + ":" + data.values.image_tag @) (@ end @) + (@ end @) (@ if data.values.image_pull_dockerconfigjson: @) imagePullSecrets: - image-pull-secret diff --git a/deploy/values.yaml b/deploy/values.yaml index b579a212..0a25f7ee 100644 --- a/deploy/values.yaml +++ b/deploy/values.yaml @@ -15,6 +15,11 @@ image_repo: docker.io/getpinniped/pinniped-server image_digest: #! e.g. sha256:f3c4fdfd3ef865d4b97a1fd295d94acc3f0c654c46b6f27ffad5cf80216903c8 image_tag: latest +#! Optionally specify a different image for the "kube-cert-agent" pod which is scheduled +#! on the control plane. This image needs only to include `sleep` and `cat` binaries. +#! By default, the same image specified for image_repo/image_digest/image_tag will be re-used. +kube_cert_agent_image: + #! Specifies a secret to be used when pulling the above container image. #! Can be used when the above image_repo is a private registry. #! Typically the value would be the output of: kubectl create secret docker-registry x --docker-server=https://example.io --docker-username="USERNAME" --docker-password="PASSWORD" --dry-run=client -o json | jq -r '.data[".dockerconfigjson"]' diff --git a/doc/demo.md b/doc/demo.md index 88b72839..52d9e766 100644 --- a/doc/demo.md +++ b/doc/demo.md @@ -9,17 +9,15 @@ 1. An identity provider of a type supported by Pinniped as described in [doc/architecture.md](../doc/architecture.md). - Don't have an identity provider of a type supported by Pinniped handy? - Start by installing `local-user-authenticator` on the same cluster where you would like to try Pinniped + Don't have an identity provider of a type supported by Pinniped handy? No problem, there is a demo identity provider + available. Start by installing local-user-authenticator on the same cluster where you would like to try Pinniped by following the directions in [deploy-local-user-authenticator/README.md](../deploy-local-user-authenticator/README.md). See below for an example of deploying this on kind. 1. A kubeconfig where the current context points to the cluster and has admin-like privileges on that cluster. -## Steps - -### Overview +## Overview Installing and trying Pinniped on any cluster will consist of the following general steps. See the next section below for a more specific example of installing onto a local kind cluster, including the exact commands to use for that case. @@ -29,7 +27,23 @@ for a more specific example of installing onto a local kind cluster, including t 1. Generate a kubeconfig using the Pinniped CLI. Run `pinniped get-kubeconfig --help` for more information. 1. Run `kubectl` commands using the generated kubeconfig. Pinniped will automatically be used for authentication during those commands. -### Steps to Deploy the Latest Release on kind Using local-user-authenticator as the Identity Provider +## Example of Deploying on kind + +[kind](https://kind.sigs.k8s.io) is a tool for creating and managing Kubernetes clusters on your local machine +which uses Docker containers as the cluster's "nodes". This is a convenient way to try out Pinniped on a local +non-production cluster. + +The following steps will deploy the latest release of Pinniped on kind using the local-user-authenticator component +as the identity provider. + + +

+Pinniped Installation Demo +

1. Install the tools required for the following steps. @@ -65,7 +79,8 @@ for a more specific example of installing onto a local kind cluster, including t pinniped_version=v0.2.0 ``` -1. Deploy the `local-user-authenticator` app. +1. Deploy the local-user-authenticator app. This is a demo identity provider. In production, you would use your + real identity provider, and therefore would not need to deploy or configure local-user-authenticator. ```bash kubectl apply -f https://github.com/vmware-tanzu/pinniped/releases/download/$pinniped_version/install-local-user-authenticator.yaml @@ -76,7 +91,7 @@ for a more specific example of installing onto a local kind cluster, including t see [deploy-local-user-authenticator/README.md](../deploy-local-user-authenticator/README.md) for instructions on how to deploy using `ytt`. -1. Create a test user. +1. Create a test user named `pinny-the-seal` in the local-user-authenticator identity provider. ```bash kubectl create secret generic pinny-the-seal \ @@ -85,7 +100,7 @@ for a more specific example of installing onto a local kind cluster, including t --from-literal=passwordHash=$(htpasswd -nbBC 10 x password123 | sed -e "s/^x://") ``` -1. Fetch the auto-generated CA bundle for the `local-user-authenticator`'s HTTP TLS endpoint. +1. Fetch the auto-generated CA bundle for the local-user-authenticator's HTTP TLS endpoint. ```bash kubectl get secret local-user-authenticator-tls-serving-certificate --namespace local-user-authenticator \ @@ -103,7 +118,7 @@ for a more specific example of installing onto a local kind cluster, including t If you would prefer to customize the available options, please see [deploy/README.md](../deploy/README.md) for instructions on how to deploy using `ytt`. -1. Create a `WebhookIdentityProvider` object to configure Pinniped to authenticate using `local-user-authenticator`. +1. Create a `WebhookIdentityProvider` object to configure Pinniped to authenticate using local-user-authenticator. ```bash cat < {}'.format(restart_file))] + + docker_build(ref, context, entrypoint=entrypoint_with_entr, dockerfile_contents=df, + live_update=live_update, **cleaned_kwargs) diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 3a1a04fa..7985140a 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -9,6 +9,14 @@ set -euo pipefail # # Helper functions # +TILT_MODE=${TILT_MODE:-no} +function tilt_mode() { + if [[ "$TILT_MODE" == "yes" ]]; then + return 0 + fi + return 1 +} + function log_note() { GREEN='\033[0;32m' NC='\033[0m' @@ -99,73 +107,77 @@ if [ "$(kubectl version --client=true --short | cut -d '.' -f 2)" -lt 18 ]; then exit 1 fi -if [[ "$clean_kind" == "yes" ]]; then - log_note "Deleting running kind clusters to prepare from a clean slate..." - kind delete cluster -fi - -# -# Setup kind and build the app -# -log_note "Checking for running kind clusters..." -if ! kind get clusters | grep -q -e '^kind$'; then - log_note "Creating a kind cluster..." - # single-node.yaml exposes node port 31234 as localhost:12345 - kind create cluster --config "$pinniped_path/hack/lib/kind-config/single-node.yaml" -else - if ! kubectl cluster-info | grep master | grep -q 127.0.0.1; then - log_error "Seems like your kubeconfig is not targeting a local cluster." - log_error "Exiting to avoid accidentally running tests against a real cluster." - exit 1 +if ! tilt_mode; then + if [[ "$clean_kind" == "yes" ]]; then + log_note "Deleting running kind clusters to prepare from a clean slate..." + kind delete cluster fi -fi -registry="docker.io" -repo="test/build" -registry_repo="$registry/$repo" -tag=$(uuidgen) # always a new tag to force K8s to reload the image on redeploy - -if [[ "$skip_build" == "yes" ]]; then - most_recent_tag=$(docker images "$repo" --format "{{.Tag}}" | head -1) - if [[ -n "$most_recent_tag" ]]; then - tag="$most_recent_tag" - do_build=no + # + # Setup kind and build the app + # + log_note "Checking for running kind clusters..." + if ! kind get clusters | grep -q -e '^kind$'; then + log_note "Creating a kind cluster..." + # single-node.yaml exposes node port 31234 as localhost:12345 + kind create cluster --config "$pinniped_path/hack/lib/kind-config/single-node.yaml" + else + if ! kubectl cluster-info | grep master | grep -q 127.0.0.1; then + log_error "Seems like your kubeconfig is not targeting a local cluster." + log_error "Exiting to avoid accidentally running tests against a real cluster." + exit 1 + fi + fi + + registry="docker.io" + repo="test/build" + registry_repo="$registry/$repo" + tag=$(uuidgen) # always a new tag to force K8s to reload the image on redeploy + + if [[ "$skip_build" == "yes" ]]; then + most_recent_tag=$(docker images "$repo" --format "{{.Tag}}" | head -1) + if [[ -n "$most_recent_tag" ]]; then + tag="$most_recent_tag" + do_build=no + else + # Oops, there was no previous build. Need to build anyway. + do_build=yes + fi else - # Oops, there was no previous build. Need to build anyway. do_build=yes fi -else - do_build=yes + + registry_repo_tag="${registry_repo}:${tag}" + + if [[ "$do_build" == "yes" ]]; then + # Rebuild the code + log_note "Docker building the app..." + docker build . --tag "$registry_repo_tag" + fi + + # Load it into the cluster + log_note "Loading the app's container image into the kind cluster..." + kind load docker-image "$registry_repo_tag" + + manifest=/tmp/manifest.yaml + + # + # Deploy local-user-authenticator + # + pushd deploy-local-user-authenticator >/dev/null + + log_note "Deploying the local-user-authenticator app to the cluster..." + ytt --file . \ + --data-value "image_repo=$registry_repo" \ + --data-value "image_tag=$tag" >"$manifest" + + kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema. + kapp deploy --yes --app local-user-authenticator --diff-changes --file "$manifest" + + popd >/dev/null + fi -registry_repo_tag="${registry_repo}:${tag}" - -if [[ "$do_build" == "yes" ]]; then - # Rebuild the code - log_note "Docker building the app..." - docker build . --tag "$registry_repo_tag" -fi - -# Load it into the cluster -log_note "Loading the app's container image into the kind cluster..." -kind load docker-image "$registry_repo_tag" - -manifest=/tmp/manifest.yaml - -# -# Deploy local-user-authenticator -# -pushd deploy-local-user-authenticator >/dev/null - -log_note "Deploying the local-user-authenticator app to the cluster..." -ytt --file . \ - --data-value "image_repo=$registry_repo" \ - --data-value "image_tag=$tag" >"$manifest" - -kubectl apply --dry-run=client -f "$manifest" # Validate manifest schema. -kapp deploy --yes --app local-user-authenticator --diff-changes --file "$manifest" - -popd >/dev/null test_username="test-username" test_groups="test-group-0,test-group-1" @@ -230,19 +242,24 @@ webhook_url="https://local-user-authenticator.local-user-authenticator.svc/authe webhook_ca_bundle="$(kubectl get secret local-user-authenticator-tls-serving-certificate --namespace local-user-authenticator -o 'jsonpath={.data.caCertificate}')" discovery_url="$(TERM=dumb kubectl cluster-info | awk '/Kubernetes master/ {print $NF}')" -pushd deploy >/dev/null +if ! tilt_mode; then + # + # Deploy Pinniped + # + pushd deploy >/dev/null -log_note "Deploying the Pinniped app to the cluster..." -ytt --file . \ - --data-value "app_name=$app_name" \ - --data-value "namespace=$namespace" \ - --data-value "image_repo=$registry_repo" \ - --data-value "image_tag=$tag" \ - --data-value "discovery_url=$discovery_url" >"$manifest" + log_note "Deploying the Pinniped app to the cluster..." + ytt --file . \ + --data-value "app_name=$app_name" \ + --data-value "namespace=$namespace" \ + --data-value "image_repo=$registry_repo" \ + --data-value "image_tag=$tag" \ + --data-value "discovery_url=$discovery_url" >"$manifest" -kapp deploy --yes --app "$app_name" --diff-changes --file "$manifest" + kapp deploy --yes --app "$app_name" --diff-changes --file "$manifest" -popd >/dev/null + popd >/dev/null +fi # # Create the environment file @@ -283,7 +300,10 @@ log_note log_note 'Want to run integration tests in GoLand? Copy/paste this "Environment" value for GoLand run configurations:' log_note " ${goland_vars}PINNIPED_CLUSTER_CAPABILITY_FILE=${kind_capabilities_file}" log_note -log_note "You can rerun this script to redeploy local production code changes while you are working." -log_note -log_note "To delete the deployments, run 'kapp delete -a local-user-authenticator -y && kapp delete -a pinniped -y'." -log_note "When you're finished, use 'kind delete cluster' to tear down the cluster." + +if ! tilt_mode; then + log_note "You can rerun this script to redeploy local production code changes while you are working." + log_note + log_note "To delete the deployments, run 'kapp delete -a local-user-authenticator -y && kapp delete -a pinniped -y'." + log_note "When you're finished, use 'kind delete cluster' to tear down the cluster." +fi diff --git a/hack/tilt-down.sh b/hack/tilt-down.sh new file mode 100755 index 00000000..95992122 --- /dev/null +++ b/hack/tilt-down.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Copyright 2020 the Pinniped contributors. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" +cd "${ROOT}" +exec tilt down -f ./hack/lib/tilt/Tiltfile diff --git a/hack/tilt-up.sh b/hack/tilt-up.sh new file mode 100755 index 00000000..acbf5a3f --- /dev/null +++ b/hack/tilt-up.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Copyright 2020 the Pinniped contributors. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" +cd "${ROOT}" +exec tilt up -f ./hack/lib/tilt/Tiltfile --stream diff --git a/internal/oidc/pkce/pkce.go b/internal/oidc/pkce/pkce.go new file mode 100644 index 00000000..309b3d4d --- /dev/null +++ b/internal/oidc/pkce/pkce.go @@ -0,0 +1,45 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package pkce + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "io" + + "github.com/pkg/errors" + "golang.org/x/oauth2" +) + +// Generate generates a new random PKCE code. +func Generate() (Code, error) { return generate(rand.Reader) } + +func generate(rand io.Reader) (Code, error) { + var buf [32]byte + if _, err := io.ReadFull(rand, buf[:]); err != nil { + return "", errors.WithMessage(err, "could not generate PKCE code") + } + return Code(hex.EncodeToString(buf[:])), nil +} + +// Code implements the basic options required for RFC 7636: Proof Key for Code Exchange (PKCE). +type Code string + +// Challenge returns the OAuth2 auth code parameter for sending the PKCE code challenge. +func (p *Code) Challenge() oauth2.AuthCodeOption { + b := sha256.Sum256([]byte(*p)) + return oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(b[:])) +} + +// Method returns the OAuth2 auth code parameter for sending the PKCE code challenge method. +func (p *Code) Method() oauth2.AuthCodeOption { + return oauth2.SetAuthURLParam("code_challenge_method", "S256") +} + +// Verifier returns the OAuth2 auth code parameter for sending the PKCE code verifier. +func (p *Code) Verifier() oauth2.AuthCodeOption { + return oauth2.SetAuthURLParam("code_verifier", string(*p)) +} diff --git a/internal/oidc/pkce/pkce_test.go b/internal/oidc/pkce/pkce_test.go new file mode 100644 index 00000000..be611378 --- /dev/null +++ b/internal/oidc/pkce/pkce_test.go @@ -0,0 +1,42 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package pkce + +import ( + "bytes" + "encoding/base64" + "net/url" + "testing" + + "golang.org/x/oauth2" + + "github.com/stretchr/testify/require" +) + +func TestPKCE(t *testing.T) { + p, err := Generate() + require.NoError(t, err) + + cfg := oauth2.Config{} + authCodeURL, err := url.Parse(cfg.AuthCodeURL("", p.Challenge(), p.Method())) + require.NoError(t, err) + + // The code_challenge must be 256 bits (sha256) encoded as unpadded urlsafe base64. + chal, err := base64.RawURLEncoding.DecodeString(authCodeURL.Query().Get("code_challenge")) + require.NoError(t, err) + require.Len(t, chal, 32) + + // The code_challenge_method must be a fixed value. + require.Equal(t, "S256", authCodeURL.Query().Get("code_challenge_method")) + + // The code_verifier param should be 64 hex characters. + verifyURL, err := url.Parse(cfg.AuthCodeURL("", p.Verifier())) + require.NoError(t, err) + require.Regexp(t, `\A[0-9a-f]{64}\z`, verifyURL.Query().Get("code_verifier")) + + var empty bytes.Buffer + p, err = generate(&empty) + require.EqualError(t, err, "could not generate PKCE code: EOF") + require.Empty(t, p) +} diff --git a/internal/oidc/state/state.go b/internal/oidc/state/state.go new file mode 100644 index 00000000..7d70e51b --- /dev/null +++ b/internal/oidc/state/state.go @@ -0,0 +1,37 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package state + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "io" + + "github.com/pkg/errors" +) + +// Generate generates a new random state parameter of an appropriate size. +func Generate() (State, error) { return generate(rand.Reader) } + +func generate(rand io.Reader) (State, error) { + var buf [16]byte + if _, err := io.ReadFull(rand, buf[:]); err != nil { + return "", errors.WithMessage(err, "could not generate random state") + } + return State(hex.EncodeToString(buf[:])), nil +} + +// State implements some utilities for working with OAuth2 state parameters. +type State string + +// String returns the string encoding of this state value. +func (s *State) String() string { + return string(*s) +} + +// Validate the returned state (from a callback parameter). Returns true iff the state is valid. +func (s *State) Valid(returnedState string) bool { + return subtle.ConstantTimeCompare([]byte(returnedState), []byte(*s)) == 1 +} diff --git a/internal/oidc/state/state_test.go b/internal/oidc/state/state_test.go new file mode 100644 index 00000000..ff181839 --- /dev/null +++ b/internal/oidc/state/state_test.go @@ -0,0 +1,25 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package state + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestState(t *testing.T) { + s, err := Generate() + require.NoError(t, err) + require.Len(t, s, 32) + require.Len(t, s.String(), 32) + require.True(t, s.Valid(string(s))) + require.False(t, s.Valid(string(s)+"x")) + + var empty bytes.Buffer + s, err = generate(&empty) + require.EqualError(t, err, "could not generate random state: EOF") + require.Empty(t, s) +}