diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30560a4..9e00f6f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,6 +69,8 @@ jobs: e2e: needs: check runs-on: ubuntu-latest + env: + E2E_TEST_OPTS: -v -count=1 steps: - uses: docker/setup-qemu-action@v3 with: @@ -78,4 +80,17 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod + # Configure the Docker hostname to be able to access the host from the containers + - name: Add Docker internal host to /etc/hosts + run: echo "127.0.0.1 host.docker.internal" | sudo tee -a /etc/hosts - run: make docker e2e + + - name: Upload e2e logs on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-logs + path: | + e2e/**/logs/* + e2e/**/certs/* + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 939e534..9e37753 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,28 +1,29 @@ -# How to Contribute - -We'd love to accept your patches and contributions to this project. There are -just a few small guidelines you need to follow. - -## Contributor License Agreement - -Contributions to this project must be accompanied by a Contributor License -Agreement. You (or your employer) retain the copyright to your contribution; -this simply gives us permission to use and redistribute your contributions as -part of the project. Head over to to see -your current agreements on file or to sign a new one. - -You generally only need to submit a CLA once, so if you've already submitted one -(even if it was for a different project), you probably don't need to do it -again. - -## Code reviews - -All submissions, including submissions by project members, require review. We -use GitHub pull requests for this purpose. Consult -[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more -information on using pull requests. - -## Community Guidelines - -This project follows [Google's Open Source Community -Guidelines](https://opensource.google.com/conduct/). +# Contributing + +We welcome contributions from the community. Please read the following guidelines carefully to +maximize the chances of your PR being merged. + +## Coding Style + +* To ensure your change passes format checks, run `make check`. To format your files, you can run `make format`. +* We follow standard Go table-driven tests and use the `testify` library to assert correctness. + To verify all tests pass, you can run `make test`. + +## Code Reviews + +* The pull request title should describe what the change does and not embed issue numbers. + The pull request should only be blank when the change is minor. Any feature should include + a description of the change and what motivated it. If the change or design changes through + review, please keep the title and description updated accordingly. +* A single approval is sufficient to merge. If a reviewer asks for + changes in a PR they should be addressed before the PR is merged, + even if another reviewer has already approved the PR. +* During the review, address the comments and commit the changes + _without_ squashing the commits. This facilitates incremental reviews + since the reviewer does not go through all the code again to find out + what has changed since the last review. When a change goes out of sync with main, + please rebase and force push, keeping the original commits where practical. +* Commits are squashed prior to merging a pull request, using the title + as commit message by default. Maintainers may request contributors to + edit the pull request tite to ensure that it remains descriptive as a + commit message. Alternatively, maintainers may change the commit message directly. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..14a3a8e --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,92 @@ +# Developer guide + +All the build targets are self-explanatory and can be listed with: + +```bash +$ make help +``` + +The following software and tools are needed to build the project and run the tests: + +* [Go](https://golang.org/dl/) +* [GNU make](https://www.gnu.org/software/make/) +* [Docker](https://docs.docker.com/get-docker/) + + +## Generating the API code + +The configuration options are defined in the [config](config/) directory using [Protocol Buffers](https://protobuf.dev/). +To generate the configuration API code after doing changes to the `.proto` files, run: + +```bash +$ make generate +``` + +There is no need to run `generate` after checking out the code; it's only needed when changes are made to +the `.proto` files. + + +## Building the binary + +To build the binary simply run: + +```bash +$ make build # Builds a dynamically linked binary +$ make static # Builds a statically linked binary +``` + +The resulting binaries will be in the `bin/` directory. You can play with the +`TARGETS` environment variable to control the operating systems and architectures you want +to build for. + + +## Docker image + +To build the Docker image, run: + +```bash +$ make docker # Build a single-arch Docker image tagged with "-latest-$arch" +$ make docker-push # Build and push the multi-arch Docker images to the registry +``` + +This will automatically build the required binaries and create a Docker image with them. + +The `make docker` target will produce images that are suitable to be used in the `e2e` tests. +The `make docker-push` target will produce multi-arch images and push them to the registry. +You can use the `DOCKER_TARGETS` environment variable to control the operating systems and architectures +you want to build the Docker images for. + + +## Testing + +The main testing targets are: + +```bash +$ make test # Run the unit tests +$ make lint # Run the linters +$ make e2e # Run the end-to-end tests +``` + +### e2e tests + +The end-to-end tests are found in the [e2e](e2e/) directory. Each subdirectory contains a test suite +that can be run independently. The `make e2e` target will run all the test suites by default. To run +individual suites, simply run `make e2e/`. For example: + +```bash +$ make e2e # Run all the e2e suites +$ make e2e/keycloak # Run the 'keycloak' e2e suite + +# Examples with custom test options +$ E2E_TEST_OPTS="-v -count=1" make e2e # Run all the e2e suites with verbose output and no caching +$ E2E_PRESERVE_LOGS=true make e2e # Preserve the container logs even if tests succeed +``` + +> [!Note] +> The end-to-end tests use the `authservice` Docker image, and it **must be up-to-date**. +> Make sure you run `make clean docker` before running the tests + +The end-to-end tests use Docker Compose to set up the required infrastructure before running the tests. +Once the tests are done, the infrastructure is automatically torn down if tests pass, or left running +if tests fail, to facilitate troubleshooting. Container logs are also captured upon test failure, to +aid in debugging. diff --git a/README.md b/README.md index dab55fb..023a9ac 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,9 @@ An implementation of [Envoy](https://envoyproxy.io) [External Authorization](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/ext_authz_filter), focused on delivering authN/Z solutions for [Istio](https://istio.io) and [Kubernetes](https://kubernetes.io). +This project is a port of the [istio-ecosystem/authservice](https://github.com/istio-ecosystem/authservice) +project from C++ to Go. + ## Introduction `authservice` helps delegate the [OIDC Authorization Code Grant Flow](https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) @@ -14,40 +17,26 @@ including [Authentication Policy](https://istio.io/docs/tasks/security/authn-pol Together, they allow developers to protect their APIs and web apps without any application code required. Some of the features it provides: -- Transparent login and logout - - Retrieves OAuth2 Access tokens, ID tokens, and refresh tokens -- Fine-grained control over which url paths are protected -- Session management - - Configuration of session lifetime and idle timeouts - - Refreshes expired tokens automatically -- Compatible with any standard OIDC Provider -- Supports multiple OIDC Providers for same application -- Trusts custom CA certs when talking to OIDC Providers -- Works either at the sidecar or gateway level - -## Using the `authservice` docker image +* Transparent login and logout + * Retrieves OAuth2 Access tokens, ID tokens, and refresh tokens +* Fine-grained control over which url paths are protected +* Session management + * Configuration of session lifetime and idle timeouts + * Refreshes expired tokens automatically +* Compatible with any standard OIDC Provider +* Supports multiple OIDC Providers for same application +* Trusts custom CA certs when talking to OIDC Providers +* Works either at the sidecar or gateway level -The `authservice` images are hosted on [authservice's GitHub Package Registry](https://github.com/istio-ecosystem/authservice/packages). ## How does authservice work? -We have created a [flowchart](https://miro.com/app/board/o9J_kvus6b4=/) to explain how authservice makes decisions at different points in the login lifecycle. +[This flowchart](https://miro.com/app/board/o9J_kvus6b4=/) explains how `authservice` +makes decisions at different points in the login lifecycle. ## Contributing -To get started: - -- [Contributing guide](./CONTRIBUTING.md) - -## Roadmap -See the [authservice github Project](https://github.com/istio-ecosystem/authservice/projects/1) - -Additional features being considered: -- A more Istio-integrated experience of deploying/configuring/enabling `authservice` - (e.g.: extending Istio Authentication Policy to include `authservice` configs). - -## Contributing & Contact +Contributions are very welcome! Please read the [Contributing guidelines](CONTRIBUTING.md) +to get started. -We welcome feedback and contributions. Aside from submitting Github issues/PRs, you can reach out at `#oidc-proposal` -or `#security` channel on [Istio’s Slack](https://istio.slack.com/) workspace -([here's how to join](https://istio.io/about/community/join/)). +Detailed development instructions can be found in the [Development guide](DEVELOPMENT.md). diff --git a/e2e/Makefile b/e2e/Makefile index 25c8da8..e96ef6f 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -13,7 +13,7 @@ # limitations under the License. -SUITES := mock redis +SUITES := mock redis keycloak .PHONY: e2e e2e: $(SUITES:%=e2e/%) ## Run all e2e tests diff --git a/e2e/keycloak/.gitignore b/e2e/keycloak/.gitignore new file mode 100644 index 0000000..df91287 --- /dev/null +++ b/e2e/keycloak/.gitignore @@ -0,0 +1 @@ +certs/ diff --git a/e2e/keycloak/Makefile b/e2e/keycloak/Makefile new file mode 100644 index 0000000..9762a95 --- /dev/null +++ b/e2e/keycloak/Makefile @@ -0,0 +1,55 @@ +# 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. + +CERTS_DIR := certs +NAME := host.docker.internal +SHELL := bash + +.PHONY: e2e-pre +e2e-pre:: gen + +include ../suite.mk + + +gen: gen/ca gen/certs ## Generates the CA and certificates + @chmod -R a+r $(CERTS_DIR) + +$(CERTS_DIR): + @mkdir -p $(CERTS_DIR) + +.PHONY: gen/ca +gen/ca: $(CERTS_DIR) ## Generates the CA + @echo "Generating CA" + @openssl genrsa -out "$(CERTS_DIR)/ca.key" 4096 + @openssl req -x509 -new -sha256 -nodes -days 365 -key "$(CERTS_DIR)/ca.key" -out "$(CERTS_DIR)/ca.crt" \ + -subj "/C=US/ST=California/O=Tetrate/OU=Engineering/CN=$(NAME)" \ + -addext "basicConstraints=critical,CA:true,pathlen:1" \ + -addext "keyUsage=critical,digitalSignature,nonRepudiation,keyEncipherment,keyCertSign" \ + -addext "subjectAltName=DNS:$(NAME)" + + +.PHONY: gen/certs +gen/certs: $(CERTS_DIR) ## Generates the certificates + @echo "Generating $(NAME) cert" + @openssl genrsa -out "$(CERTS_DIR)/server.key" 2048 + @openssl req -new -sha256 -key "$(CERTS_DIR)/server.key" -out "$(CERTS_DIR)/server.csr" \ + -subj "/C=US/ST=California/O=Tetrate/OU=Engineering/CN=$(NAME)" \ + -addext "subjectAltName=DNS:$(NAME)" + @openssl x509 -req -sha256 -days 120 -in "$(CERTS_DIR)/server.csr" -out "$(CERTS_DIR)/server.crt" \ + -CA "$(CERTS_DIR)/ca.crt" -CAkey "$(CERTS_DIR)/ca.key" -CAcreateserial -CAserial $(CERTS_DIR)/ca.srl \ + -extfile <(printf "subjectAltName=DNS:$(NAME)") + +.PHONY: clean +clean:: + @rm -rf $(CERTS_DIR) diff --git a/e2e/keycloak/README.md b/e2e/keycloak/README.md new file mode 100644 index 0000000..308c5d0 --- /dev/null +++ b/e2e/keycloak/README.md @@ -0,0 +1,22 @@ +# Keycloak e2e tests + +The Keycloak e2e test suite contains tests that use the Keycloak OIDC provider. A +Keycloak instance is deployed and configured in the Docker environment as the backend +OIDC provider. + +The setup is performed in the [setup-keycloak.sh](setup-keycloak.sh) script, which +configures the default `master` realm with: + +* A user named `authservice` with a predefined password. +* A client named `authservice` with a predefined secret. + +The user and client will be used in the e2e tests to verify the entire Authorization Code flow. + +## Docker host name resolution + +The Keycloak end-to-end tests rely on the host `host.docker.internal` to resolve to the host machine, +so you may need to add an entry to your `/etc/hosts` file to make it work. For example: + +```bash +$ echo "127.0.0.1 host.docker.internal" >> /etc/hosts +``` diff --git a/e2e/keycloak/authz-config.json b/e2e/keycloak/authz-config.json new file mode 100644 index 0000000..eb8710d --- /dev/null +++ b/e2e/keycloak/authz-config.json @@ -0,0 +1,28 @@ +{ + "listen_address": "0.0.0.0", + "listen_port": 10003, + "log_level": "debug", + "chains": [ + { + "name": "keycloak", + "filters": [ + { + "oidc": { + "configuration_uri": "http://host.docker.internal:8080/realms/master/.well-known/openid-configuration", + "callback_uri": "https://host.docker.internal:8443/callback", + "client_id": "authservice", + "client_secret": "authservice-secret", + "cookie_name_prefix": "authservice", + "id_token": { + "preamble": "Bearer", + "header": "authorization" + }, + "redis_session_store_config": { + "server_uri": "redis://redis:6379" + } + } + } + ] + } + ] +} diff --git a/e2e/keycloak/docker-compose.yaml b/e2e/keycloak/docker-compose.yaml new file mode 100644 index 0000000..09ee671 --- /dev/null +++ b/e2e/keycloak/docker-compose.yaml @@ -0,0 +1,103 @@ +# 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. + +version: "3.9" + +services: + # This is the main backend service. It returns a fixed HTTP 200 response. + # It is configured to serve on port 443, and to use the ext-authz filter + # to intercept all requests. + envoy: + depends_on: + ext-authz: + condition: service_started + image: envoyproxy/envoy:v1.29-latest + platform: linux/${ARCH:-amd64} + command: -c /etc/envoy/envoy-config.yaml --log-level warning + ports: + - "8443:443" + volumes: + - type: bind + source: envoy-config.yaml + target: /etc/envoy/envoy-config.yaml + - type: bind + source: certs + target: /etc/envoy/certs + + # This is the `authservice` image that should be up-to-date when running the tests. + ext-authz: + depends_on: + setup-keycloak: + condition: service_completed_successfully + image: gcr.io/tetrate-internal-containers/authservice:latest-${ARCH:-amd64} + platform: linux/${ARCH:-amd64} + volumes: + - type: bind + source: authz-config.json + target: /etc/authservice/config.json + extra_hosts: # Required when running on Linux + - "host.docker.internal:host-gateway" + + # Redis container to be used to persist the session information and OIDC authorization + # state. + redis: + image: redis:7.2.4 + platform: linux/${ARCH:-amd64} + + # Keycloak container to be used as the OIDC provider. The tests will use the `master` realm + keycloak: + image: quay.io/keycloak/keycloak:23.0.6 + platform: linux/${ARCH:-amd64} + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + ports: + - "8080:8080" + command: start-dev --import-realm + volumes: + - type: bind + source: setup-keycloak.sh + target: /opt/jboss/startup-scripts/setup-keycloak.sh + healthcheck: + test: /opt/keycloak/bin/kcadm.sh get realms/master --server http://localhost:8080 --realm master --user admin --password admin + interval: 5s + timeout: 2s + retries: 10 + start_period: 5s + extra_hosts: # Required when running on Linux + - "host.docker.internal:host-gateway" + + # Container to configure the Keycloak instance with a User and Client application + setup-keycloak: + depends_on: + keycloak: + condition: service_healthy + image: quay.io/keycloak/keycloak:23.0.6 + platform: linux/${ARCH:-amd64} + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + entrypoint: /opt/setup-keycloak.sh + volumes: + - type: bind + source: setup-keycloak.sh + target: /opt/setup-keycloak.sh + # Healthcheck to make sure the created client has been successfully created, and that other services + # can depend on + healthcheck: + test: /opt/keycloak/bin/kcreg.sh get authservice --server http://keycloak:8080 --realm master --user admin --password admin + interval: 2s + timeout: 2s + retries: 10 + start_period: 2s diff --git a/e2e/keycloak/envoy-config.yaml b/e2e/keycloak/envoy-config.yaml new file mode 100644 index 0000000..e87e5ff --- /dev/null +++ b/e2e/keycloak/envoy-config.yaml @@ -0,0 +1,88 @@ +# 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. + +static_resources: + listeners: + - name: http + address: + socket_address: + address: 0.0.0.0 + port_value: 443 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + route_config: + name: http + virtual_hosts: + - name: http + domains: ["*"] + routes: + - match: + prefix: "/" + direct_response: + status: 200 + body: + inline_string: "Access allowed\n" + http_filters: + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + grpc_service: + envoy_grpc: + cluster_name: ext_authz + timeout: 300s + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: /etc/envoy/certs/server.crt + private_key: + filename: /etc/envoy/certs/server.key + validation_context: + trusted_ca: + filename: /etc/envoy/certs/ca.crt + + clusters: + - name: ext_authz + connect_timeout: 0.25s + type: LOGICAL_DNS + lb_policy: ROUND_ROBIN + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: ext_authz + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: ext-authz + port_value: 10003 diff --git a/e2e/keycloak/keycloak_test.go b/e2e/keycloak/keycloak_test.go new file mode 100644 index 0000000..b50705e --- /dev/null +++ b/e2e/keycloak/keycloak_test.go @@ -0,0 +1,187 @@ +// 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 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" +) + +const ( + dockerLocalHost = "host.docker.internal" + authServiceCookiePrefix = "authservice" + 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 +) + +// skipIfDockerHostNonResolvable skips the test if the Docker host is not resolvable. +func skipIfDockerHostNonResolvable(t *testing.T) { + _, err := net.ResolveIPAddr("ip", dockerLocalHost) + if err != nil { + t.Fatalf("skipping test: %[1]q is not resolvable\n"+ + "Please configure your environment so that %[1]q resolves to the address of the Docker host machine.\n"+ + "For example: echo \"127.0.0.1 %[1]s\" >>/etc/hosts", + dockerLocalHost) + } +} + +func TestOIDC(t *testing.T) { + skipIfDockerHostNonResolvable(t) + + client := testHTTPClient(t) + + // Send a request. This 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) + + // 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())) + 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) + require.NoError(t, err) + require.Equal(t, res.StatusCode, http.StatusOK) + 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/keycloak/setup-keycloak.sh b/e2e/keycloak/setup-keycloak.sh new file mode 100755 index 0000000..fb8e245 --- /dev/null +++ b/e2e/keycloak/setup-keycloak.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# 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. + +KEYCLOAK_SERVER="http://keycloak:8080" +REALM="master" +USERNAME=authservice +PASSWORD=authservice +CLIENT_ID=authservice +CLIENT_SECRET=authservice-secret +REDIRECT_URL=https://host.docker.internal:8443/callback + +set -ex + +/opt/keycloak/bin/kcadm.sh create users \ + -s username="${USERNAME}" \ + -s enabled=true \ + --server "${KEYCLOAK_SERVER}" \ + --realm "${REALM}" \ + --user "${KEYCLOAK_ADMIN}" \ + --password "${KEYCLOAK_ADMIN_PASSWORD}" + +/opt/keycloak/bin/kcadm.sh set-password \ + --username "${USERNAME}" \ + --new-password "${PASSWORD}" \ + --server "${KEYCLOAK_SERVER}" \ + --realm "${REALM}" \ + --user "${KEYCLOAK_ADMIN}" \ + --password "${KEYCLOAK_ADMIN_PASSWORD}" + +/opt/keycloak/bin/kcreg.sh create \ + -s clientId="${CLIENT_ID}" \ + -s secret="${CLIENT_SECRET}" \ + -s "redirectUris=[\"${REDIRECT_URL}\"]" \ + -s consentRequired=false \ + --server "${KEYCLOAK_SERVER}" \ + --realm "${REALM}" \ + --user "${KEYCLOAK_ADMIN}" \ + --password "${KEYCLOAK_ADMIN_PASSWORD}" diff --git a/e2e/mock/README.md b/e2e/mock/README.md new file mode 100644 index 0000000..b6e323d --- /dev/null +++ b/e2e/mock/README.md @@ -0,0 +1,7 @@ +# Mock e2e tests + +The `mock` e2e test suite contains tests that use the `mock` OIDC provider. +The suite is mostly used to verify the correct behavior of the different configuration +options without making real requests to an OIDC provider. + +It can be used for rapid prorotyping and development of new features. diff --git a/e2e/mock/docker-compose.yaml b/e2e/mock/docker-compose.yaml index dc74c4d..e7ff2e1 100644 --- a/e2e/mock/docker-compose.yaml +++ b/e2e/mock/docker-compose.yaml @@ -16,6 +16,9 @@ version: "3.9" services: envoy: + # This is the main backend service. It returns a fixed HTTP 200 response. + # It is configured to serve on port 80, and to use the ext-authz filter + # to intercept all requests. image: envoyproxy/envoy:v1.29-latest platform: linux/${ARCH:-amd64} command: -c /etc/envoy/envoy-config.yaml --log-level warning @@ -26,6 +29,7 @@ services: source: envoy-config.yaml target: /etc/envoy/envoy-config.yaml + # This is the `authservice` image that should be up-to-date when running the tests. ext-authz: image: gcr.io/tetrate-internal-containers/authservice:latest-${ARCH:-amd64} platform: linux/${ARCH:-amd64} diff --git a/e2e/mock/envoy-config.yaml b/e2e/mock/envoy-config.yaml index c86b026..8ac57ac 100644 --- a/e2e/mock/envoy-config.yaml +++ b/e2e/mock/envoy-config.yaml @@ -59,7 +59,11 @@ static_resources: connect_timeout: 0.25s type: LOGICAL_DNS lb_policy: ROUND_ROBIN - http2_protocol_options: {} + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} load_assignment: cluster_name: ext_authz endpoints: diff --git a/e2e/redis/README.md b/e2e/redis/README.md new file mode 100644 index 0000000..0036c56 --- /dev/null +++ b/e2e/redis/README.md @@ -0,0 +1,5 @@ +# Redis e2e tests + +The Redis e2e test suite contains tests that verify the correct behavior of the Redis +session store for the OIDC providers. It targets the `SessionStore` interface directly +and verifies the contents of the Redis database on each operation. diff --git a/e2e/suite.mk b/e2e/suite.mk index f93f5f6..a9bfe8e 100644 --- a/e2e/suite.mk +++ b/e2e/suite.mk @@ -12,6 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +# THis file contains the common e2e targets and variables for all e2e suites. +# When adding a suite, create a new directory under e2e/ and add a Makefile that +# includes this file. + # Force run of the e2e tests E2E_TEST_OPTS ?= -count=1 @@ -29,12 +33,15 @@ e2e-test: @go test $(E2E_TEST_OPTS) ./... || ( $(MAKE) e2e-post-error; exit 1 ) .PHONY: e2e-pre -e2e-pre: +e2e-pre:: @docker compose up --detach --wait --force-recreate --remove-orphans || ($(MAKE) e2e-post-error; exit 1) .PHONY: e2e-post -e2e-post: - @docker compose down +e2e-post:: +ifeq ($(E2E_PRESERVE_LOGS),true) + @$(MAKE) capture-logs +endif + @docker compose down --remove-orphans .PHONY: e2e-post-error e2e-post-error: capture-logs @@ -45,5 +52,5 @@ capture-logs: @docker compose logs > logs/docker-compose-logs.log .PHONY: clean -clean: +clean:: @rm -rf ./logs diff --git a/go.mod b/go.mod index 57eca37..3884e4b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/tetratelabs/log v0.2.3 github.com/tetratelabs/run v0.3.0 github.com/tetratelabs/telemetry v0.8.2 + golang.org/x/net v0.20.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe google.golang.org/grpc v1.61.0 google.golang.org/protobuf v1.32.0 @@ -41,7 +42,6 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/yuin/gopher-lua v1.1.0 // indirect golang.org/x/crypto v0.18.0 // indirect - golang.org/x/net v0.20.0 // indirect golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/internal/authz/oidc.go b/internal/authz/oidc.go index f175806..25d7467 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 9fbfda6..65b06b9 100644 --- a/internal/config.go +++ b/internal/config.go @@ -29,6 +29,8 @@ import ( oidcv1 "github.com/tetrateio/authservice-go/config/gen/go/v1/oidc" ) +const scopeOIDC = "openid" + var ( _ run.Config = (*LocalConfigFile)(nil) @@ -146,6 +148,9 @@ func mergeAndValidateOIDCConfigs(cfg *configv1.Config) error { errs = append(errs, fmt.Errorf("%w: missing JWKS URI in chain %q", ErrRequiredURL, fc.Name)) } } + + // Set the defaults + applyOIDCDefaults(f.GetOidc()) } } // Clear the default config as it has already been merged. This way there is only one @@ -155,6 +160,18 @@ func mergeAndValidateOIDCConfigs(cfg *configv1.Config) error { return errors.Join(errs...) } +func applyOIDCDefaults(config *oidcv1.OIDCConfig) { + if config.GetScopes() == nil { + config.Scopes = []string{scopeOIDC} + } + for _, s := range config.GetScopes() { + if s == scopeOIDC { + return + } + } + config.Scopes = append(config.Scopes, scopeOIDC) +} + func ConfigToJSONString(c *configv1.Config) string { b, _ := protojson.Marshal(c) return string(b) diff --git a/internal/config_test.go b/internal/config_test.go index 935b876..c745a81 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -210,6 +210,7 @@ func TestLoadOIDC(t *testing.T) { IdToken: &oidcv1.TokenConfig{Preamble: "Bearer", Header: "authorization"}, ProxyUri: "http://fake", RedisSessionStoreConfig: &oidcv1.RedisConfig{ServerUri: "redis://localhost:6379/0"}, + Scopes: []string{scopeOIDC}, }, }, }, diff --git a/internal/server/authz.go b/internal/server/authz.go index a59c839..a9cae03 100644 --- a/internal/server/authz.go +++ b/internal/server/authz.go @@ -80,7 +80,6 @@ func (e *ExtAuthZFilter) Register(server *grpc.Server) { // Check is the implementation of the Envoy AuthorizationServer interface. func (e *ExtAuthZFilter) Check(ctx context.Context, req *envoy.CheckRequest) (response *envoy.CheckResponse, err error) { - ctx = propagateRequestID(ctx, req) // Push the original request id tot eh context to include it in all logs log := e.log.Context(ctx) // If there are no trigger rules, allow the request with no check executions. @@ -118,7 +117,6 @@ func (e *ExtAuthZFilter) Check(ctx context.Context, req *envoy.CheckRequest) (re case *configv1.Filter_Mock: h = authz.NewMockHandler(ft.Mock) case *configv1.Filter_Oidc: - // TODO(nacx): Check if the Oidc setting is enough or we have to pull the default Oidc settings if h, err = authz.NewOIDCHandler(ft.Oidc, e.jwks, e.sessions, oidc.Clock{}, oidc.NewRandomGenerator()); err != nil { return nil, err } @@ -148,15 +146,6 @@ func (e *ExtAuthZFilter) Check(ctx context.Context, req *envoy.CheckRequest) (re return deny(codes.PermissionDenied, "no chains matched"), nil } -// propagateRequestID propagates the request id from the request headers to the context. -func propagateRequestID(ctx context.Context, req *envoy.CheckRequest) context.Context { - headers := req.GetAttributes().GetRequest().GetHttp().GetHeaders() - if headers == nil || headers[EnvoyXRequestID] == "" { - return ctx - } - return telemetry.KeyValuesToContext(ctx, EnvoyXRequestID, headers[EnvoyXRequestID]) -} - // matches returns true if the given request matches the given match configuration func matches(m *configv1.Match, req *envoy.CheckRequest) bool { if m == nil { diff --git a/internal/server/authz_test.go b/internal/server/authz_test.go index 49684fe..17120b0 100644 --- a/internal/server/authz_test.go +++ b/internal/server/authz_test.go @@ -130,7 +130,7 @@ func TestGrpcNoChainsMatched(t *testing.T) { require.NoError(t, err) client := envoy.NewAuthorizationClient(conn) - ok, err := client.Check(context.Background(), &envoy.CheckRequest{}) + ok, err := client.Check(context.Background(), header("test")) require.NoError(t, err) require.Equal(t, int32(codes.PermissionDenied), ok.Status.Code) } @@ -325,6 +325,7 @@ func header(value string) *envoy.CheckRequest { Request: &envoy.AttributeContext_Request{ Http: &envoy.AttributeContext_HttpRequest{ Headers: map[string]string{ + "x-request-id": "test-request-id", "x-test-headers": value, }, }, diff --git a/internal/server/requestid.go b/internal/server/requestid.go new file mode 100644 index 0000000..8ddfb39 --- /dev/null +++ b/internal/server/requestid.go @@ -0,0 +1,45 @@ +// 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 server + +import ( + "context" + + envoy "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "github.com/tetratelabs/telemetry" + "google.golang.org/grpc" +) + +// PropagateRequestID is a gRPC middleware that propagates the request id from an Envoy CheckRequest +// to the logging context. +func PropagateRequestID( + ctx context.Context, + req interface{}, + _ *grpc.UnaryServerInfo, + handler grpc.UnaryHandler, +) (interface{}, error) { + check, ok := req.(*envoy.CheckRequest) + if !ok { + return handler(ctx, req) + } + + headers := check.GetAttributes().GetRequest().GetHttp().GetHeaders() + if headers == nil || headers[EnvoyXRequestID] == "" { + return handler(ctx, req) + } + + ctx = telemetry.KeyValuesToContext(ctx, EnvoyXRequestID, headers[EnvoyXRequestID]) + return handler(ctx, req) +} diff --git a/internal/server/server.go b/internal/server/server.go index a132597..ae0fc10 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -74,7 +74,10 @@ func (s *Server) PreRun() error { // Initialize the gRPC server s.server = grpc.NewServer( // TODO(nacx): Expose the right flags for secure connections - grpc.ChainUnaryInterceptor(logMiddleware.UnaryServerInterceptor), + grpc.ChainUnaryInterceptor( + PropagateRequestID, + logMiddleware.UnaryServerInterceptor, + ), ) for _, h := range s.registerHandlers {