From c9b51888a28047b21595129ddafa86dfb8aef828 Mon Sep 17 00:00:00 2001 From: Ignasi Barrera Date: Tue, 20 Feb 2024 13:38:31 +0100 Subject: [PATCH] Generalize test client --- Makefile | 17 +-- e2e/common/testclient.go | 227 ++++++++++++++++++++++++++++++++++ e2e/keycloak/keycloak_test.go | 151 +++------------------- e2e/suite.mk | 10 +- env.mk | 32 +++++ internal/authz/oidc.go | 8 +- internal/config.go | 2 +- 7 files changed, 291 insertions(+), 156 deletions(-) create mode 100644 e2e/common/testclient.go create mode 100644 env.mk diff --git a/Makefile b/Makefile index 55865c5..011b64a 100644 --- a/Makefile +++ b/Makefile @@ -18,22 +18,9 @@ BUILD_OPTS ?= TEST_OPTS ?= TEST_PKGS ?= $(shell go list ./... | grep -v /e2e) OUTDIR ?= bin -TARGETS ?= linux-amd64 linux-arm64 #darwin-amd64 darwin-arm64 -DOCKER_HUB ?= gcr.io/tetrate-internal-containers -DOCKER_TAG ?= $(shell git rev-parse HEAD) -DOCKER_TARGETS ?= linux-amd64 linux-arm64 -DOCKER_BUILDER_NAME ?= $(NAME)-builder - -GO_MODULE := $(shell sed -ne 's/^module //gp' go.mod) - -GOLANGCI_LINT ?= github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2 -GOSIMPORTS ?= github.com/rinchsan/gosimports/cmd/gosimports@v0.3.8 -LICENSER ?= github.com/liamawhite/licenser@v0.6.1-0.20210729145742-be6c77bf6a1f - - -# Pick up any local overrides. --include .makerc +include env.mk # Load common variables +-include .makerc # Pick up any local overrides. ##@ Build targets diff --git a/e2e/common/testclient.go b/e2e/common/testclient.go new file mode 100644 index 0000000..48ee7ee --- /dev/null +++ b/e2e/common/testclient.go @@ -0,0 +1,227 @@ +// Copyright 2024 Tetrate +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package common + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" + + "golang.org/x/net/html" +) + +// LoggingRoundTripper is a http.RoundTripper that logs requests and responses. +type LoggingRoundTripper struct { + LogFunc func(...any) + LogBody bool + Delegate http.RoundTripper +} + +// RoundTrip logs all the requests and responses using the configured settings. +func (l LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if dump, derr := httputil.DumpRequestOut(req, l.LogBody); derr == nil { + l.LogFunc(string(dump)) + } + + res, err := l.Delegate.RoundTrip(req) + + if dump, derr := httputil.DumpResponse(res, l.LogBody); derr == nil { + l.LogFunc(string(dump)) + } + + return res, err +} + +// CookieTracker is a http.RoundTripper that tracks cookies received from the server. +type CookieTracker struct { + Delegate http.RoundTripper + Cookies map[string]*http.Cookie +} + +// RoundTrip tracks the cookies received from the server. +func (c CookieTracker) RoundTrip(req *http.Request) (*http.Response, error) { + res, err := c.Delegate.RoundTrip(req) + if err == nil { + // Track the cookies received from the server + for _, ck := range res.Cookies() { + c.Cookies[ck.Name] = ck + } + } + return res, err +} + +// OIDCTestClient encapsulates a http.Client and keeps track of the state of the OIDC login process. +type OIDCTestClient struct { + http *http.Client // Delegate HTTP client + cookies map[string]*http.Cookie // Cookies received from the server + loginURL string // URL of the IdP where users need to authenticate + loginMethod string // Method (GET/POST) to use when posting the credentials to the IdP + tlsConfig *tls.Config // Custom TLS configuration, if needed +} + +// Option is a functional option for configuring the OIDCTestClient. +type Option func(*OIDCTestClient) error + +// WithCustomCA configures the OIDCTestClient to use a custom CA bundle to verify certificates. +func WithCustomCA(caCert string) Option { + return func(o *OIDCTestClient) error { + caCert, err := os.ReadFile(caCert) + if err != nil { + return err + } + + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + o.tlsConfig = &tls.Config{RootCAs: caCertPool} + return nil + } +} + +// WithLoggingOptions configures the OIDCTestClient to log requests and responses. +func WithLoggingOptions(logFunc func(...any), logBody bool) Option { + return func(o *OIDCTestClient) error { + o.http.Transport = LoggingRoundTripper{ + LogBody: logBody, + LogFunc: logFunc, + Delegate: o.http.Transport, + } + return nil + } +} + +// NewOIDCTestClient creates a new OIDCTestClient. +func NewOIDCTestClient(opts ...Option) (*OIDCTestClient, error) { + var ( + defaultTransport = http.DefaultTransport.(*http.Transport).Clone() + cookies = make(map[string]*http.Cookie) + client = &OIDCTestClient{ + cookies: cookies, + http: &http.Client{ + Transport: CookieTracker{ + Cookies: cookies, + Delegate: defaultTransport, + }, + }, + } + ) + + for _, opt := range opts { + if err := opt(client); err != nil { + return nil, err + } + } + + defaultTransport.TLSClientConfig = client.tlsConfig + + return client, nil +} + +// Get sends a GET request to the specified URL. +func (o *OIDCTestClient) Get(url string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + return o.Send(req) +} + +// Send sends the specified request. +func (o *OIDCTestClient) Send(req *http.Request) (*http.Response, error) { + for _, c := range o.cookies { + req.AddCookie(c) + } + return o.http.Do(req) +} + +// Login logs in to the IdP using the provided credentials. +func (o *OIDCTestClient) Login(formData map[string]string) (*http.Response, error) { + if o.loginURL == "" { + return nil, fmt.Errorf("login URL is not set") + } + data := url.Values{} + for k, v := range formData { + data.Add(k, v) + } + req, err := http.NewRequest(o.loginMethod, o.loginURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return o.Send(req) +} + +// ParseLoginForm parses the HTML response body to get the URL where the login page would post the user-entered credentials. +func (o *OIDCTestClient) ParseLoginForm(responseBody io.ReadCloser, formID string) error { + body, err := io.ReadAll(responseBody) + if err != nil { + return err + } + o.loginURL, o.loginMethod, err = getFormAction(string(body), formID) + return err +} + +// getFormAction returns the action attribute of the form with the specified ID in the given HTML response body. +func getFormAction(responseBody string, formID string) (string, string, error) { + // Parse HTML response + doc, err := html.Parse(strings.NewReader(responseBody)) + if err != nil { + return "", "", err + } + + // Find the form with the specified ID + var findForm func(*html.Node) (string, string) + findForm = func(n *html.Node) (string, string) { + var ( + action string + method = "POST" + ) + if n.Type == html.ElementNode && n.Data == "form" { + for _, attr := range n.Attr { + if attr.Key == "id" && attr.Val == formID { + for _, a := range n.Attr { + if a.Key == "action" { + action = a.Val + } else if a.Key == "method" { + method = strings.ToUpper(a.Val) + } + } + return action, method + } + } + } + + // Recursively search for the form in child nodes + for c := n.FirstChild; c != nil; c = c.NextSibling { + if ra, rm := findForm(c); ra != "" { + return ra, rm + } + } + + return "", "" + } + + action, method := findForm(doc) + if action == "" { + return "", "", fmt.Errorf("form with ID '%s' not found", formID) + } + + return action, method, nil +} diff --git a/e2e/keycloak/keycloak_test.go b/e2e/keycloak/keycloak_test.go index b50705e..656190d 100644 --- a/e2e/keycloak/keycloak_test.go +++ b/e2e/keycloak/keycloak_test.go @@ -15,39 +15,26 @@ package keycloak import ( - "crypto/tls" - "crypto/x509" "fmt" "io" "net" "net/http" - "net/http/httputil" - "net/url" - "os" - "strings" "testing" "github.com/stretchr/testify/require" - "golang.org/x/net/html" - oidcv1 "github.com/tetrateio/authservice-go/config/gen/go/v1/oidc" - "github.com/tetrateio/authservice-go/internal/authz" + "github.com/tetrateio/authservice-go/e2e/common" ) const ( - dockerLocalHost = "host.docker.internal" - authServiceCookiePrefix = "authservice" - keyCloakLoginFormID = "kc-form-login" - testCAFile = "certs/ca.crt" - username = "authservice" - password = "authservice" + dockerLocalHost = "host.docker.internal" + keyCloakLoginFormID = "kc-form-login" + testCAFile = "certs/ca.crt" + username = "authservice" + password = "authservice" ) -var ( - testURL = fmt.Sprintf("https://%s:8443", dockerLocalHost) - authServiceCookieName = authz.GetCookieName(&oidcv1.OIDCConfig{CookieNamePrefix: authServiceCookiePrefix}) - authServiceCookie *http.Cookie -) +var testURL = fmt.Sprintf("https://%s:8443", dockerLocalHost) // skipIfDockerHostNonResolvable skips the test if the Docker host is not resolvable. func skipIfDockerHostNonResolvable(t *testing.T) { @@ -63,125 +50,27 @@ func skipIfDockerHostNonResolvable(t *testing.T) { func TestOIDC(t *testing.T) { skipIfDockerHostNonResolvable(t) - client := testHTTPClient(t) + // Initialize the test OIDC client that will keep track of the state of the OIDC login process + client, err := common.NewOIDCTestClient( + common.WithCustomCA(testCAFile), + common.WithLoggingOptions(t.Log, true), + ) + require.NoError(t, err) - // Send a request. This will be redirected to the IdP login page + // Send a request to the test server. It will be redirected to the IdP login page res, err := client.Get(testURL) require.NoError(t, err) - logResponse(t, res) // Parse the response body to get the URL where the login page would post the user-entered credentials - body, err := io.ReadAll(res.Body) - require.NoError(t, err) - formAction, err := getFormAction(string(body), keyCloakLoginFormID) - require.NoError(t, err) + require.NoError(t, client.ParseLoginForm(res.Body, keyCloakLoginFormID)) - // Generate a request to authenticate against the IdP by posting the credentials - data := url.Values{} - data.Add("username", username) - data.Add("password", password) - data.Add("credentialId", "") - req, err := http.NewRequest("POST", formAction, strings.NewReader(data.Encode())) + // Submit the login form to the IdP. This will authenticate and redirect back to the application + res, err = client.Login(map[string]string{"username": username, "password": password, "credentialId": ""}) require.NoError(t, err) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - for _, c := range res.Cookies() { // Propagate all returned cookies - req.AddCookie(c) - } - // This cookie should have been captured by the client when the AuthService redirected the request to the IdP - req.AddCookie(authServiceCookie) - logRequest(t, req) - - // Post the login credentials. After this, the IdP should redirect to the original request URL - res, err = client.Do(req) - require.NoError(t, err) - logResponse(t, res) - // Verify the response to check that we were redirected to tha target service. - body, err = io.ReadAll(res.Body) + // Verify that we get the expected response from the application + body, err := io.ReadAll(res.Body) require.NoError(t, err) - require.Equal(t, res.StatusCode, http.StatusOK) + require.Equal(t, http.StatusOK, res.StatusCode) require.Contains(t, string(body), "Access allowed") } - -// testHTTPClient returns an HTTP client with custom transport that trusts the CA certificate used in the e2e tests. -func testHTTPClient(t *testing.T) *http.Client { - caCert, err := os.ReadFile(testCAFile) - require.NoError(t, err) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.TLSClientConfig = &tls.Config{RootCAs: caCertPool} - - return &http.Client{ - Transport: transport, - // We intercept the redirect call to the AuthService to be able to save the cookie set - // bu the AuthService and use it when posting the credentials to authenticate to the IdP. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - for _, c := range req.Response.Cookies() { - if c.Name == authServiceCookieName { - authServiceCookie = c - break - } - } - return nil - }, - } -} - -// logRequest logs the request details. -func logRequest(t *testing.T, req *http.Request) { - dump, err := httputil.DumpRequestOut(req, true) - require.NoError(t, err) - t.Log(string(dump)) -} - -// logResponse logs the response details. -func logResponse(t *testing.T, res *http.Response) { - dump, err := httputil.DumpResponse(res, true) - require.NoError(t, err) - t.Log(string(dump)) -} - -// getFormAction returns the action attribute of the form with the specified ID in the given HTML response body. -func getFormAction(responseBody string, formID string) (string, error) { - // Parse HTML response - doc, err := html.Parse(strings.NewReader(responseBody)) - if err != nil { - return "", err - } - - // Find the form with the specified ID - var findForm func(*html.Node) string - findForm = func(n *html.Node) string { - if n.Type == html.ElementNode && n.Data == "form" { - for _, attr := range n.Attr { - if attr.Key == "id" && attr.Val == formID { - // Found the form, return its action attribute - for _, a := range n.Attr { - if a.Key == "action" { - return a.Val - } - } - } - } - } - - // Recursively search for the form in child nodes - for c := n.FirstChild; c != nil; c = c.NextSibling { - if result := findForm(c); result != "" { - return result - } - } - - return "" - } - - action := findForm(doc) - if action == "" { - return "", fmt.Errorf("form with ID '%s' not found", formID) - } - - return action, nil -} diff --git a/e2e/suite.mk b/e2e/suite.mk index a9bfe8e..6cd81b7 100644 --- a/e2e/suite.mk +++ b/e2e/suite.mk @@ -16,13 +16,13 @@ # When adding a suite, create a new directory under e2e/ and add a Makefile that # includes this file. -# Force run of the e2e tests +ROOT := $(shell git rev-parse --show-toplevel) + +include $(ROOT)/env.mk + +# Force run of the e2e tests by default E2E_TEST_OPTS ?= -count=1 -export ARCH := $(shell uname -m) -ifeq ($(ARCH),x86_64) -export ARCH := amd64 -endif .PHONY: e2e e2e: e2e-pre diff --git a/env.mk b/env.mk new file mode 100644 index 0000000..2117b6e --- /dev/null +++ b/env.mk @@ -0,0 +1,32 @@ +# Copyright 2024 Tetrate +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +ROOT := $(shell git rev-parse --show-toplevel) +GO_MODULE := $(shell sed -ne 's/^module //gp' $(ROOT)/go.mod) + +GOLANGCI_LINT ?= github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2 +GOSIMPORTS ?= github.com/rinchsan/gosimports/cmd/gosimports@v0.3.8 +LICENSER ?= github.com/liamawhite/licenser@v0.6.1-0.20210729145742-be6c77bf6a1f + +TARGETS ?= linux-amd64 linux-arm64 #darwin-amd64 darwin-arm64 + +DOCKER_HUB ?= gcr.io/tetrate-internal-containers +DOCKER_TAG ?= $(shell git rev-parse HEAD) +DOCKER_TARGETS ?= linux-amd64 linux-arm64 +DOCKER_BUILDER_NAME ?= $(NAME)-builder + +export ARCH := $(shell uname -m) +ifeq ($(ARCH),x86_64) +export ARCH := amd64 +endif diff --git a/internal/authz/oidc.go b/internal/authz/oidc.go index 25d7467..f175806 100644 --- a/internal/authz/oidc.go +++ b/internal/authz/oidc.go @@ -264,7 +264,7 @@ func (o *oidcHandler) redirectToIDP(ctx context.Context, log telemetry.Logger, }) // add the set-cookie header - cookieName := GetCookieName(o.config) + cookieName := getCookieName(o.config) cookie := generateSetCookieHeader(cookieName, sessionID, 0) deny.Headers = append(deny.Headers, &corev3.HeaderValueOption{ Header: &corev3.HeaderValue{Key: inthttp.HeaderSetCookie, Value: cookie}, @@ -591,7 +591,7 @@ func getCookieDirectives(timeout time.Duration) []string { // getSessionIDFromCookie retrieves the session id from the cookie in the headers. func getSessionIDFromCookie(log telemetry.Logger, headers map[string]string, config *oidcv1.OIDCConfig) string { - cookieName := GetCookieName(config) + cookieName := getCookieName(config) value := headers[inthttp.HeaderCookie] if value == "" { @@ -615,8 +615,8 @@ const ( defaultCookieName = "__Host-authservice-session-id-cookie" ) -// GetCookieName returns the cookie name to use for the session id. -func GetCookieName(config *oidcv1.OIDCConfig) string { +// getCookieName returns the cookie name to use for the session id. +func getCookieName(config *oidcv1.OIDCConfig) string { if prefix := config.GetCookieNamePrefix(); prefix != "" { return prefixCookieName + prefix + suffixCookieName } diff --git a/internal/config.go b/internal/config.go index 65b06b9..0a4a55f 100644 --- a/internal/config.go +++ b/internal/config.go @@ -97,7 +97,7 @@ func (l *LocalConfigFile) Validate() error { } if f.GetOidc() != nil || f.GetOidcOverride() != nil { if hasOidc { - return fmt.Errorf("%w: ionly one OIDC configuration is allowed in a chain", ErrMultipleOIDCConfig) + return fmt.Errorf("%w: only one OIDC configuration is allowed in a chain", ErrMultipleOIDCConfig) } hasOidc = true }