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 {