From 25395f273557e1aa29171f8dc90209e729bf361a Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Fri, 21 Aug 2026 14:20:03 +0100 Subject: [PATCH 01/14] outposts: fix docker controller for proxy outpost (#25353) --- authentik/outposts/controllers/docker.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py index 4a083b526f4c..214c6f4c13bc 100644 --- a/authentik/outposts/controllers/docker.py +++ b/authentik/outposts/controllers/docker.py @@ -22,6 +22,7 @@ DockerServiceConnection, Outpost, OutpostServiceConnectionState, + OutpostType, ServiceConnectionInvalid, ) @@ -195,6 +196,10 @@ def _get_container(self) -> tuple[Container, bool]: except NotFound: self.logger.info("(Re-)creating container...") image_name = self.try_pull_image() + # Go outposts have a different syntax for this than the rust proxy outpost + healthcheck_cmd = [f"/{self.outpost.type}", "healthcheck"] + if self.outpost.type == OutpostType.PROXY: + healthcheck_cmd = ["/authentik", "healthcheck", self.outpost.type] container_args = { "image": image_name, "name": self.name, @@ -204,7 +209,7 @@ def _get_container(self) -> tuple[Container, bool]: "restart_policy": {"Name": "unless-stopped"}, "network": self.outpost.config.docker_network, "healthcheck": { - "test": ["CMD", f"/{self.outpost.type}", "healthcheck"], + "test": ["CMD", *healthcheck_cmd], "interval": 5 * 1_000 * 1_000_000, "retries": 20, "start_period": 3 * 1_000 * 1_000_000, From bc683c5b2987af806d8774e820c732f7c4051e34 Mon Sep 17 00:00:00 2001 From: Abhishek Sharma Date: Fri, 21 Aug 2026 06:23:43 -0700 Subject: [PATCH 02/14] sources: don't 500 on an object-shaped `groups` claim (#25208) Co-authored-by: Marc 'risson' Schmitt --- authentik/core/sources/flow_manager.py | 33 ++++++++++++++++++- .../core/tests/test_source_flow_manager.py | 22 +++++++++++++ .../oauth/tests/test_property_mappings.py | 16 ++++----- .../sources/property-mappings/index.md | 2 ++ 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 562f13234f26..751c8b4b6f92 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -140,10 +140,41 @@ def __init__( group_id=group_id, **self.user_info, ) - for group_id in self.user_properties.setdefault("groups", []) + for group_id in self._keyable_group_ids(self.user_properties.setdefault("groups", [])) } del self.user_properties["groups"] + def _keyable_group_ids(self, group_ids: list[Any]) -> list[Any]: + """Drop group identifiers that cannot be used as a `groups_properties` key. + + An unhashable identifier, such as an object in an IdP's `groups` claim, + raised `TypeError` out of the constructor and surfaced as HTTP 500, + locking every member of that group out of the source. Skipped entries are + recorded so a user arriving with fewer groups than the IdP granted stays + visible to an operator. + """ + keyable = [] + skipped = [] + for group_id in group_ids: + try: + hash(group_id) + except TypeError: + skipped.append(group_id) + else: + keyable.append(group_id) + if skipped: + self._logger.warning("Skipping groups with an unusable identifier", groups=skipped) + Event.new( + EventAction.CONFIGURATION_ERROR, + message=( + f"Source '{self.source.name}' returned {len(skipped)} group(s) whose " + "identifier is not a string; they were not applied to the user." + ), + source=self.source, + groups=skipped, + ).from_http(self.request) + return keyable + def get_action(self, **kwargs) -> tuple[Action, UserSourceConnection | None]: # noqa: PLR0911 """decide which action should be taken""" # When request is authenticated, always link diff --git a/authentik/core/tests/test_source_flow_manager.py b/authentik/core/tests/test_source_flow_manager.py index 4bbde7a4bb3b..640d93db9ce1 100644 --- a/authentik/core/tests/test_source_flow_manager.py +++ b/authentik/core/tests/test_source_flow_manager.py @@ -12,6 +12,7 @@ from authentik.core.sources.matcher import MatchFailureReason from authentik.core.sources.stage import PostSourceStage from authentik.core.tests.utils import RequestFactory, create_test_flow +from authentik.events.models import Event, EventAction from authentik.flows.planner import FlowPlan from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.lib.generators import generate_id @@ -161,6 +162,27 @@ def test_unauthenticated_link(self): self.assertIsNone(connection.pk) flow_manager.get_flow() + def test_unusable_group_identifier_does_not_abort(self): + """Test a group identifier that cannot be used as a key being skipped (#25191)""" + request = self.request_factory.get("/", user=AnonymousUser()) + flow_manager = OAuthSourceFlowManager( + self.source, + request, + self.identifier, + {"info": {"groups": ["usable", {"id": "unusable"}, ["also-unusable"]]}}, + {}, + ) + # Enrolling has to remain possible: raising here surfaced as HTTP 500 and + # locked out every member of such a group. + self.assertEqual(list(flow_manager.groups_properties.keys()), ["usable"]) + self.assertEqual(flow_manager.get_action()[0], Action.ENROLL) + self.assertEqual(flow_manager.get_flow().status_code, 302) + + event = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR).first() + self.assertIsNotNone(event) + self.assertIn("2 group(s)", event.context["message"]) + self.assertEqual(event.context["groups"], [{"id": "unusable"}, ["also-unusable"]]) + def test_unauthenticated_enroll_email(self): """Test un-authenticated user enrolling (link on email)""" User.objects.create(username="foo", email="foo@bar.baz") diff --git a/authentik/sources/oauth/tests/test_property_mappings.py b/authentik/sources/oauth/tests/test_property_mappings.py index dd74caa8bbb7..655da3094f05 100644 --- a/authentik/sources/oauth/tests/test_property_mappings.py +++ b/authentik/sources/oauth/tests/test_property_mappings.py @@ -110,6 +110,12 @@ def test_grup_property_mappings(self): ) def test_group_property_mappings_with_object_groups(self): + """An object-shaped `groups` entry is skipped instead of aborting the flow. + + Added in #25195 asserting the `TypeError` that #25191 is about; the + identifier is still unusable as a key, so the entry is dropped rather + than mapped. + """ info = deepcopy(INFO) info["groups"] = [ {"id": "group-1", "name": "Admins"}, @@ -117,11 +123,5 @@ def test_group_property_mappings_with_object_groups(self): request = self.request_factory.get("/", user=AnonymousUser()) - with self.assertRaises(TypeError): - OAuthSourceFlowManager( - self.source, - request, - IDENTIFIER, - {"info": info}, - {}, - ) + flow_manager = OAuthSourceFlowManager(self.source, request, IDENTIFIER, {"info": info}, {}) + self.assertEqual(flow_manager.groups_properties, {}) diff --git a/website/docs/users-sources/sources/property-mappings/index.md b/website/docs/users-sources/sources/property-mappings/index.md index 139aab4c0a0d..d34719034cd9 100644 --- a/website/docs/users-sources/sources/property-mappings/index.md +++ b/website/docs/users-sources/sources/property-mappings/index.md @@ -75,3 +75,5 @@ return { ``` The `groups` attribute is a special attribute that must contain group identifiers. By default, those identifiers are also used as the group name. Each identifier is then given to group property mappings as the `group_id` variable, if extra processing needs to happen. + +An identifier has to be a simple value such as a string. Entries that are not, such as the objects some identity providers return in an OpenID Connect `groups` claim, are skipped, and a **Configuration error** event records how many were dropped. From 285a1d8c664f823725745fa81873258014e203a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcelo=20Elizeche=20Land=C3=B3?= Date: Fri, 21 Aug 2026 10:27:44 -0300 Subject: [PATCH 03/14] core: set days=1 as default token duration for new installs (#25341) --- .../migrations/0002_tenant_default_token_duration_and_more.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py index 0aa7accfc886..ae877bd9ebb0 100644 --- a/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py +++ b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): model_name="tenant", name="default_token_duration", field=models.TextField( - default=CONFIG.get("default_token_duration", "minutes=30"), + default=CONFIG.get("default_token_duration", "days=1"), help_text="Default token duration", validators=[authentik.lib.utils.time.timedelta_string_validator], ), From 155f5415ff321cc949b439d418ca5e51df22866e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Fri, 21 Aug 2026 14:40:38 +0100 Subject: [PATCH 04/14] stages/identification: only join Source subtypes that can render a login button (#25238) Co-authored-by: Marc 'risson' Schmitt Signed-off-by: Marc 'risson' Schmitt --- authentik/stages/identification/stage.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/authentik/stages/identification/stage.py b/authentik/stages/identification/stage.py index 15b8617270a5..6d05d7d0e6ef 100644 --- a/authentik/stages/identification/stage.py +++ b/authentik/stages/identification/stage.py @@ -1,6 +1,7 @@ """Identification stage logic""" from dataclasses import asdict +from functools import cache from typing import Any from django.contrib.auth.hashers import make_password @@ -69,6 +70,22 @@ def get_login_serializers(): return mapping +@cache +def login_capable_source_subclasses() -> list[type[Source]]: + """Concrete Source subclasses that can render a UI login button. + + ``Source.ui_login_button`` returns None, so a source only reaches the + challenge below if its subclass overrides it. Abstract subclasses are skipped + because they have no table to join against. + """ + return [ + source_type + for source_type in all_subclasses(Source) + if not source_type._meta.abstract + and source_type.ui_login_button is not Source.ui_login_button + ] + + @extend_schema_field( PolymorphicProxySerializer( component_name="LoginChallengeTypes", @@ -386,7 +403,9 @@ def get_challenge(self) -> Challenge: # Check all enabled source, add them if they have a UI Login button. ui_sources = [] sources: list[Source] = ( - current_stage.sources.filter(enabled=True).order_by("name").select_subclasses() + current_stage.sources.filter(enabled=True) + .order_by("name") + .select_subclasses(*login_capable_source_subclasses()) ) for source in sources: ui_login_button = source.ui_login_button(self.request) From 72df29b9a43a27a819bdf070d685c4b34650a6d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:46:01 +0000 Subject: [PATCH 05/14] core: bump github.com/stretchr/testify from 1.12.0 to 1.12.1 (#25376) Signed-off-by: dependabot[bot] --- go.mod | 5 ++--- go.sum | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 521340e35305..1bd686c06f03 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/sethvargo/go-envconfig v1.4.3 github.com/sirupsen/logrus v1.10.0 github.com/spf13/cobra v1.10.2 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/wwt/guac v1.3.2 golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/oauth2 v0.36.0 @@ -87,11 +87,10 @@ require ( go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 004cd62d55a9..36557960726c 100644 --- a/go.sum +++ b/go.sum @@ -194,8 +194,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4= github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -213,8 +213,9 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -275,8 +276,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= From f123ab37f49e63c266cc049a1d1cad25f1950530 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 13:58:56 +0000 Subject: [PATCH 06/14] core: bump rust-toolchain from 1.97.1 to 1.98.0 (#25378) Co-authored-by: Marc 'risson' Schmitt Signed-off-by: dependabot[bot] Signed-off-by: Marc 'risson' Schmitt --- packages/ak-common/src/api.rs | 2 +- rust-toolchain.toml | 2 +- src/outpost/proxy/mod.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ak-common/src/api.rs b/packages/ak-common/src/api.rs index ac5127f8607a..4c753d67321a 100644 --- a/packages/ak-common/src/api.rs +++ b/packages/ak-common/src/api.rs @@ -96,7 +96,7 @@ where G: Fn(R) -> Vec, { let mut page = 1_i32; - let mut results = Vec::with_capacity(0); + let mut results = Vec::new(); loop { let response = fetch(page).await?; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 338adb7724b2..6748f6d59ffc 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.0" components = ["clippy", "rust-analyzer", "llvm-tools-preview"] diff --git a/src/outpost/proxy/mod.rs b/src/outpost/proxy/mod.rs index cde3eea0d3d2..1000a87a7cb8 100644 --- a/src/outpost/proxy/mod.rs +++ b/src/outpost/proxy/mod.rs @@ -66,7 +66,7 @@ impl Outpost for ProxyOutpost { async fn new(controller: Arc) -> Result { Ok(Self { controller, - apps: ArcSwap::from_pointee(HashMap::with_capacity(0)), + apps: ArcSwap::from_pointee(HashMap::new()), certificate_store: CertificateStore::new(), default_cert: Arc::new(tls::self_signed::generate_certifiedkey()?), }) From 98bfc45c9d2a3007246028f56dc7f28835019732 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 16:28:32 +0200 Subject: [PATCH 07/14] internal: remove unused go code (#25244) Co-authored-by: Jens L. Signed-off-by: Marc 'risson' Schmitt --- .github/workflows/ci-outpost.yml | 19 - AGENTS.md | 8 +- cmd/proxy/main.go | 45 - cmd/server/main.go | 10 - cmd/server/server.go | 111 - go.mod | 18 - go.sum | 45 - internal/config/config.go | 38 - internal/config/config_test.go | 12 +- internal/config/struct.go | 87 +- internal/gounicorn/gounicorn.go | 205 -- .../proxyv2/application/application.go | 312 --- internal/outpost/proxyv2/application/auth.go | 120 -- .../outpost/proxyv2/application/auth_basic.go | 87 - .../proxyv2/application/auth_bearer.go | 61 - .../outpost/proxyv2/application/auth_test.go | 363 ---- .../outpost/proxyv2/application/endpoint.go | 90 - .../proxyv2/application/endpoint_test.go | 88 - internal/outpost/proxyv2/application/error.go | 41 - .../proxyv2/application/mode_common.go | 156 -- .../proxyv2/application/mode_common_test.go | 185 -- .../proxyv2/application/mode_forward.go | 177 -- .../application/mode_forward_caddy_test.go | 144 -- .../application/mode_forward_envoy_test.go | 113 - .../application/mode_forward_nginx_test.go | 75 - .../application/mode_forward_traefik_test.go | 144 -- .../outpost/proxyv2/application/mode_proxy.go | 98 - .../proxyv2/application/mode_proxy_test.go | 123 -- internal/outpost/proxyv2/application/oauth.go | 106 - .../proxyv2/application/oauth_callback.go | 75 - .../proxyv2/application/oauth_state.go | 152 -- .../outpost/proxyv2/application/oauth_test.go | 71 - .../outpost/proxyv2/application/session.go | 143 -- .../application/session_postgres_test.go | 285 --- .../proxyv2/application/session_test.go | 192 -- internal/outpost/proxyv2/application/test.go | 99 - internal/outpost/proxyv2/application/utils.go | 57 - .../outpost/proxyv2/application/utils_test.go | 116 -- internal/outpost/proxyv2/codecs/codec.go | 43 - .../outpost/proxyv2/constants/constants.go | 12 - .../filesystemstore/filesystemstore.go | 206 -- .../filesystemstore/filesystemstore_test.go | 146 -- internal/outpost/proxyv2/handlers.go | 130 -- internal/outpost/proxyv2/hs256/hs256.go | 38 - internal/outpost/proxyv2/metrics/metrics.go | 17 - .../outpost/proxyv2/postgresstore/connpool.go | 289 --- .../proxyv2/postgresstore/connpool_test.go | 417 ---- .../outpost/proxyv2/postgresstore/logger.go | 48 - .../proxyv2/postgresstore/postgresstore.go | 676 ------ .../postgresstore/postgresstore_test.go | 1843 ----------------- internal/outpost/proxyv2/proxyv2.go | 229 -- internal/outpost/proxyv2/refresh.go | 106 - .../outpost/proxyv2/sessionstore/cleanup.go | 113 - .../proxyv2/sessionstore/cleanup_test.go | 191 -- internal/outpost/proxyv2/templates/error.html | 72 - .../outpost/proxyv2/templates/templates.go | 19 - internal/outpost/proxyv2/types/claims.go | 23 - internal/outpost/proxyv2/ws.go | 29 - internal/utils/web/http_compress.go | 91 - internal/utils/web/http_forwarded.go | 53 - internal/utils/web/http_host_interceptor.go | 36 - internal/utils/web/keepalive.go | 32 - internal/utils/web/server.go | 28 - internal/utils/web/static.go | 17 - internal/web/brand_tls/brand_tls.go | 117 -- internal/web/metrics.go | 57 - internal/web/proxy.go | 208 -- internal/web/static.go | 195 -- internal/web/web.go | 288 --- internal/web/web_tls.go | 72 - .../docs/developer-docs/setup/debugging.md | 4 +- 71 files changed, 13 insertions(+), 10103 deletions(-) delete mode 100644 cmd/proxy/main.go delete mode 100644 cmd/server/main.go delete mode 100644 cmd/server/server.go delete mode 100644 internal/gounicorn/gounicorn.go delete mode 100644 internal/outpost/proxyv2/application/application.go delete mode 100644 internal/outpost/proxyv2/application/auth.go delete mode 100644 internal/outpost/proxyv2/application/auth_basic.go delete mode 100644 internal/outpost/proxyv2/application/auth_bearer.go delete mode 100644 internal/outpost/proxyv2/application/auth_test.go delete mode 100644 internal/outpost/proxyv2/application/endpoint.go delete mode 100644 internal/outpost/proxyv2/application/endpoint_test.go delete mode 100644 internal/outpost/proxyv2/application/error.go delete mode 100644 internal/outpost/proxyv2/application/mode_common.go delete mode 100644 internal/outpost/proxyv2/application/mode_common_test.go delete mode 100644 internal/outpost/proxyv2/application/mode_forward.go delete mode 100644 internal/outpost/proxyv2/application/mode_forward_caddy_test.go delete mode 100644 internal/outpost/proxyv2/application/mode_forward_envoy_test.go delete mode 100644 internal/outpost/proxyv2/application/mode_forward_nginx_test.go delete mode 100644 internal/outpost/proxyv2/application/mode_forward_traefik_test.go delete mode 100644 internal/outpost/proxyv2/application/mode_proxy.go delete mode 100644 internal/outpost/proxyv2/application/mode_proxy_test.go delete mode 100644 internal/outpost/proxyv2/application/oauth.go delete mode 100644 internal/outpost/proxyv2/application/oauth_callback.go delete mode 100644 internal/outpost/proxyv2/application/oauth_state.go delete mode 100644 internal/outpost/proxyv2/application/oauth_test.go delete mode 100644 internal/outpost/proxyv2/application/session.go delete mode 100644 internal/outpost/proxyv2/application/session_postgres_test.go delete mode 100644 internal/outpost/proxyv2/application/session_test.go delete mode 100644 internal/outpost/proxyv2/application/test.go delete mode 100644 internal/outpost/proxyv2/application/utils.go delete mode 100644 internal/outpost/proxyv2/application/utils_test.go delete mode 100644 internal/outpost/proxyv2/codecs/codec.go delete mode 100644 internal/outpost/proxyv2/constants/constants.go delete mode 100644 internal/outpost/proxyv2/filesystemstore/filesystemstore.go delete mode 100644 internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go delete mode 100644 internal/outpost/proxyv2/handlers.go delete mode 100644 internal/outpost/proxyv2/hs256/hs256.go delete mode 100644 internal/outpost/proxyv2/metrics/metrics.go delete mode 100644 internal/outpost/proxyv2/postgresstore/connpool.go delete mode 100644 internal/outpost/proxyv2/postgresstore/connpool_test.go delete mode 100644 internal/outpost/proxyv2/postgresstore/logger.go delete mode 100644 internal/outpost/proxyv2/postgresstore/postgresstore.go delete mode 100644 internal/outpost/proxyv2/postgresstore/postgresstore_test.go delete mode 100644 internal/outpost/proxyv2/proxyv2.go delete mode 100644 internal/outpost/proxyv2/refresh.go delete mode 100644 internal/outpost/proxyv2/sessionstore/cleanup.go delete mode 100644 internal/outpost/proxyv2/sessionstore/cleanup_test.go delete mode 100644 internal/outpost/proxyv2/templates/error.html delete mode 100644 internal/outpost/proxyv2/templates/templates.go delete mode 100644 internal/outpost/proxyv2/types/claims.go delete mode 100644 internal/outpost/proxyv2/ws.go delete mode 100644 internal/utils/web/http_compress.go delete mode 100644 internal/utils/web/http_forwarded.go delete mode 100644 internal/utils/web/http_host_interceptor.go delete mode 100644 internal/utils/web/keepalive.go delete mode 100644 internal/utils/web/server.go delete mode 100644 internal/utils/web/static.go delete mode 100644 internal/web/brand_tls/brand_tls.go delete mode 100644 internal/web/metrics.go delete mode 100644 internal/web/proxy.go delete mode 100644 internal/web/static.go delete mode 100644 internal/web/web.go delete mode 100644 internal/web/web_tls.go diff --git a/.github/workflows/ci-outpost.yml b/.github/workflows/ci-outpost.yml index 46381d242239..6ff3826831c9 100644 --- a/.github/workflows/ci-outpost.yml +++ b/.github/workflows/ci-outpost.yml @@ -79,7 +79,6 @@ jobs: fail-fast: false matrix: type: - - proxy - ldap - radius - rac @@ -92,24 +91,6 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: "go.mod" - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - - name: Pin pnpm store directory - run: | - echo "PNPM_HOME=${RUNNER_TEMP}/pnpm-home" >> "$GITHUB_ENV" - echo "npm_config_store_dir=${RUNNER_TEMP}/pnpm-home/store" >> "$GITHUB_ENV" - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v4 - with: - node-version-file: package.json - cache: pnpm - cache-dependency-path: | - pnpm-lock.yaml - web/pnpm-lock.yaml - - name: Install dependencies - run: | - pnpm install --frozen-lockfile - pnpm --dir web install --frozen-lockfile - - name: Build web - run: pnpm --dir web run build-proxy - name: Build outpost run: | set -x diff --git a/AGENTS.md b/AGENTS.md index 28e672c09387..28c13c69ab27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,8 @@ It is a **polyglot monorepo**. Most work lands in one of the subtrees below; whe | Language | Where | What it is | Deeper guide | | -------------- | -------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------- | | **Python** | `authentik/`, `lifecycle/` | The core server — a Django + Django REST Framework app. The source of truth for the IdP. | — | -| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, proxy, RAC, RADIUS) and the front reverse-proxy that fronts Django. | — | -| **Rust** | `src/`, `packages/ak-*` | Newer server/worker components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — | +| **Go** | `cmd/`, `internal/` | **Outposts** (LDAP, RAC, RADIUS). | — | +| **Rust** | `src/`, `packages/ak-*` | Newer server/worker/proxy outpost components and shared crates (`ak-axum`, `ak-common`, `ak-guardian`). | — | | **TypeScript** | `web/` | The web UI — three Lit + PatternFly apps (Admin, User, Flow). | [`web/AGENTS.md`](web/AGENTS.md) | | **Docs** | `website/` | The documentation, integrations, and API sites (Docusaurus). | [`website/AGENTS.md`](website/AGENTS.md) | @@ -19,8 +19,8 @@ The Python core and the web UI talk through a **generated OpenAPI client** — n ``` authentik/ # Django core — the IdP itself (see "The authentik Django package" below) lifecycle/ # Boot/runtime: migrations, gunicorn config, the `ak` CLI, container + AWS entrypoints -cmd/ # Go entrypoints: ldap/ proxy/ rac/ radius/ outposts + server/ (front reverse-proxy) -internal/ # Shared Go: outpost implementations, config, web proxy, gounicorn process manager +cmd/ # Go entrypoints: ldap/ rac/ radius/ outposts +internal/ # Shared Go: outpost implementations, config src/ # Rust server/worker (ak-axum based; gated behind cargo features) packages/ # Shared workspace packages, polyglot: # client-go / client-rust / client-ts — GENERATED API clients (do not hand-edit) diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go deleted file mode 100644 index 24aee3c93397..000000000000 --- a/cmd/proxy/main.go +++ /dev/null @@ -1,45 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/spf13/cobra" - - "goauthentik.io/internal/common" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/ak/entrypoint" - "goauthentik.io/internal/outpost/ak/healthcheck" - "goauthentik.io/internal/outpost/proxyv2" -) - -const helpMessage = `authentik proxy - -Required environment variables: -- AUTHENTIK_HOST: URL to connect to (format "http://authentik.company") -- AUTHENTIK_TOKEN: Token to authenticate with -- AUTHENTIK_INSECURE: Skip SSL Certificate verification - -Optionally, you can set these: -- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST` - -var rootCmd = &cobra.Command{ - Long: helpMessage, - Version: constants.FullVersion(), - PersistentPreRun: common.PreRun, - RunE: func(cmd *cobra.Command, args []string) error { - err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer) - if err != nil { - fmt.Println(helpMessage) - } - return err - }, -} - -func main() { - rootCmd.AddCommand(healthcheck.Command) - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 37cb4775820c..000000000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import "os" - -func main() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} diff --git a/cmd/server/server.go b/cmd/server/server.go deleted file mode 100644 index 042d55951d70..000000000000 --- a/cmd/server/server.go +++ /dev/null @@ -1,111 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "net/url" - "os" - "time" - - "github.com/getsentry/sentry-go" - log "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - - "goauthentik.io/internal/common" - "goauthentik.io/internal/config" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/debug" - "goauthentik.io/internal/outpost/ak" - "goauthentik.io/internal/outpost/proxyv2" - sentryutils "goauthentik.io/internal/utils/sentry" - webutils "goauthentik.io/internal/utils/web" - "goauthentik.io/internal/web" -) - -var rootCmd = &cobra.Command{ - Use: "authentik", - Short: "Start authentik instance", - Version: constants.FullVersion(), - PersistentPreRun: common.PreRun, - Run: func(cmd *cobra.Command, args []string) { - debug.EnableDebugServer("authentik.core") - l := log.WithField("logger", "authentik.root") - - if config.Get().ErrorReporting.Enabled { - err := sentry.Init(sentry.ClientOptions{ - Dsn: config.Get().ErrorReporting.SentryDSN, - AttachStacktrace: true, - EnableTracing: true, - TracesSampler: sentryutils.SamplerFunc(config.Get().ErrorReporting.SampleRate), - Release: fmt.Sprintf("authentik@%s", constants.VERSION()), - Environment: config.Get().ErrorReporting.Environment, - HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgent(), http.DefaultTransport), - IgnoreErrors: []string{ - http.ErrAbortHandler.Error(), - }, - }) - if err != nil { - l.WithError(err).Warning("failed to init sentry") - } - } - - ex := common.Init() - defer common.Defer() - - u := url.URL{ - Scheme: "unix", - Host: fmt.Sprintf("%s/%s", os.TempDir(), web.SocketName), - Path: config.Get().Web.Path, - } - - ws := web.NewWebServer() - ws.Core().AddHealthyCallback(func() { - if config.Get().Outposts.DisableEmbeddedOutpost { - return - } - go attemptProxyStart(ws, u) - }) - ws.Start() - <-ex - l.Info("shutting down webserver") - go ws.Shutdown() - }, -} - -func attemptProxyStart(ws *web.WebServer, u url.URL) { - maxTries := 100 - attempt := 0 - l := log.WithField("logger", "authentik.server") - for { - l.Debug("attempting to init outpost") - ac := ak.NewAPIController(u, config.Get().SecretKey) - if ac == nil { - attempt += 1 - time.Sleep(1 * time.Second) - if attempt > maxTries { - break - } - continue - } - ac.AddRefreshHandler(func() { - ws.BrandTLS.Check() - }) - - srv := proxyv2.NewProxyServer(ac) - ws.ProxyServer = srv.(*proxyv2.ProxyServer) - ac.Server = srv - l.Debug("attempting to start outpost") - err := ac.StartBackgroundTasks() - if err != nil { - l.WithError(err).Warning("outpost failed to start") - attempt += 1 - time.Sleep(15 * time.Second) - if attempt > maxTries { - break - } - continue - } else { - select {} - } - } -} diff --git a/go.mod b/go.mod index 1bd686c06f03..eff5cacb34aa 100644 --- a/go.mod +++ b/go.mod @@ -6,20 +6,15 @@ require ( beryju.io/ldap v0.2.2 beryju.io/radius-eap v0.1.1 github.com/avast/retry-go/v4 v4.7.0 - github.com/coreos/go-oidc/v3 v3.20.0 github.com/getsentry/sentry-go v0.48.0 - github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-ldap/ldap/v3 v3.4.14 github.com/go-openapi/runtime v0.33.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/securecookie v1.1.2 - github.com/gorilla/sessions v1.4.0 github.com/gorilla/websocket v1.5.3 github.com/grafana/pyroscope-go v1.4.2 - github.com/jackc/pgx/v5 v5.10.0 github.com/jellydator/ttlcache/v3 v3.4.1 github.com/mitchellh/mapstructure v1.5.0 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 @@ -30,12 +25,8 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 github.com/wwt/guac v1.3.2 - golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab - golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.22.0 gopkg.in/yaml.v2 v2.4.0 - gorm.io/driver/postgres v1.6.2 - gorm.io/gorm v1.31.2 layeh.com/radius v0.0.0-20231213012653-1006025d24f8 ) @@ -43,11 +34,7 @@ require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/felixge/httpsnoop v1.0.3 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect - github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect - github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect - github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/analysis v0.25.5 // indirect @@ -71,11 +58,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.19.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect diff --git a/go.sum b/go.sum index 36557960726c..294b7230115e 100644 --- a/go.sum +++ b/go.sum @@ -12,27 +12,14 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= -github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss= github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ= github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 h1:zga7zaRE8HCbWjcXMDlfvmQtH0/kMVLo7cQ48dy6kWg= -github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1/go.mod h1:PumS+5d59wmAGsZo6IfRpVNaJUq+6xjC4Utt/k8GO6Q= -github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 h1:O6yi4xa9b2DMosGsXzlMe2E9qXgXCVkRLCoRX+5amxI= -github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27/go.mod h1:AYvN8omj7nKLmbcXS2dyABYU6JB1Lz1bHmkkq1kf4I4= -github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9yue4+QkG/HQ/W67wvtQmWJ4SDo9aK/GIno= -github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs= github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -95,14 +82,10 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= -github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= -github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -114,14 +97,6 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= -github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -136,10 +111,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -149,8 +120,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -187,13 +156,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4= @@ -221,8 +187,6 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab h1:628ME69lBm9C6JY2wXhAph/yjN3jezx1z7BIDLUwxjo= -golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -232,8 +196,6 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -275,12 +237,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4= -gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= -gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= layeh.com/radius v0.0.0-20231213012653-1006025d24f8 h1:orYXpi6BJZdvgytfHH4ybOe4wHnLbbS71Cmd8mWdZjs= layeh.com/radius v0.0.0-20231213012653-1006025d24f8/go.mod h1:QRf+8aRqXc019kHkpcs/CTgyWXFzf+bxlsyuo2nAl1o= diff --git a/internal/config/config.go b/internal/config/config.go index 10ba6b500828..f29bb8e46906 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -191,44 +191,6 @@ func (c *Config) parseScheme(rawVal string) string { return rawVal } -// RefreshPostgreSQLConfig re-reads PostgreSQL configuration from file:// and env:// URIs -// This enables hot-reloading when credentials are rotated by updating the referenced files. -// Note: Plain environment variables (without file:// or env:// prefixes) are read from the -// process environment and will not change unless the process is restarted or os.Setenv is called. -func (c *Config) RefreshPostgreSQLConfig() PostgreSQLConfig { - // Start with current config as base - refreshed := c.PostgreSQL - - // Manually read from environment variables with proper prefix - // We can't use env.Process directly on PostgreSQLConfig because it loses the AUTHENTIK_POSTGRESQL__ prefix - // Map of environment variable suffix to config field pointer - envVars := map[string]*string{ - "HOST": &refreshed.Host, - "PORT": &refreshed.Port, - "USER": &refreshed.User, - "PASSWORD": &refreshed.Password, - "NAME": &refreshed.Name, - "SSLMODE": &refreshed.SSLMode, - "SSLROOTCERT": &refreshed.SSLRootCert, - "SSLCERT": &refreshed.SSLCert, - "SSLKEY": &refreshed.SSLKey, - "DEFAULT_SCHEMA": &refreshed.DefaultSchema, - "CONN_OPTIONS": &refreshed.ConnOptions, - } - - // Read each environment variable if it exists - for suffix, field := range envVars { - if val, ok := os.LookupEnv("AUTHENTIK_POSTGRESQL__" + suffix); ok { - *field = val - } - } - - // Process file:// and env:// URI schemes - c.walkScheme(&refreshed) - - return refreshed -} - func (c *Config) configureLogger() { switch strings.ToLower(c.LogLevel) { case "trace": diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 066592f48ec2..7fcaa2ef0678 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,22 +10,22 @@ import ( ) func TestConfigEnv(t *testing.T) { - assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "bar")) + assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "bar")) cfg = nil if err := Get().fromEnv(); err != nil { panic(err) } - assert.Equal(t, "bar", Get().SecretKey) + assert.Equal(t, "bar", Get().LogLevel) } func TestConfigEnv_Scheme(t *testing.T) { assert.NoError(t, os.Setenv("foo", "bar")) - assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", "env://foo")) + assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", "env://foo")) cfg = nil if err := Get().fromEnv(); err != nil { panic(err) } - assert.Equal(t, "bar", Get().SecretKey) + assert.Equal(t, "bar", Get().LogLevel) } func TestConfigEnv_File(t *testing.T) { @@ -41,10 +41,10 @@ func TestConfigEnv_File(t *testing.T) { panic(err) } - assert.NoError(t, os.Setenv("AUTHENTIK_SECRET_KEY", fmt.Sprintf("file://%s", file.Name()))) + assert.NoError(t, os.Setenv("AUTHENTIK_LOG_LEVEL", fmt.Sprintf("file://%s", file.Name()))) cfg = nil if err := Get().fromEnv(); err != nil { panic(err) } - assert.Equal(t, "bar", Get().SecretKey) + assert.Equal(t, "bar", Get().LogLevel) } diff --git a/internal/config/struct.go b/internal/config/struct.go index 0644a9444297..6aedbad80731 100644 --- a/internal/config/struct.go +++ b/internal/config/struct.go @@ -1,20 +1,11 @@ package config type Config struct { - // Core specific config - Storage StorageConfig `yaml:"storage"` - LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL, overwrite"` - ErrorReporting ErrorReportingConfig `yaml:"error_reporting" env:", prefix=AUTHENTIK_ERROR_REPORTING__"` - PostgreSQL PostgreSQLConfig `yaml:"postgresql" env:", prefix=AUTHENTIK_POSTGRESQL__"` - Outposts OutpostConfig `yaml:"outposts" env:", prefix=AUTHENTIK_OUTPOSTS__"` - - // Config for core and embedded outpost - SecretKey string `yaml:"secret_key" env:"AUTHENTIK_SECRET_KEY, overwrite"` + LogLevel string `yaml:"log_level" env:"AUTHENTIK_LOG_LEVEL, overwrite"` // Config for both core and outposts Debug bool `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"` Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"` - Web WebConfig `yaml:"web" env:", prefix=AUTHENTIK_WEB__"` Log LogConfig `yaml:"log" env:", prefix=AUTHENTIK_LOG__"` LDAP LDAPConfig `yaml:"ldap" env:", prefix=AUTHENTIK_LDAP__"` @@ -27,32 +18,7 @@ type Config struct { AuthentikInsecure bool `env:"AUTHENTIK_INSECURE"` } -type PostgreSQLConfig struct { - Host string `yaml:"host" env:"HOST, overwrite"` - Port string `yaml:"port" env:"PORT, overwrite"` - User string `yaml:"user" env:"USER, overwrite"` - Password string `yaml:"password" env:"PASSWORD, overwrite"` - Name string `yaml:"name" env:"NAME, overwrite"` - - // SSL/TLS settings - SSLMode string `yaml:"sslmode" env:"SSLMODE, overwrite"` - SSLRootCert string `yaml:"sslrootcert" env:"SSLROOTCERT, overwrite"` - SSLCert string `yaml:"sslcert" env:"SSLCERT, overwrite"` - SSLKey string `yaml:"sslkey" env:"SSLKEY, overwrite"` - - // Connection management - ConnMaxAge int `yaml:"conn_max_age" env:"CONN_MAX_AGE, overwrite"` - ConnHealthChecks bool `yaml:"conn_health_checks" env:"CONN_HEALTH_CHECKS, overwrite"` - DisableServerSideCursors bool `yaml:"disable_server_side_cursors" env:"DISABLE_SERVER_SIDE_CURSORS, overwrite"` - - // Advanced settings - DefaultSchema string `yaml:"default_schema" env:"DEFAULT_SCHEMA, overwrite"` - ConnOptions string `yaml:"conn_options" env:"CONN_OPTIONS, overwrite"` -} - type ListenConfig struct { - HTTP []string `yaml:"http" env:"HTTP, overwrite"` - HTTPS []string `yaml:"https" env:"HTTPS, overwrite"` LDAP []string `yaml:"ldap" env:"LDAP, overwrite"` LDAPS []string `yaml:"ldaps" env:"LDAPS, overwrite"` Radius []string `yaml:"radius" env:"RADIUS, overwrite"` @@ -61,57 +27,6 @@ type ListenConfig struct { TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"TRUSTED_PROXY_CIDRS, overwrite"` } -type StorageConfig struct { - Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__BACKEND"` - File StorageFileConfig `yaml:"file"` - Media StorageMediaConfig `yaml:"media"` - Reports StorageReportsConfig `yaml:"reports"` -} - -type StorageFileConfig struct { - Path string `yaml:"path" env:"AUTHENTIK_STORAGE__FILE__PATH, overwrite"` -} - -type StorageMediaConfig struct { - Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__MEDIA__BACKEND"` - File StorageMediaFileConfig `yaml:"file"` -} - -type StorageMediaFileConfig struct { - Path string `yaml:"path" env:"AUTHENTIK_STORAGE__MEDIA__FILE__PATH, overwrite"` -} - -type StorageReportsConfig struct { - Backend string `yaml:"backend" env:"AUTHENTIK_STORAGE__REPORTS__BACKEND"` - File StorageReportsFileConfig `yaml:"file"` -} - -type StorageReportsFileConfig struct { - Path string `yaml:"path" env:"AUTHENTIK_STORAGE__REPORTS__FILE__PATH, overwrite"` -} - -type ErrorReportingConfig struct { - Enabled bool `yaml:"enabled" env:"ENABLED, overwrite"` - SentryDSN string `yaml:"sentry_dsn" env:"SENTRY_DSN, overwrite"` - Environment string `yaml:"environment" env:"ENVIRONMENT, overwrite"` - SendPII bool `yaml:"send_pii" env:"SEND_PII, overwrite"` - SampleRate float64 `yaml:"sample_rate" env:"SAMPLE_RATE, overwrite"` -} - -type OutpostConfig struct { - ContainerImageBase string `yaml:"container_image_base" env:"CONTAINER_IMAGE_BASE, overwrite"` - Discover bool `yaml:"discover" env:"DISCOVER, overwrite"` - DisableEmbeddedOutpost bool `yaml:"disable_embedded_outpost" env:"DISABLE_EMBEDDED_OUTPOST, overwrite"` -} - -type WebConfig struct { - Path string `yaml:"path" env:"PATH, overwrite"` - TimeoutHttpReadHeader string `yaml:"timeout_http_read_header" env:"TIMEOUT_HTTP_READ_HEADER, overwrite"` - TimeoutHttpRead string `yaml:"timeout_http_read" env:"TIMEOUT_HTTP_READ, overwrite"` - TimeoutHttpWrite string `yaml:"timeout_http_write" env:"TIMEOUT_HTTP_WRITE, overwrite"` - TimeoutHttpIdle string `yaml:"timeout_http_idle" env:"TIMEOUT_HTTP_IDLE, overwrite"` -} - type LogConfig struct { HttpHeaders []string `yaml:"http_headers" env:"HTTP_HEADERS, overwrite"` } diff --git a/internal/gounicorn/gounicorn.go b/internal/gounicorn/gounicorn.go deleted file mode 100644 index 3013d6daa6ed..000000000000 --- a/internal/gounicorn/gounicorn.go +++ /dev/null @@ -1,205 +0,0 @@ -package gounicorn - -import ( - "fmt" - "os" - "os/exec" - "os/signal" - "runtime" - "strconv" - "strings" - "syscall" - "time" - - log "github.com/sirupsen/logrus" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/utils" -) - -type GoUnicorn struct { - Healthcheck func() bool - healthyCallbacks []func() - - log *log.Entry - p *exec.Cmd - pidFile string - started bool - killed bool - alive bool -} - -func New(healthcheck func() bool) *GoUnicorn { - logger := log.WithField("logger", "authentik.router.unicorn") - g := &GoUnicorn{ - Healthcheck: healthcheck, - log: logger, - started: false, - killed: false, - alive: false, - healthyCallbacks: []func(){}, - } - g.initCmd() - c := make(chan os.Signal, 1) - signal.Notify(c, syscall.SIGHUP, syscall.SIGUSR2) - go func() { - for sig := range c { - switch sig { - case syscall.SIGHUP: - g.log.Info("SIGHUP received, forwarding to gunicorn") - g.Reload() - case syscall.SIGUSR2: - g.log.Info("SIGUSR2 received, restarting gunicorn") - g.Restart() - } - } - }() - return g -} - -func (g *GoUnicorn) initCmd() { - command := "./manage.py" - args := []string{"dev_server"} - if !config.Get().Debug { - pidFile, err := os.CreateTemp("", "authentik-gunicorn.*.pid") - if err != nil { - panic(fmt.Errorf("failed to create temporary pid file: %v", err)) - } - g.pidFile = pidFile.Name() - command = "gunicorn" - args = []string{"-c", "./lifecycle/gunicorn.conf.py", "authentik.root.asgi:application"} - if g.pidFile != "" { - args = append(args, "--pid", g.pidFile) - } - } - g.log.WithField("args", args).WithField("cmd", command).Debug("Starting gunicorn") - g.p = exec.Command(command, args...) - g.p.Env = os.Environ() - g.p.Stdout = os.Stdout - g.p.Stderr = os.Stderr -} - -func (g *GoUnicorn) AddHealthyCallback(cb func()) { - g.healthyCallbacks = append(g.healthyCallbacks, cb) -} - -func (g *GoUnicorn) IsRunning() bool { - return g.alive -} - -func (g *GoUnicorn) Start() error { - if g.started { - g.initCmd() - } - g.killed = false - g.started = true - go g.healthcheck() - return g.p.Run() -} - -func (g *GoUnicorn) healthcheck() { - g.log.Debug("starting healthcheck") - // Default healthcheck is every 1 second on startup - // once we've been healthy once, increase to 30 seconds - for range time.NewTicker(time.Second).C { - if g.Healthcheck() { - g.alive = true - g.log.Debug("backend is alive, backing off with healthchecks") - for _, cb := range g.healthyCallbacks { - cb() - } - break - } - g.log.Debug("backend not alive yet") - } -} - -func (g *GoUnicorn) Reload() { - g.log.WithField("method", "reload").Info("reloading gunicorn") - err := g.p.Process.Signal(syscall.SIGHUP) - if err != nil { - g.log.WithError(err).Warning("failed to reload gunicorn") - } -} - -func (g *GoUnicorn) Restart() { - g.log.WithField("method", "restart").Info("restart gunicorn") - if g.pidFile == "" { - g.log.Warning("pidfile is non existent, cannot restart") - return - } - - err := g.p.Process.Signal(syscall.SIGUSR2) - if err != nil { - g.log.WithError(err).Warning("failed to restart gunicorn") - return - } - - newPidFile := fmt.Sprintf("%s.2", g.pidFile) - - // Wait for the new PID file to be created - for range time.NewTicker(1 * time.Second).C { - _, err = os.Stat(newPidFile) - if err == nil || !os.IsNotExist(err) { - break - } - g.log.Debugf("waiting for new gunicorn pidfile to appear at %s", newPidFile) - } - if err != nil { - g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") - return - } - - newPidB, err := os.ReadFile(newPidFile) - if err != nil { - g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") - return - } - newPidS := strings.TrimSpace(string(newPidB[:])) - newPid, err := strconv.Atoi(newPidS) - if err != nil { - g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") - return - } - g.log.Warningf("new gunicorn PID is %d", newPid) - - newProcess, err := utils.FindProcess(newPid) - if newProcess == nil || err != nil { - g.log.WithError(err).Warning("failed to find the new gunicorn process, aborting") - return - } - - // The new process has started, let's gracefully kill the old one - g.log.Warning("killing old gunicorn") - err = g.p.Process.Signal(syscall.SIGTERM) - if err != nil { - g.log.Warning("failed to kill old instance of gunicorn") - } - - g.p.Process = newProcess - // No need to close any files and the .2 pid file is deleted by Gunicorn -} - -func (g *GoUnicorn) Kill() { - if !g.started { - return - } - var err error - if runtime.GOOS == "darwin" { - g.log.WithField("method", "kill").Warning("stopping gunicorn") - err = g.p.Process.Kill() - } else { - g.log.WithField("method", "sigterm").Warning("stopping gunicorn") - err = syscall.Kill(g.p.Process.Pid, syscall.SIGTERM) - } - if err != nil { - g.log.WithError(err).Warning("failed to stop gunicorn") - } - if g.pidFile != "" { - err := os.Remove(g.pidFile) - if err != nil { - g.log.WithError(err).Warning("failed to remove pidfile") - } - } - g.killed = true -} diff --git a/internal/outpost/proxyv2/application/application.go b/internal/outpost/proxyv2/application/application.go deleted file mode 100644 index d94cd5361854..000000000000 --- a/internal/outpost/proxyv2/application/application.go +++ /dev/null @@ -1,312 +0,0 @@ -package application - -import ( - "context" - "crypto/sha256" - "crypto/tls" - "encoding/gob" - "encoding/hex" - "fmt" - "html/template" - "net/http" - "net/url" - "path" - "regexp" - "strings" - "time" - - "github.com/coreos/go-oidc/v3/oidc" - "github.com/getsentry/sentry-go" - sentryhttp "github.com/getsentry/sentry-go/http" - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - "github.com/jellydator/ttlcache/v3" - "github.com/prometheus/client_golang/prometheus" - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/config" - "goauthentik.io/internal/outpost/ak" - "goauthentik.io/internal/outpost/proxyv2/hs256" - "goauthentik.io/internal/outpost/proxyv2/metrics" - "goauthentik.io/internal/outpost/proxyv2/templates" - "goauthentik.io/internal/outpost/proxyv2/types" - "goauthentik.io/internal/utils/web" - api "goauthentik.io/packages/client-go" - "golang.org/x/oauth2" -) - -type Application struct { - Host string - Cert *tls.Certificate - UnauthenticatedRegex []*regexp.Regexp - - endpoint OIDCEndpoint - oauthConfig oauth2.Config - tokenVerifier *oidc.IDTokenVerifier - outpostName string - sessionName string - - sessions sessions.Store - proxyConfig api.ProxyOutpostConfig - httpClient *http.Client - publicHostHTTPClient *http.Client - - log *log.Entry - mux *mux.Router - ak *ak.APIController - srv Server - - errorTemplates *template.Template - authHeaderCache *ttlcache.Cache[string, types.Claims] - - isEmbedded bool -} - -type Server interface { - API() *ak.APIController - Apps() []*Application - CryptoStore() *ak.CryptoStore - SessionBackend() string -} - -func init() { - gob.Register(types.Claims{}) -} - -func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, oldApp *Application) (*Application, error) { - muxLogger := log.WithField("logger", "authentik.outpost.proxyv2.application").WithField("name", p.Name) - - externalHost, err := url.Parse(p.ExternalHost) - if err != nil { - return nil, fmt.Errorf("failed to parse URL, skipping provider") - } - - var ks oidc.KeySet - if contains(p.OidcConfiguration.IdTokenSigningAlgValuesSupported, "HS256") { - ks = hs256.NewKeySet(*p.ClientSecret) - } else { - ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c) - ks = oidc.NewRemoteKeySet(ctx, p.OidcConfiguration.JwksUri) - } - - redirectUri, _ := url.Parse(p.ExternalHost) - redirectUri.Path = path.Join(redirectUri.Path, "/outpost.goauthentik.io/callback") - redirectUri.RawQuery = url.Values{ - CallbackSignature: []string{"true"}, - }.Encode() - - isEmbedded := server.API().IsEmbedded() - // Configure an OpenID Connect aware OAuth2 client. - endpoint := GetOIDCEndpoint( - p, - server.API().Outpost.Config["authentik_host"].(string), - isEmbedded, - ) - - verifier := oidc.NewVerifier(endpoint.Issuer, ks, &oidc.Config{ - ClientID: *p.ClientId, - SupportedSigningAlgs: []string{"RS256", "HS256"}, - }) - - oauth2Config := oauth2.Config{ - ClientID: *p.ClientId, - ClientSecret: *p.ClientSecret, - RedirectURL: redirectUri.String(), - Endpoint: endpoint.Endpoint, - Scopes: p.ScopesToRequest, - } - mux := mux.NewRouter() - - // Save cookie name, based on hashed client ID - hs := sha256.Sum256([]byte(*p.ClientId)) - bs := hex.EncodeToString(hs[:]) - sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8]) - - // When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match - // otherwise we use the internally configured authentik_host - tokenEndpointHost := server.API().Outpost.Config["authentik_host"].(string) - if config.Get().AuthentikHostBrowser != "" { - tokenEndpointHost = config.Get().AuthentikHostBrowser - } - publicHTTPClient := web.NewHostInterceptor(c, tokenEndpointHost) - - a := &Application{ - Host: externalHost.Host, - log: muxLogger, - outpostName: server.API().Outpost.Name, - sessionName: sessionName, - endpoint: endpoint, - oauthConfig: oauth2Config, - tokenVerifier: verifier, - proxyConfig: p, - httpClient: c, - publicHostHTTPClient: publicHTTPClient, - mux: mux, - errorTemplates: templates.GetTemplates(), - ak: server.API(), - authHeaderCache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, types.Claims]()), - srv: server, - isEmbedded: isEmbedded, - } - go a.authHeaderCache.Start() - if oldApp != nil && oldApp.sessions != nil { - a.sessions = oldApp.sessions - muxLogger.Debug("reusing existing session store") - } else { - sess, err := a.getStore(p, externalHost) - if err != nil { - return nil, err - } - a.sessions = sess - } - mux.Use(web.NewLoggingHandler(muxLogger, func(l *log.Entry, r *http.Request) *log.Entry { - c := a.getClaimsFromSession(nil, r) - if c == nil { - return l - } - if c.PreferredUsername != "" { - return l.WithField("user", c.PreferredUsername) - } - return l.WithField("user", c.Sub) - })) - mux.Use(func(inner http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - c := a.getClaimsFromSession(nil, r) - user := "" - if c != nil { - user = c.PreferredUsername - hub := sentry.GetHubFromContext(r.Context()) - if hub == nil { - hub = sentry.CurrentHub() - } - hub.Scope().SetUser(sentry.User{ - Username: user, - ID: c.Sub, - IPAddress: r.RemoteAddr, - }) - } - before := time.Now() - inner.ServeHTTP(rw, r) - elapsed := time.Since(before) - metrics.Requests.With(prometheus.Labels{ - "outpost_name": a.outpostName, - "type": "app", - "method": r.Method, - "host": web.GetHost(r), - }).Observe(float64(elapsed) / float64(time.Second)) - }) - }) - if server.API().GlobalConfig.ErrorReporting.Enabled { - mux.Use(sentryhttp.New(sentryhttp.Options{}).Handle) - } - mux.Use(func(inner http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.EqualFold(r.URL.Query().Get(CallbackSignature), "true") { - a.log.Debug("handling OAuth Callback from querystring signature") - a.handleAuthCallback(w, r) - } else if strings.EqualFold(r.URL.Query().Get(LogoutSignature), "true") { - a.log.Debug("handling OAuth Logout from querystring signature") - a.handleSignOut(w, r) - } else { - inner.ServeHTTP(w, r) - } - }) - }) - - mux.HandleFunc("/outpost.goauthentik.io/start", func(w http.ResponseWriter, r *http.Request) { - fwd := "" - // This should only really be hit for nginx forward_auth - // as for that the auth start redirect URL is generated by the - // reverse proxy, and as such we won't have a request we just - // denied to reference for final URL - rd, ok := a.checkRedirectParam(r) - if ok { - a.log.WithField("rd", rd).Trace("Setting redirect") - fwd = rd - } - a.handleAuthStart(w, r, fwd) - }) - mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleAuthCallback) - mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut) - switch *p.Mode { - case api.PROXYMODE_PROXY: - err = a.configureProxy() - case api.PROXYMODE_FORWARD_SINGLE: - fallthrough - case api.PROXYMODE_FORWARD_DOMAIN: - err = a.configureForward() - } - if err != nil { - return nil, fmt.Errorf("failed to configure application mode: %w", err) - } - - if kp := p.Certificate.Get(); kp != nil { - err := server.CryptoStore().AddKeypair(*kp) - if err != nil { - return nil, fmt.Errorf("failed to initially fetch certificate: %w", err) - } - a.Cert = server.CryptoStore().Get(*kp) - } - - if *p.SkipPathRegex != "" { - a.UnauthenticatedRegex = make([]*regexp.Regexp, 0) - for regex := range strings.SplitSeq(*p.SkipPathRegex, "\n") { - re, err := regexp.Compile(regex) - if err != nil { - // TODO: maybe create event for this? - a.log.WithError(err).Warning("failed to compile SkipPathRegex") - continue - } - a.UnauthenticatedRegex = append(a.UnauthenticatedRegex, re) - } - } - return a, nil -} - -func (a *Application) Mode() api.ProxyMode { - return *a.proxyConfig.Mode -} - -func (a *Application) ShouldHandleURL(r *http.Request) bool { - if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io") { - return true - } - if strings.EqualFold(r.URL.Query().Get(CallbackSignature), "true") { - return true - } - if strings.EqualFold(r.URL.Query().Get(LogoutSignature), "true") { - return true - } - return false -} - -func (a *Application) ProxyConfig() api.ProxyOutpostConfig { - return a.proxyConfig -} - -func (a *Application) ServeHTTP(rw http.ResponseWriter, r *http.Request) { - a.mux.ServeHTTP(rw, r) -} - -func (a *Application) Stop() { - a.authHeaderCache.Stop() -} - -func (a *Application) handleSignOut(rw http.ResponseWriter, r *http.Request) { - redirect := a.endpoint.EndSessionEndpoint - cc := a.getClaimsFromSession(rw, r) - if cc == nil { - a.redirectToStart(rw, r) - return - } - uv := url.Values{ - "id_token_hint": []string{cc.RawToken}, - } - redirect += "?" + uv.Encode() - err := a.Logout(r.Context(), func(c types.Claims) bool { - return c.Sub == cc.Sub - }) - if err != nil { - a.log.WithError(err).Warning("failed to logout of other sessions") - } - http.Redirect(rw, r, redirect, http.StatusFound) -} diff --git a/internal/outpost/proxyv2/application/auth.go b/internal/outpost/proxyv2/application/auth.go deleted file mode 100644 index 4ce594d838a8..000000000000 --- a/internal/outpost/proxyv2/application/auth.go +++ /dev/null @@ -1,120 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - "time" - - "github.com/mitchellh/mapstructure" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -// checkAuth Get claims which are currently in session -// Returns an error if the session can't be loaded or the claims can't be parsed/type-cast -func (a *Application) checkAuth(rw http.ResponseWriter, r *http.Request) (*types.Claims, error) { - c := a.getClaimsFromSession(rw, r) - if c != nil { - return c, nil - } - - if rw == nil { - return nil, fmt.Errorf("no response writer") - } - // Check TTL cache - c = a.getClaimsFromCache(r) - if c != nil { - return c, nil - } - // Check bearer token if set - bearer := a.checkAuthHeaderBearer(r) - if bearer != "" { - a.log.Trace("checking bearer token") - tc := a.attemptBearerAuth(bearer) - if tc != nil { - return a.saveAndCacheClaims(rw, r, tc.Claims) - } - a.log.Trace("no/invalid bearer token") - } - // Check basic auth if set - username, password, basicSet := r.BasicAuth() - if basicSet { - a.log.Trace("checking basic auth") - tc := a.attemptBasicAuth(username, password) - if tc != nil { - return a.saveAndCacheClaims(rw, r, *tc) - } - a.log.Trace("no/invalid basic auth") - } - - return nil, fmt.Errorf("failed to get claims from session") -} - -func (a *Application) getClaimsFromSession(rw http.ResponseWriter, r *http.Request) *types.Claims { - s, err := a.sessions.Get(r, a.SessionName()) - if err != nil { - // err == user has no session/session is not valid - // Delete the stale session cookie if it exists - if rw != nil { - s.Options.MaxAge = -1 - if saveErr := s.Save(r, rw); saveErr != nil { - a.log.WithError(saveErr).Warning("failed to delete stale session cookie") - } - } - return nil - } - claims, ok := s.Values[constants.SessionClaims] - if claims == nil || !ok { - // no claims saved, reject - return nil - } - - // Claims are always stored as types.Claims but may be deserialized differently: - // - Filesystem store (gob): preserves struct type as types.Claims - // - PostgreSQL store (JSON): deserializes as map[string]any - - // Handle struct type (filesystem store) - if c, ok := claims.(types.Claims); ok { - return &c - } - - // Handle map type (PostgreSQL store) - if claimsMap, ok := claims.(map[string]any); ok { - var c types.Claims - if err := mapstructure.Decode(claimsMap, &c); err != nil { - return nil - } - return &c - } - - return nil -} - -func (a *Application) getClaimsFromCache(r *http.Request) *types.Claims { - key := r.Header.Get(constants.HeaderAuthorization) - item := a.authHeaderCache.Get(key) - if item != nil && !item.IsExpired() { - v := item.Value() - return &v - } - return nil -} - -func (a *Application) saveAndCacheClaims(rw http.ResponseWriter, r *http.Request, claims types.Claims) (*types.Claims, error) { - s, _ := a.sessions.Get(r, a.SessionName()) - - s.Values[constants.SessionClaims] = claims - err := s.Save(r, rw) - if err != nil { - return nil, err - } - - key := r.Header.Get(constants.HeaderAuthorization) - item := a.authHeaderCache.Get(key) - // Don't set when the key is already found - if item == nil { - a.authHeaderCache.Set(key, claims, time.Second*60) - } - r.Header.Del(constants.HeaderAuthorization) - return &claims, nil -} diff --git a/internal/outpost/proxyv2/application/auth_basic.go b/internal/outpost/proxyv2/application/auth_basic.go deleted file mode 100644 index 061fec6f0a94..000000000000 --- a/internal/outpost/proxyv2/application/auth_basic.go +++ /dev/null @@ -1,87 +0,0 @@ -package application - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/url" - "strings" - - "goauthentik.io/internal/outpost/proxyv2/types" -) - -type TokenResponse struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` -} - -const JWTUsername = "goauthentik.io/token" - -func (a *Application) attemptBasicAuth(username, password string) *types.Claims { - if username == JWTUsername { - res := a.attemptBearerAuth(password) - if res != nil { - return &res.Claims - } - } - values := url.Values{ - "grant_type": []string{"client_credentials"}, - "client_id": []string{a.oauthConfig.ClientID}, - "username": []string{username}, - "password": []string{password}, - "scope": []string{strings.Join(a.oauthConfig.Scopes, " ")}, - } - req, err := http.NewRequest("POST", a.endpoint.TokenURL, strings.NewReader(values.Encode())) - if err != nil { - a.log.WithError(err).Warning("failed to create token request") - return nil - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - res, err := a.publicHostHTTPClient.Do(req) - if err != nil { - a.log.WithError(err).Warning("failed to send token request") - return nil - } - defer func() { - if err := res.Body.Close(); err != nil { - a.log.WithError(err).Warning("failed to close response body") - } - }() - - if res.StatusCode > 200 { - b, readErr := io.ReadAll(res.Body) - if readErr != nil { - b = []byte(readErr.Error()) - a.log.WithError(readErr).WithField("body", string(b)).Warning("failed to read error response body") - } else { - a.log.WithField("body", string(b)).Warning("failed to send token request") - } - return nil - } - - var token TokenResponse - err = json.NewDecoder(res.Body).Decode(&token) - if err != nil { - a.log.WithError(err).Warning("failed to parse token response") - return nil - } - // Parse and verify ID Token payload. - idToken, err := a.tokenVerifier.Verify(context.Background(), token.IDToken) - if err != nil { - a.log.WithError(err).Warning("failed to verify token") - return nil - } - - // Extract custom claims - var claims *types.Claims - if err := idToken.Claims(&claims); err != nil { - a.log.WithError(err).Warning("failed to convert token to claims") - return nil - } - if claims.Proxy == nil { - claims.Proxy = &types.ProxyClaims{} - } - claims.RawToken = token.IDToken - return claims -} diff --git a/internal/outpost/proxyv2/application/auth_bearer.go b/internal/outpost/proxyv2/application/auth_bearer.go deleted file mode 100644 index 4e84ea7f0b02..000000000000 --- a/internal/outpost/proxyv2/application/auth_bearer.go +++ /dev/null @@ -1,61 +0,0 @@ -package application - -import ( - "encoding/json" - "net/http" - "net/url" - "strings" - - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -func (a *Application) checkAuthHeaderBearer(r *http.Request) string { - auth := r.Header.Get(constants.HeaderAuthorization) - if auth == "" { - return "" - } - if len(auth) < len(constants.AuthBearer) || !strings.EqualFold(auth[:len(constants.AuthBearer)], constants.AuthBearer) { - return "" - } - return auth[len(constants.AuthBearer):] -} - -type TokenIntrospectionResponse struct { - types.Claims - Scope string `json:"scope"` - Active bool `json:"active"` - ClientID string `json:"client_id"` -} - -func (a *Application) attemptBearerAuth(token string) *TokenIntrospectionResponse { - values := url.Values{ - "client_id": []string{a.oauthConfig.ClientID}, - "client_secret": []string{a.oauthConfig.ClientSecret}, - "token": []string{token}, - } - req, err := http.NewRequest("POST", a.endpoint.TokenIntrospection, strings.NewReader(values.Encode())) - if err != nil { - a.log.WithError(err).Warning("failed to create introspection request") - return nil - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - res, err := a.publicHostHTTPClient.Do(req) - if err != nil || res.StatusCode > 200 { - a.log.WithError(err).Warning("failed to send introspection request") - return nil - } - intro := TokenIntrospectionResponse{} - err = json.NewDecoder(res.Body).Decode(&intro) - if err != nil { - a.log.WithError(err).Warning("failed to parse introspection response") - return nil - } - if !intro.Active { - a.log.Warning("token is not active") - return nil - } - intro.RawToken = token - a.log.Trace("successfully introspected bearer token") - return &intro -} diff --git a/internal/outpost/proxyv2/application/auth_test.go b/internal/outpost/proxyv2/application/auth_test.go deleted file mode 100644 index 89876e281bac..000000000000 --- a/internal/outpost/proxyv2/application/auth_test.go +++ /dev/null @@ -1,363 +0,0 @@ -package application - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gorilla/sessions" - "github.com/mitchellh/mapstructure" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -// TestClaimsJSONSerialization tests that Claims can be serialized to JSON and back -func TestClaimsJSONSerialization(t *testing.T) { - claims := types.Claims{ - Sub: "user-id-123", - Exp: 1234567890, - Email: "test@example.com", - Verified: true, - Name: "Test User", - PreferredUsername: "testuser", - Groups: []string{"admin", "user"}, - Entitlements: []string{"read", "write"}, - Sid: "session-id-456", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "custom_field": "custom_value", - "department": "engineering", - }, - BackendOverride: "custom-backend", - HostHeader: "example.com", - IsSuperuser: true, - }, - RawToken: "raw.jwt.token", - } - - // Serialize to JSON - jsonData, err := json.Marshal(claims) - require.NoError(t, err) - - // Deserialize back - var parsedClaims types.Claims - err = json.Unmarshal(jsonData, &parsedClaims) - require.NoError(t, err) - - // Verify all fields - assert.Equal(t, claims.Sub, parsedClaims.Sub) - assert.Equal(t, claims.Exp, parsedClaims.Exp) - assert.Equal(t, claims.Email, parsedClaims.Email) - assert.Equal(t, claims.Verified, parsedClaims.Verified) - assert.Equal(t, claims.Name, parsedClaims.Name) - assert.Equal(t, claims.PreferredUsername, parsedClaims.PreferredUsername) - assert.Equal(t, claims.Groups, parsedClaims.Groups) - assert.Equal(t, claims.Entitlements, parsedClaims.Entitlements) - assert.Equal(t, claims.Sid, parsedClaims.Sid) - - // RawToken has no json tag, so it's serialized using the field name - assert.Equal(t, claims.RawToken, parsedClaims.RawToken) - - // Verify proxy claims - require.NotNil(t, parsedClaims.Proxy) - assert.Equal(t, claims.Proxy.BackendOverride, parsedClaims.Proxy.BackendOverride) - assert.Equal(t, claims.Proxy.HostHeader, parsedClaims.Proxy.HostHeader) - assert.Equal(t, claims.Proxy.IsSuperuser, parsedClaims.Proxy.IsSuperuser) - assert.Equal(t, "custom_value", parsedClaims.Proxy.UserAttributes["custom_field"]) - assert.Equal(t, "engineering", parsedClaims.Proxy.UserAttributes["department"]) -} - -// TestClaimsMapSerialization tests that Claims stored as map[string]any can be converted back -func TestClaimsMapSerialization(t *testing.T) { - // Simulate how claims are stored in session as map (like from PostgreSQL JSONB) - claimsMap := map[string]any{ - "sub": "user-id-123", - "exp": float64(1234567890), // json numbers become float64 - "email": "test@example.com", - "email_verified": true, - "name": "Test User", - "preferred_username": "testuser", - "groups": []any{"admin", "user"}, - "entitlements": []any{"read", "write"}, - "sid": "session-id-456", - "ak_proxy": map[string]any{ - "user_attributes": map[string]any{ - "custom_field": "custom_value", - }, - "backend_override": "custom-backend", - "host_header": "example.com", - "is_superuser": true, - }, - "raw_token": "not-a-real-token", - } - - // Convert map to Claims using mapstructure marshaling (like getClaimsFromSession does) - var claims types.Claims - err := mapstructure.Decode(claimsMap, &claims) - require.NoError(t, err) - - // Verify fields - assert.Equal(t, "user-id-123", claims.Sub) - assert.Equal(t, 1234567890, claims.Exp) - assert.Equal(t, "test@example.com", claims.Email) - assert.True(t, claims.Verified) - assert.Equal(t, "Test User", claims.Name) - assert.Equal(t, "testuser", claims.PreferredUsername) - assert.Equal(t, []string{"admin", "user"}, claims.Groups) - assert.Equal(t, []string{"read", "write"}, claims.Entitlements) - assert.Equal(t, "session-id-456", claims.Sid) - assert.Equal(t, "not-a-real-token", claims.RawToken) - - // Verify proxy claims - require.NotNil(t, claims.Proxy) - assert.Equal(t, "custom-backend", claims.Proxy.BackendOverride) - assert.Equal(t, "example.com", claims.Proxy.HostHeader) - assert.True(t, claims.Proxy.IsSuperuser) - assert.Equal(t, "custom_value", claims.Proxy.UserAttributes["custom_field"]) -} - -// TestClaimsMinimalFields tests that Claims work with minimal required fields -func TestClaimsMinimalFields(t *testing.T) { - claimsMap := map[string]any{ - "sub": "user-id-123", - "exp": float64(1234567890), - } - - jsonData, err := json.Marshal(claimsMap) - require.NoError(t, err) - - var claims types.Claims - err = json.Unmarshal(jsonData, &claims) - require.NoError(t, err) - - assert.Equal(t, "user-id-123", claims.Sub) - assert.Equal(t, 1234567890, claims.Exp) - assert.Empty(t, claims.Email) - assert.Empty(t, claims.Name) - assert.Empty(t, claims.Groups) - assert.Nil(t, claims.Proxy) -} - -// TestClaimsWithEmptyArrays tests that empty arrays are handled correctly -func TestClaimsWithEmptyArrays(t *testing.T) { - claimsMap := map[string]any{ - "sub": "user-id-123", - "exp": float64(1234567890), - "groups": []any{}, - "entitlements": []any{}, - } - - jsonData, err := json.Marshal(claimsMap) - require.NoError(t, err) - - var claims types.Claims - err = json.Unmarshal(jsonData, &claims) - require.NoError(t, err) - - assert.Equal(t, "user-id-123", claims.Sub) - assert.NotNil(t, claims.Groups) - assert.NotNil(t, claims.Entitlements) - assert.Len(t, claims.Groups, 0) - assert.Len(t, claims.Entitlements, 0) -} - -// TestClaimsWithNullProxyClaims tests that null proxy claims don't cause issues -func TestClaimsWithNullProxyClaims(t *testing.T) { - claimsMap := map[string]any{ - "sub": "user-id-123", - "exp": float64(1234567890), - "ak_proxy": nil, - } - - jsonData, err := json.Marshal(claimsMap) - require.NoError(t, err) - - var claims types.Claims - err = json.Unmarshal(jsonData, &claims) - require.NoError(t, err) - - assert.Equal(t, "user-id-123", claims.Sub) - assert.Nil(t, claims.Proxy) -} - -// TestGetClaimsFromSession_Success tests successful retrieval of claims from session -// uses a mock session that returns claims as map[string]any to simulate -// how PostgreSQL storage deserializes JSONB data -func TestGetClaimsFromSession_Success(t *testing.T) { - // Create a custom mock store that returns claims as map - store := &mockMapSessionStore{ - claimsMap: map[string]any{ - "sub": "user-id-123", - "exp": float64(1234567890), - "email": "test@example.com", - "email_verified": true, - "preferred_username": "testuser", - "groups": []any{"admin", "user"}, - }, - } - - app := &Application{ - sessions: store, - } - - req := httptest.NewRequest("GET", "/", nil) - - // Test getClaimsFromSession - claims := app.getClaimsFromSession(nil, req) - require.NotNil(t, claims) - assert.Equal(t, "user-id-123", claims.Sub) - assert.Equal(t, 1234567890, claims.Exp) - assert.Equal(t, "test@example.com", claims.Email) - assert.True(t, claims.Verified) - assert.Equal(t, "testuser", claims.PreferredUsername) - assert.Equal(t, []string{"admin", "user"}, claims.Groups) -} - -// mockMapSessionStore is a mock session store that returns claims as map[string]any -type mockMapSessionStore struct { - claimsMap map[string]any -} - -func (m *mockMapSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) { - session := sessions.NewSession(m, name) - if m.claimsMap != nil { - session.Values[constants.SessionClaims] = m.claimsMap - } - return session, nil -} - -func (m *mockMapSessionStore) New(r *http.Request, name string) (*sessions.Session, error) { - return m.Get(r, name) -} - -func (m *mockMapSessionStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { - return nil -} - -// TestGetClaimsFromSession_NoSession tests behavior when no session exists -func TestGetClaimsFromSession_NoSession(t *testing.T) { - store := &mockMapSessionStore{ - claimsMap: nil, // No claims - } - - app := &Application{ - sessions: store, - } - - req := httptest.NewRequest("GET", "/", nil) - - claims := app.getClaimsFromSession(nil, req) - assert.Nil(t, claims) -} - -// TestGetClaimsFromSession_NoClaims tests behavior when session exists but has no claims -func TestGetClaimsFromSession_NoClaims(t *testing.T) { - store := &mockMapSessionStore{ - claimsMap: nil, // No claims in session - } - - app := &Application{ - sessions: store, - } - - req := httptest.NewRequest("GET", "/", nil) - - claims := app.getClaimsFromSession(nil, req) - assert.Nil(t, claims) -} - -// TestGetClaimsFromSession_InvalidClaimsType tests behavior when claims have wrong type -func TestGetClaimsFromSession_InvalidClaimsType(t *testing.T) { - store := &mockInvalidClaimsStore{} - - app := &Application{ - sessions: store, - } - - req := httptest.NewRequest("GET", "/", nil) - - claims := app.getClaimsFromSession(nil, req) - assert.Nil(t, claims) -} - -// mockInvalidClaimsStore returns claims as invalid type (string) -type mockInvalidClaimsStore struct{} - -func (m *mockInvalidClaimsStore) Get(r *http.Request, name string) (*sessions.Session, error) { - session := sessions.NewSession(m, name) - session.Values[constants.SessionClaims] = "invalid-string-value" - return session, nil -} - -func (m *mockInvalidClaimsStore) New(r *http.Request, name string) (*sessions.Session, error) { - return m.Get(r, name) -} - -func (m *mockInvalidClaimsStore) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error { - return nil -} - -// TestClaimsRoundTrip tests full round trip: save Claims, retrieve as map, convert back to Claims -func TestClaimsRoundTrip(t *testing.T) { - originalClaims := types.Claims{ - Sub: "user-id-789", - Exp: 1234567890, - Email: "roundtrip@example.com", - Verified: true, - Name: "Round Trip User", - PreferredUsername: "roundtripuser", - Groups: []string{"group1", "group2", "group3"}, - Entitlements: []string{"ent1", "ent2"}, - Sid: "session-789", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "attr1": "value1", - "attr2": float64(42), - "attr3": true, - }, - BackendOverride: "backend", - HostHeader: "host.example.com", - IsSuperuser: false, - }, - } - - // Step 1: Serialize Claims to JSON (simulating storage) - jsonData, err := json.Marshal(originalClaims) - require.NoError(t, err) - - // Step 2: Deserialize to map[string]any (simulating PostgreSQL load) - var claimsMap map[string]any - err = json.Unmarshal(jsonData, &claimsMap) - require.NoError(t, err) - - // Step 3: Convert map back to Claims (simulating getClaimsFromSession) - jsonData2, err := json.Marshal(claimsMap) - require.NoError(t, err) - - var retrievedClaims types.Claims - err = json.Unmarshal(jsonData2, &retrievedClaims) - require.NoError(t, err) - - // Verify all fields match - assert.Equal(t, originalClaims.Sub, retrievedClaims.Sub) - assert.Equal(t, originalClaims.Exp, retrievedClaims.Exp) - assert.Equal(t, originalClaims.Email, retrievedClaims.Email) - assert.Equal(t, originalClaims.Verified, retrievedClaims.Verified) - assert.Equal(t, originalClaims.Name, retrievedClaims.Name) - assert.Equal(t, originalClaims.PreferredUsername, retrievedClaims.PreferredUsername) - assert.Equal(t, originalClaims.Groups, retrievedClaims.Groups) - assert.Equal(t, originalClaims.Entitlements, retrievedClaims.Entitlements) - assert.Equal(t, originalClaims.Sid, retrievedClaims.Sid) - - require.NotNil(t, retrievedClaims.Proxy) - assert.Equal(t, originalClaims.Proxy.BackendOverride, retrievedClaims.Proxy.BackendOverride) - assert.Equal(t, originalClaims.Proxy.HostHeader, retrievedClaims.Proxy.HostHeader) - assert.Equal(t, originalClaims.Proxy.IsSuperuser, retrievedClaims.Proxy.IsSuperuser) - assert.Equal(t, "value1", retrievedClaims.Proxy.UserAttributes["attr1"]) - assert.Equal(t, float64(42), retrievedClaims.Proxy.UserAttributes["attr2"]) - assert.Equal(t, true, retrievedClaims.Proxy.UserAttributes["attr3"]) -} diff --git a/internal/outpost/proxyv2/application/endpoint.go b/internal/outpost/proxyv2/application/endpoint.go deleted file mode 100644 index 7b26d7fff75b..000000000000 --- a/internal/outpost/proxyv2/application/endpoint.go +++ /dev/null @@ -1,90 +0,0 @@ -package application - -import ( - "net/url" - - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/config" - api "goauthentik.io/packages/client-go" - "golang.org/x/oauth2" -) - -type OIDCEndpoint struct { - oauth2.Endpoint - TokenIntrospection string - EndSessionEndpoint string - JwksUri string - Issuer string -} - -func updateURL(rawUrl string, scheme string, host string) string { - u, err := url.Parse(rawUrl) - if err != nil { - return rawUrl - } - u.Host = host - u.Scheme = scheme - return u.String() -} - -func GetOIDCEndpoint(p api.ProxyOutpostConfig, authentikHost string, embedded bool) OIDCEndpoint { - authUrl := p.OidcConfiguration.AuthorizationEndpoint - endUrl := p.OidcConfiguration.EndSessionEndpoint - jwksUri := p.OidcConfiguration.JwksUri - issuer := p.OidcConfiguration.Issuer - ep := OIDCEndpoint{ - Endpoint: oauth2.Endpoint{ - AuthURL: authUrl, - TokenURL: p.OidcConfiguration.TokenEndpoint, - AuthStyle: oauth2.AuthStyleInParams, - }, - EndSessionEndpoint: endUrl, - JwksUri: jwksUri, - TokenIntrospection: p.OidcConfiguration.IntrospectionEndpoint, - Issuer: issuer, - } - aku, err := url.Parse(authentikHost) - if err != nil { - return ep - } - // For the embedded outpost, we use the configure `authentik_host` for the browser URLs - // and localhost (which is what we've got from the API) for backchannel URLs - // - // For other outposts, when `AUTHENTIK_HOST_BROWSER` is set, we use that for the browser URLs - // and use what we got from the API for backchannel - hostBrowser := config.Get().AuthentikHostBrowser - if !embedded && hostBrowser == "" { - return ep - } - var newHost = aku - var newBrowserHost *url.URL - if embedded { - if authentikHost == "" { - log.Warning("Outpost has localhost/blank API Connection but no authentik_host is configured.") - return ep - } - newBrowserHost = aku - } else if hostBrowser != "" { - browser, err := url.Parse(hostBrowser) - if err != nil { - return ep - } - newBrowserHost = browser - } - // Update all browser-accessed URLs to use the new host and scheme - ep.AuthURL = updateURL(authUrl, newBrowserHost.Scheme, newBrowserHost.Host) - ep.EndSessionEndpoint = updateURL(endUrl, newBrowserHost.Scheme, newBrowserHost.Host) - // Update issuer to use the same host and scheme, which would normally break as we don't - // change the token URL here, but the token HTTP transport overwrites the Host header - // - // This is only used in embedded outposts as there we can guarantee that the request - // is routed correctly - if embedded { - ep.Issuer = updateURL(ep.Issuer, newHost.Scheme, newHost.Host) - ep.JwksUri = updateURL(jwksUri, newHost.Scheme, newHost.Host) - } else { - // Fixes: https://github.com/goauthentik/authentik/issues/9622 / ep.Issuer must be the HostBrowser URL - ep.Issuer = updateURL(ep.Issuer, newBrowserHost.Scheme, newBrowserHost.Host) - } - return ep -} diff --git a/internal/outpost/proxyv2/application/endpoint_test.go b/internal/outpost/proxyv2/application/endpoint_test.go deleted file mode 100644 index 8daf215f56a9..000000000000 --- a/internal/outpost/proxyv2/application/endpoint_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package application - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/config" - api "goauthentik.io/packages/client-go" -) - -func TestEndpointDefault(t *testing.T) { - pc := api.ProxyOutpostConfig{ - OidcConfiguration: api.OpenIDConnectConfiguration{ - AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/", - EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/", - IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/", - Issuer: "https://test.goauthentik.io/application/o/test-app/", - JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/", - TokenEndpoint: "https://test.goauthentik.io/application/o/token/", - }, - } - - ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", false) - // Standard outpost, non embedded - // All URLs should use the host that they get from the config - assert.Equal(t, "https://test.goauthentik.io/application/o/authorize/", ep.AuthURL) - assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL) - assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/", ep.Issuer) - assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri) - assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint) - assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection) -} - -func TestEndpointAuthentikHostBrowser(t *testing.T) { - c := config.Get() - c.AuthentikHostBrowser = "https://browser.test.goauthentik.io" - defer func() { - c.AuthentikHostBrowser = "" - }() - pc := api.ProxyOutpostConfig{ - OidcConfiguration: api.OpenIDConnectConfiguration{ - AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/", - EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/", - IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/", - Issuer: "https://test.goauthentik.io/application/o/test-app/", - JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/", - TokenEndpoint: "https://test.goauthentik.io/application/o/token/", - UserinfoEndpoint: "https://test.goauthentik.io/application/o/userinfo/", - }, - } - - ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", false) - // Standard outpost, with AUTHENTIK_HOST_BROWSER set - // Only the authorize/end session URLs should be changed - assert.Equal(t, "https://browser.test.goauthentik.io/application/o/authorize/", ep.AuthURL) - assert.Equal(t, "https://browser.test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint) - assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL) - assert.Equal(t, "https://browser.test.goauthentik.io/application/o/test-app/", ep.Issuer) - assert.Equal(t, "https://test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri) - assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection) -} - -func TestEndpointEmbedded(t *testing.T) { - pc := api.ProxyOutpostConfig{ - OidcConfiguration: api.OpenIDConnectConfiguration{ - AuthorizationEndpoint: "https://test.goauthentik.io/application/o/authorize/", - EndSessionEndpoint: "https://test.goauthentik.io/application/o/test-app/end-session/", - IntrospectionEndpoint: "https://test.goauthentik.io/application/o/introspect/", - Issuer: "https://test.goauthentik.io/application/o/test-app/", - JwksUri: "https://test.goauthentik.io/application/o/test-app/jwks/", - TokenEndpoint: "https://test.goauthentik.io/application/o/token/", - UserinfoEndpoint: "https://test.goauthentik.io/application/o/userinfo/", - }, - } - - ep := GetOIDCEndpoint(pc, "https://authentik-host.test.goauthentik.io", true) - // Embedded outpost - // Browser URLs should use the config of "authentik_host", everything else can use what's - // received from the API endpoint - // Token URL is an exception since it's sent via a special HTTP transport that overrides the - // HTTP Host header, to make sure it's the same value as the issuer - assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/authorize/", ep.AuthURL) - assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/", ep.Issuer) - assert.Equal(t, "https://test.goauthentik.io/application/o/token/", ep.TokenURL) - assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/jwks/", ep.JwksUri) - assert.Equal(t, "https://authentik-host.test.goauthentik.io/application/o/test-app/end-session/", ep.EndSessionEndpoint) - assert.Equal(t, "https://test.goauthentik.io/application/o/introspect/", ep.TokenIntrospection) -} diff --git a/internal/outpost/proxyv2/application/error.go b/internal/outpost/proxyv2/application/error.go deleted file mode 100644 index 8db0b48c33b2..000000000000 --- a/internal/outpost/proxyv2/application/error.go +++ /dev/null @@ -1,41 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - - log "github.com/sirupsen/logrus" -) - -type ErrorPageData struct { - Title string - Message string - ProxyPrefix string -} - -func (a *Application) ErrorPage(rw http.ResponseWriter, r *http.Request, err string) { - claims, _ := a.checkAuth(rw, r) - data := ErrorPageData{ - Title: "Bad Gateway", - Message: "Error proxying to upstream server", - ProxyPrefix: "/outpost.goauthentik.io", - } - if claims != nil && claims.Proxy != nil && claims.Proxy.IsSuperuser { - data.Message = err - } else { - data.Message = "Failed to connect to backend." - } - er := a.errorTemplates.Execute(rw, data) - if er != nil { - http.Error(rw, "Internal Server Error", http.StatusInternalServerError) - } -} - -// NewProxyErrorHandler creates a ProxyErrorHandler using the template given. -func (a *Application) newProxyErrorHandler() func(http.ResponseWriter, *http.Request, error) { - return func(rw http.ResponseWriter, req *http.Request, proxyErr error) { - log.WithError(proxyErr).Warning("Error proxying to upstream server") - rw.WriteHeader(http.StatusBadGateway) - a.ErrorPage(rw, req, fmt.Sprintf("Error proxying to upstream server: %v", proxyErr)) - } -} diff --git a/internal/outpost/proxyv2/application/mode_common.go b/internal/outpost/proxyv2/application/mode_common.go deleted file mode 100644 index 239b226c1a69..000000000000 --- a/internal/outpost/proxyv2/application/mode_common.go +++ /dev/null @@ -1,156 +0,0 @@ -package application - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -func (a *Application) addHeaders(headers http.Header, c *types.Claims) { - nh := a.getHeaders(c) - for key, val := range nh { - headers.Set(key, val) - } - a.removeDuplicateUnderscoreHeader(headers) -} - -func (a *Application) removeDuplicateUnderscoreHeader(h http.Header) { - for key := range h { - ush := strings.ReplaceAll(key, "_", "-") - if _, ok := h[ush]; !ok { - h.Del(key) - } - } -} - -func (a *Application) getHeaders(c *types.Claims) map[string]string { - headers := map[string]string{} - // https://docs.goauthentik.io/add-secure-apps/providers/proxy - headers["X-authentik-username"] = c.PreferredUsername - headers["X-authentik-groups"] = strings.Join(c.Groups, "|") - headers["X-authentik-entitlements"] = strings.Join(c.Entitlements, "|") - headers["X-authentik-email"] = c.Email - headers["X-authentik-name"] = c.Name - headers["X-authentik-uid"] = c.Sub - headers["X-authentik-jwt"] = c.RawToken - - // System headers - headers["X-authentik-meta-jwks"] = a.endpoint.JwksUri - headers["X-authentik-meta-outpost"] = a.outpostName - headers["X-authentik-meta-provider"] = a.proxyConfig.Name - headers["X-authentik-meta-app"] = a.proxyConfig.AssignedApplicationSlug - headers["X-authentik-meta-version"] = constants.UserAgentOutpost() - - if c.Proxy == nil { - return headers - } - if authz := a.setAuthorizationHeader(c); authz != "" { - headers["Authorization"] = authz - } - // Check if user has additional headers set that we should sent - userAttributes := c.Proxy.UserAttributes - if additionalHeaders, ok := userAttributes["additionalHeaders"]; ok { - a.log.WithField("headers", additionalHeaders).Trace("setting additional headers") - if additionalHeaders == nil { - return headers - } - for key, value := range additionalHeaders.(map[string]any) { - headers[key] = toString(value) - } - } - return headers -} - -// Attempt to set basic auth based on user's attributes -func (a *Application) setAuthorizationHeader(c *types.Claims) string { - if !*a.proxyConfig.BasicAuthEnabled { - return "" - } - userAttributes := c.Proxy.UserAttributes - var ok bool - var username string - var password string - if password, ok = userAttributes[*a.proxyConfig.BasicAuthPasswordAttribute].(string); !ok { - password = "" - } - // Check if we should use email or a custom attribute as username - if username, ok = userAttributes[*a.proxyConfig.BasicAuthUserAttribute].(string); !ok { - username = c.Email - } - if password == "" { - return "" - } - authVal := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - a.log.WithField("username", username).Trace("setting http basic auth") - return fmt.Sprintf("Basic %s", authVal) -} - -// getTraefikForwardUrl See https://doc.traefik.io/traefik/middlewares/forwardauth/ -func (a *Application) getTraefikForwardUrl(r *http.Request) (*url.URL, error) { - u, err := url.Parse(fmt.Sprintf( - "%s://%s%s", - r.Header.Get("X-Forwarded-Proto"), - r.Header.Get("X-Forwarded-Host"), - r.Header.Get("X-Forwarded-Uri"), - )) - if err != nil { - return nil, err - } - a.log.WithField("url", u.String()).Trace("traefik forwarded url") - return u, nil -} - -// getNginxForwardUrl See https://github.com/kubernetes/ingress-nginx/blob/main/rootfs/etc/nginx/template/nginx.tmpl -func (a *Application) getNginxForwardUrl(r *http.Request) (*url.URL, error) { - h := r.Header.Get("X-Original-URL") - if len(h) < 1 { - return nil, errors.New("no forward URL found") - } - u, err := url.Parse(h) - if err != nil { - a.log.WithError(err).Warning("failed to parse URL from nginx") - return nil, err - } - a.log.WithField("url", u.String()).Trace("nginx forwarded url") - return u, nil -} - -func (a *Application) ReportMisconfiguration(r *http.Request, msg string, fields map[string]any) { - fields["message"] = msg - a.log.WithFields(fields).Error("Reporting configuration error") - req := api.EventRequest{ - Action: api.EVENTACTIONS_CONFIGURATION_ERROR, - App: "authentik.providers.proxy", // must match python apps.py name - ClientIp: *api.NewNullableString(new(r.RemoteAddr)), - Context: fields, - } - _, _, err := a.ak.Client.EventsAPI.EventsEventsCreate(context.Background()).EventRequest(req).Execute() - if err != nil { - a.log.WithError(err).Warning("failed to report configuration error") - } -} - -func (a *Application) IsAllowlisted(u *url.URL) bool { - for _, ur := range a.UnauthenticatedRegex { - var testString string - if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE { - testString = u.Path - } else { - testString = u.String() - } - match := ur.MatchString(testString) - a.log.WithField("match", match).WithField("regex", ur.String()).WithField("url", testString).Trace("Matching URL against allow list") - if match { - return true - } - } - return false -} diff --git a/internal/outpost/proxyv2/application/mode_common_test.go b/internal/outpost/proxyv2/application/mode_common_test.go deleted file mode 100644 index 6419856a106a..000000000000 --- a/internal/outpost/proxyv2/application/mode_common_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package application - -import ( - "net/http" - "net/url" - "regexp" - "testing" - - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -func urlMustParse(u string) *url.URL { - ur, err := url.Parse(u) - if err != nil { - panic(err) - } - return ur -} - -func TestIsAllowlisted_Proxy_Single(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr() - - assert.Equal(t, false, a.IsAllowlisted(urlMustParse(""))) - a.UnauthenticatedRegex = []*regexp.Regexp{ - regexp.MustCompile("^/foo"), - } - assert.Equal(t, true, a.IsAllowlisted(urlMustParse("http://some-host/foo"))) -} - -func TestIsAllowlisted_Proxy_Domain(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - - assert.Equal(t, false, a.IsAllowlisted(urlMustParse(""))) - a.UnauthenticatedRegex = []*regexp.Regexp{ - regexp.MustCompile("^/foo"), - } - assert.Equal(t, false, a.IsAllowlisted(urlMustParse("http://some-host/foo"))) - a.UnauthenticatedRegex = []*regexp.Regexp{ - regexp.MustCompile("^http://some-host/foo"), - } - assert.Equal(t, true, a.IsAllowlisted(urlMustParse("http://some-host/foo"))) - a.UnauthenticatedRegex = []*regexp.Regexp{ - regexp.MustCompile("https://health.domain.tld/ping/*"), - } - assert.Equal(t, false, a.IsAllowlisted(urlMustParse("http://some-host/foo"))) - assert.Equal(t, false, a.IsAllowlisted(urlMustParse("https://health.domain.tld/"))) - assert.Equal(t, true, a.IsAllowlisted(urlMustParse("https://health.domain.tld/ping/qq"))) -} - -func TestAdHeaders_Standard(t *testing.T) { - a := newTestApplication() - h := http.Header{} - a.addHeaders(h, &types.Claims{ - PreferredUsername: "foo", - Groups: []string{"foo", "bar"}, - Entitlements: []string{"bar", "quox"}, - Email: "bar@authentik.company", - Name: "foo", - Sub: "bar", - RawToken: "baz", - }) - assert.Equal(t, http.Header{ - "X-Authentik-Email": []string{"bar@authentik.company"}, - "X-Authentik-Entitlements": []string{"bar|quox"}, - "X-Authentik-Groups": []string{"foo|bar"}, - "X-Authentik-Jwt": []string{"baz"}, - "X-Authentik-Meta-App": []string{""}, - "X-Authentik-Meta-Jwks": []string{""}, - "X-Authentik-Meta-Outpost": []string{""}, - "X-Authentik-Meta-Provider": []string{a.proxyConfig.Name}, - "X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()}, - "X-Authentik-Name": []string{"foo"}, - "X-Authentik-Uid": []string{"bar"}, - "X-Authentik-Username": []string{"foo"}, - }, h) -} - -func TestAdHeaders_BasicAuth(t *testing.T) { - a := newTestApplication() - a.proxyConfig.BasicAuthEnabled = new(true) - a.proxyConfig.BasicAuthUserAttribute = new("user") - a.proxyConfig.BasicAuthPasswordAttribute = new("pass") - h := http.Header{} - a.addHeaders(h, &types.Claims{ - PreferredUsername: "foo", - Groups: []string{"foo", "bar"}, - Entitlements: []string{"bar", "quox"}, - Email: "bar@authentik.company", - Name: "foo", - Sub: "bar", - RawToken: "baz", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "user": "foo", - "pass": "baz", - }, - }, - }) - assert.Equal(t, http.Header{ - "Authorization": []string{"Basic Zm9vOmJheg=="}, - "X-Authentik-Email": []string{"bar@authentik.company"}, - "X-Authentik-Entitlements": []string{"bar|quox"}, - "X-Authentik-Groups": []string{"foo|bar"}, - "X-Authentik-Jwt": []string{"baz"}, - "X-Authentik-Meta-App": []string{""}, - "X-Authentik-Meta-Jwks": []string{""}, - "X-Authentik-Meta-Outpost": []string{""}, - "X-Authentik-Meta-Provider": []string{a.proxyConfig.Name}, - "X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()}, - "X-Authentik-Name": []string{"foo"}, - "X-Authentik-Uid": []string{"bar"}, - "X-Authentik-Username": []string{"foo"}, - }, h) -} - -func TestAdHeaders_Extra(t *testing.T) { - a := newTestApplication() - h := http.Header{} - a.addHeaders(h, &types.Claims{ - PreferredUsername: "foo", - Groups: []string{"foo", "bar"}, - Entitlements: []string{"bar", "quox"}, - Email: "bar@authentik.company", - Name: "foo", - Sub: "bar", - RawToken: "baz", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "additionalHeaders": map[string]any{ - "foo": "bar", - }, - }, - }, - }) - assert.Equal(t, http.Header{ - "Foo": []string{"bar"}, - "X-Authentik-Email": []string{"bar@authentik.company"}, - "X-Authentik-Entitlements": []string{"bar|quox"}, - "X-Authentik-Groups": []string{"foo|bar"}, - "X-Authentik-Jwt": []string{"baz"}, - "X-Authentik-Meta-App": []string{""}, - "X-Authentik-Meta-Jwks": []string{""}, - "X-Authentik-Meta-Outpost": []string{""}, - "X-Authentik-Meta-Provider": []string{a.proxyConfig.Name}, - "X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()}, - "X-Authentik-Name": []string{"foo"}, - "X-Authentik-Uid": []string{"bar"}, - "X-Authentik-Username": []string{"foo"}, - }, h) -} - -func TestAdHeaders_UnderscoreInitial(t *testing.T) { - a := newTestApplication() - h := http.Header{} - h.Set("X_AUTHENTIK_USERNAME", "another user") - h.Set("X-Authentik_username", "another user") - a.addHeaders(h, &types.Claims{ - PreferredUsername: "foo", - Groups: []string{"foo", "bar"}, - Entitlements: []string{"bar", "quox"}, - Email: "bar@authentik.company", - Name: "foo", - Sub: "bar", - RawToken: "baz", - }) - assert.Equal(t, http.Header{ - "X-Authentik-Email": []string{"bar@authentik.company"}, - "X-Authentik-Entitlements": []string{"bar|quox"}, - "X-Authentik-Groups": []string{"foo|bar"}, - "X-Authentik-Jwt": []string{"baz"}, - "X-Authentik-Meta-App": []string{""}, - "X-Authentik-Meta-Jwks": []string{""}, - "X-Authentik-Meta-Outpost": []string{""}, - "X-Authentik-Meta-Provider": []string{a.proxyConfig.Name}, - "X-Authentik-Meta-Version": []string{constants.UserAgentOutpost()}, - "X-Authentik-Name": []string{"foo"}, - "X-Authentik-Uid": []string{"bar"}, - "X-Authentik-Username": []string{"foo"}, - }, h) -} diff --git a/internal/outpost/proxyv2/application/mode_forward.go b/internal/outpost/proxyv2/application/mode_forward.go deleted file mode 100644 index 2bb6a6fe118e..000000000000 --- a/internal/outpost/proxyv2/application/mode_forward.go +++ /dev/null @@ -1,177 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - "strings" - - "goauthentik.io/internal/outpost/proxyv2/constants" -) - -const ( - envoyPrefix = "/outpost.goauthentik.io/auth/envoy" - caddyPrefix = "/outpost.goauthentik.io/auth/caddy" - traefikPrefix = "/outpost.goauthentik.io/auth/traefik" - nginxPrefix = "/outpost.goauthentik.io/auth/nginx" -) - -func (a *Application) configureForward() error { - a.mux.HandleFunc(traefikPrefix, a.forwardHandleTraefik) - a.mux.HandleFunc(caddyPrefix, a.forwardHandleCaddy) - a.mux.HandleFunc(nginxPrefix, a.forwardHandleNginx) - a.mux.PathPrefix(envoyPrefix).HandlerFunc(a.forwardHandleEnvoy) - return nil -} - -func (a *Application) forwardHandleTraefik(rw http.ResponseWriter, r *http.Request) { - a.log.WithField("header", r.Header).Trace("tracing headers for debug") - // First check if we've got everything we need - fwd, err := a.getTraefikForwardUrl(r) - if err != nil { - a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from Traefik", a.outpostName, a.proxyConfig.Name), map[string]any{ - "provider": a.proxyConfig.Name, - "outpost": a.outpostName, - "url": r.URL.String(), - "headers": cleanseHeaders(r.Header), - }) - http.Error(rw, "configuration error", http.StatusInternalServerError) - return - } - tr := r.Clone(r.Context()) - tr.URL = fwd - if strings.EqualFold(fwd.Query().Get(CallbackSignature), "true") { - a.log.Debug("handling OAuth Callback from querystring signature") - a.handleAuthCallback(rw, tr) - return - } else if strings.EqualFold(fwd.Query().Get(LogoutSignature), "true") { - a.log.Debug("handling OAuth Logout from querystring signature") - a.handleSignOut(rw, r) - return - } - // Check if we're authenticated, or the request path is on the allowlist - claims, err := a.checkAuth(rw, r) - if claims != nil && err == nil { - a.addHeaders(rw.Header(), claims) - rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) - a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") - return - } else if claims == nil && a.IsAllowlisted(fwd) { - a.log.Trace("path can be accessed without authentication") - return - } - // set the redirect flag to the current URL we have, since we redirect - // to a (possibly) different domain, but we want to be redirected back - // to the application - // X-Forwarded-Uri is only the path, so we need to build the entire URL - a.handleAuthStart(rw, r, fwd.String()) -} - -func (a *Application) forwardHandleCaddy(rw http.ResponseWriter, r *http.Request) { - a.log.WithField("header", r.Header).Trace("tracing headers for debug") - // First check if we've got everything we need - fwd, err := a.getTraefikForwardUrl(r) - if err != nil { - a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from Caddy", a.outpostName, a.proxyConfig.Name), map[string]any{ - "provider": a.proxyConfig.Name, - "outpost": a.outpostName, - "url": r.URL.String(), - "headers": cleanseHeaders(r.Header), - }) - http.Error(rw, "configuration error", http.StatusInternalServerError) - return - } - tr := r.Clone(r.Context()) - tr.URL = fwd - if strings.EqualFold(fwd.Query().Get(CallbackSignature), "true") { - a.log.Debug("handling OAuth Callback from querystring signature") - a.handleAuthCallback(rw, tr) - return - } else if strings.EqualFold(fwd.Query().Get(LogoutSignature), "true") { - a.log.Debug("handling OAuth Logout from querystring signature") - a.handleSignOut(rw, r) - return - } - // Check if we're authenticated, or the request path is on the allowlist - claims, err := a.checkAuth(rw, r) - if claims != nil && err == nil { - a.addHeaders(rw.Header(), claims) - rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) - a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") - return - } else if claims == nil && a.IsAllowlisted(fwd) { - a.log.Trace("path can be accessed without authentication") - return - } - // set the redirect flag to the current URL we have, since we redirect - // to a (possibly) different domain, but we want to be redirected back - // to the application - // X-Forwarded-Uri is only the path, so we need to build the entire URL - a.handleAuthStart(rw, r, fwd.String()) -} - -func (a *Application) forwardHandleNginx(rw http.ResponseWriter, r *http.Request) { - a.log.WithField("header", r.Header).Trace("tracing headers for debug") - fwd, err := a.getNginxForwardUrl(r) - if err != nil { - a.ReportMisconfiguration(r, fmt.Sprintf("Outpost %s (Provider %s) failed to detect a forward URL from nginx", a.outpostName, a.proxyConfig.Name), map[string]any{ - "provider": a.proxyConfig.Name, - "outpost": a.outpostName, - "url": r.URL.String(), - "headers": cleanseHeaders(r.Header), - }) - http.Error(rw, "configuration error", http.StatusInternalServerError) - return - } - - claims, err := a.checkAuth(rw, r) - if claims != nil && err == nil { - a.addHeaders(rw.Header(), claims) - rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) - rw.WriteHeader(200) - a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") - return - } else if claims == nil && a.IsAllowlisted(fwd) { - a.log.Trace("path can be accessed without authentication") - return - } - - s, _ := a.sessions.Get(r, a.SessionName()) - if _, redirectSet := s.Values[constants.SessionRedirect]; !redirectSet { - s.Values[constants.SessionRedirect] = fwd.String() - err = s.Save(r, rw) - if err != nil { - a.log.WithError(err).Warning("failed to save session before redirect") - } - } - - if fwd.String() != r.URL.String() { - if strings.HasPrefix(fwd.Path, "/outpost.goauthentik.io") { - a.log.WithField("url", r.URL.String()).Trace("path begins with /outpost.goauthentik.io, allowing access") - return - } - } - http.Error(rw, "unauthorized request", http.StatusUnauthorized) -} - -func (a *Application) forwardHandleEnvoy(rw http.ResponseWriter, r *http.Request) { - a.log.WithField("header", r.Header).Trace("tracing headers for debug") - r.URL.Path = strings.TrimPrefix(r.URL.Path, envoyPrefix) - r.URL.Host = r.Host - fwd := r.URL - // Check if we're authenticated, or the request path is on the allowlist - claims, err := a.checkAuth(rw, r) - if claims != nil && err == nil { - a.addHeaders(rw.Header(), claims) - rw.Header().Set("User-Agent", r.Header.Get("User-Agent")) - a.log.WithField("headers", rw.Header()).Trace("headers written to forward_auth") - return - } else if claims == nil && a.IsAllowlisted(fwd) { - a.log.Trace("path can be accessed without authentication") - return - } - // set the redirect flag to the current URL we have, since we redirect - // to a (possibly) different domain, but we want to be redirected back - // to the application - // X-Forwarded-Uri is only the path, so we need to build the entire URL - a.handleAuthStart(rw, r, fwd.String()) -} diff --git a/internal/outpost/proxyv2/application/mode_forward_caddy_test.go b/internal/outpost/proxyv2/application/mode_forward_caddy_test.go deleted file mode 100644 index 2137d0f486ab..000000000000 --- a/internal/outpost/proxyv2/application/mode_forward_caddy_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -func TestForwardHandleCaddy_Single_Blank(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleCaddy_Single_Skip(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/skip") - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) -} - -func TestForwardHandleCaddy_Single_Headers(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect) -} - -func TestForwardHandleCaddy_Single_Claims(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "username": "foo", - "password": "bar", - "additionalHeaders": map[string]any{ - "foo": "bar", - }, - }, - }, - } - err := a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - rr = httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - h := rr.Result().Header - - assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"]) - assert.Equal(t, []string{"bar"}, h["Foo"]) - assert.Equal(t, []string{""}, h["User-Agent"]) - assert.Equal(t, []string{""}, h["X-Authentik-Email"]) - assert.Equal(t, []string{""}, h["X-Authentik-Groups"]) - assert.Equal(t, []string{""}, h["X-Authentik-Jwt"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"]) - assert.Equal(t, []string{""}, h["X-Authentik-Name"]) - assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"]) - assert.Equal(t, []string{""}, h["X-Authentik-Username"]) -} - -func TestForwardHandleCaddy_Domain_Blank(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleCaddy_Domain_Header(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io" - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/caddy", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleCaddy(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect) -} diff --git a/internal/outpost/proxyv2/application/mode_forward_envoy_test.go b/internal/outpost/proxyv2/application/mode_forward_envoy_test.go deleted file mode 100644 index 56e033b57d8d..000000000000 --- a/internal/outpost/proxyv2/application/mode_forward_envoy_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -func TestForwardHandleEnvoy_Single_Skip(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http://test.goauthentik.io/skip", nil) - - rr := httptest.NewRecorder() - a.forwardHandleEnvoy(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) -} - -func TestForwardHandleEnvoy_Single_Headers(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http:///app", nil) - req.Host = "ext.t.goauthentik.io" - - rr := httptest.NewRecorder() - a.forwardHandleEnvoy(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://ext.t.goauthentik.io/app", st.Redirect) -} - -func TestForwardHandleEnvoy_Single_Claims(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http://test.goauthentik.io/app", nil) - - rr := httptest.NewRecorder() - a.forwardHandleEnvoy(rr, req) - - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "username": "foo", - "password": "bar", - "additionalHeaders": map[string]any{ - "foo": "bar", - }, - }, - }, - } - err := a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - rr = httptest.NewRecorder() - a.forwardHandleEnvoy(rr, req) - - h := rr.Result().Header - - assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"]) - assert.Equal(t, []string{"bar"}, h["Foo"]) - assert.Equal(t, []string{""}, h["User-Agent"]) - assert.Equal(t, []string{""}, h["X-Authentik-Email"]) - assert.Equal(t, []string{""}, h["X-Authentik-Groups"]) - assert.Equal(t, []string{""}, h["X-Authentik-Jwt"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"]) - assert.Equal(t, []string{""}, h["X-Authentik-Name"]) - assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"]) - assert.Equal(t, []string{""}, h["X-Authentik-Username"]) -} - -func TestForwardHandleEnvoy_Domain_Header(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io" - req, _ := http.NewRequest("GET", "http:///app", nil) - req.Host = "test.goauthentik.io" - - rr := httptest.NewRecorder() - a.forwardHandleEnvoy(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect) -} diff --git a/internal/outpost/proxyv2/application/mode_forward_nginx_test.go b/internal/outpost/proxyv2/application/mode_forward_nginx_test.go deleted file mode 100644 index 5fa8cbf44f27..000000000000 --- a/internal/outpost/proxyv2/application/mode_forward_nginx_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package application - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - api "goauthentik.io/packages/client-go" -) - -func TestForwardHandleNginx_Single_Blank(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil) - - rr := httptest.NewRecorder() - a.forwardHandleNginx(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleNginx_Single_Skip(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil) - req.Header.Set("X-Original-URL", "http://test.goauthentik.io/skip") - - rr := httptest.NewRecorder() - a.forwardHandleNginx(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) -} - -func TestForwardHandleNginx_Single_Headers(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil) - req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app") - - rr := httptest.NewRecorder() - a.forwardHandleNginx(rr, req) - - assert.Equal(t, http.StatusUnauthorized, rr.Code) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect]) -} - -func TestForwardHandleNginx_Domain_Blank(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil) - - rr := httptest.NewRecorder() - a.forwardHandleNginx(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleNginx_Domain_Header(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io" - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/nginx", nil) - req.Header.Set("X-Original-URL", "http://test.goauthentik.io/app") - - rr := httptest.NewRecorder() - a.forwardHandleNginx(rr, req) - - assert.Equal(t, http.StatusUnauthorized, rr.Code) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "http://test.goauthentik.io/app", s.Values[constants.SessionRedirect]) -} diff --git a/internal/outpost/proxyv2/application/mode_forward_traefik_test.go b/internal/outpost/proxyv2/application/mode_forward_traefik_test.go deleted file mode 100644 index 611bce164605..000000000000 --- a/internal/outpost/proxyv2/application/mode_forward_traefik_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package application - -import ( - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -func TestForwardHandleTraefik_Single_Blank(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleTraefik_Single_Skip(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/skip") - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) -} - -func TestForwardHandleTraefik_Single_Headers(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect) -} - -func TestForwardHandleTraefik_Single_Claims(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - Proxy: &types.ProxyClaims{ - UserAttributes: map[string]any{ - "username": "foo", - "password": "bar", - "additionalHeaders": map[string]any{ - "foo": "bar", - }, - }, - }, - } - err := a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - rr = httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - h := rr.Result().Header - - assert.Equal(t, []string{"Basic Zm9vOmJhcg=="}, h["Authorization"]) - assert.Equal(t, []string{"bar"}, h["Foo"]) - assert.Equal(t, []string{""}, h["User-Agent"]) - assert.Equal(t, []string{""}, h["X-Authentik-Email"]) - assert.Equal(t, []string{""}, h["X-Authentik-Groups"]) - assert.Equal(t, []string{""}, h["X-Authentik-Jwt"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-App"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Jwks"]) - assert.Equal(t, []string{""}, h["X-Authentik-Meta-Outpost"]) - assert.Equal(t, []string{""}, h["X-Authentik-Name"]) - assert.Equal(t, []string{"foo"}, h["X-Authentik-Uid"]) - assert.Equal(t, []string{""}, h["X-Authentik-Username"]) -} - -func TestForwardHandleTraefik_Domain_Blank(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - assert.Equal(t, http.StatusInternalServerError, rr.Code) -} - -func TestForwardHandleTraefik_Domain_Header(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("foo") - a.proxyConfig.ExternalHost = "http://auth.test.goauthentik.io" - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "http") - req.Header.Set("X-Forwarded-Host", "test.goauthentik.io") - req.Header.Set("X-Forwarded-Uri", "/app") - - rr := httptest.NewRecorder() - a.forwardHandleTraefik(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, st := a.assertState(t, req, rr) - shouldUrl := url.Values{ - "client_id": []string{*a.proxyConfig.ClientId}, - "redirect_uri": []string{"https://ext.t.goauthentik.io/outpost.goauthentik.io/callback?X-authentik-auth-callback=true"}, - "response_type": []string{"code"}, - } - assert.Equal(t, fmt.Sprintf("http://fake-auth.t.goauthentik.io/auth?%s", shouldUrl.Encode()), loc.String()) - assert.Equal(t, "http://test.goauthentik.io/app", st.Redirect) -} diff --git a/internal/outpost/proxyv2/application/mode_proxy.go b/internal/outpost/proxyv2/application/mode_proxy.go deleted file mode 100644 index 79126d0017a7..000000000000 --- a/internal/outpost/proxyv2/application/mode_proxy.go +++ /dev/null @@ -1,98 +0,0 @@ -package application - -import ( - "context" - "crypto/tls" - "net/http" - "net/http/httputil" - "net/url" - "time" - - "github.com/getsentry/sentry-go" - "github.com/prometheus/client_golang/prometheus" - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/outpost/proxyv2/metrics" - "goauthentik.io/internal/utils/web" -) - -func (a *Application) getUpstreamTransport() http.RoundTripper { - return &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !*a.proxyConfig.InternalHostSslValidation}, - } -} - -func (a *Application) configureProxy() error { - // Reverse proxy to the application server - u, err := url.Parse(*a.proxyConfig.InternalHost) - if err != nil { - return err - } - rsp := sentry.StartSpan(context.TODO(), "authentik.outposts.proxy.application_transport") - rp := &httputil.ReverseProxy{ - Director: a.proxyModifyRequest(u), - Transport: web.NewTracingTransport(rsp.Context(), a.getUpstreamTransport()), - ErrorHandler: a.newProxyErrorHandler(), - ModifyResponse: a.proxyModifyResponse, - FlushInterval: -1, - } - a.mux.PathPrefix("/").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - defer func() { - err := recover() - if err == nil || err == http.ErrAbortHandler { - return - } - log.WithError(err.(error)).Error("recover in reverse proxy") - }() - claims, err := a.checkAuth(rw, r) - if claims == nil && a.IsAllowlisted(r.URL) { - a.log.Trace("path can be accessed without authentication") - } else if claims == nil && err != nil { - a.log.WithError(err).Trace("no claims") - a.redirectToStart(rw, r) - return - } else { - a.addHeaders(r.Header, claims) - } - before := time.Now() - rp.ServeHTTP(rw, r) - elapsed := time.Since(before) - - metrics.UpstreamTiming.With(prometheus.Labels{ - "outpost_name": a.outpostName, - "upstream_host": r.URL.Host, - "method": r.Method, - "scheme": r.URL.Scheme, - "host": web.GetHost(r), - }).Observe(float64(elapsed) / float64(time.Second)) - }) - return nil -} - -func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) { - return func(r *http.Request) { - r.Header.Set("X-Forwarded-Host", r.Host) - r.URL.Scheme = ou.Scheme - r.URL.Host = ou.Host - claims := a.getClaimsFromSession(nil, r) - if claims != nil && claims.Proxy != nil { - if claims.Proxy.BackendOverride != "" { - u, err := url.Parse(claims.Proxy.BackendOverride) - if err != nil { - a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override") - } else { - r.URL.Scheme = u.Scheme - r.URL.Host = u.Host - } - } - if claims.Proxy.HostHeader != "" { - r.Host = claims.Proxy.HostHeader - } - } - a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url") - } -} - -func (a *Application) proxyModifyResponse(res *http.Response) error { - res.Header.Set("X-Powered-By", "goauthentik.io") - return nil -} diff --git a/internal/outpost/proxyv2/application/mode_proxy_test.go b/internal/outpost/proxyv2/application/mode_proxy_test.go deleted file mode 100644 index a481fefdda37..000000000000 --- a/internal/outpost/proxyv2/application/mode_proxy_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package application - -import ( - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -func TestProxy_ModifyRequest(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http://frontend/foo", nil) - u, err := url.Parse("http://backend:8012") - if err != nil { - panic(err) - } - a.proxyModifyRequest(u)(req) - - assert.Equal(t, "frontend", req.Header.Get("X-Forwarded-Host")) - assert.Equal(t, "/foo", req.URL.Path) - assert.Equal(t, "backend:8012", req.URL.Host) - assert.Equal(t, "frontend", req.Host) -} - -func TestProxy_Redirect(t *testing.T) { - a := newTestApplication() - _ = a.configureProxy() - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil) - rr := httptest.NewRecorder() - - a.mux.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal( - t, - "https://ext.t.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Fext.t.goauthentik.io%2Ffoo", - loc.String(), - ) -} - -func TestProxy_Redirect_Subdirectory(t *testing.T) { - a := newTestApplication() - a.proxyConfig.ExternalHost = a.proxyConfig.ExternalHost + "/subdir" - _ = a.configureProxy() - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil) - rr := httptest.NewRecorder() - - a.mux.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal( - t, - "https://ext.t.goauthentik.io/subdir/outpost.goauthentik.io/start?rd=https%3A%2F%2Fext.t.goauthentik.io%2Fsubdir%2Ffoo", - loc.String(), - ) -} - -func TestProxy_ModifyRequest_Claims(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http://frontend/foo", nil) - u, err := url.Parse("http://backend:8012") - if err != nil { - panic(err) - } - rr := httptest.NewRecorder() - - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - Proxy: &types.ProxyClaims{ - BackendOverride: "http://other-backend:8123", - }, - } - err = a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - a.proxyModifyRequest(u)(req) - - assert.Equal(t, "/foo", req.URL.Path) - assert.Equal(t, "other-backend:8123", req.URL.Host) - assert.Equal(t, "frontend", req.Host) -} - -func TestProxy_ModifyRequest_Claims_Invalid(t *testing.T) { - a := newTestApplication() - req, _ := http.NewRequest("GET", "http://frontend/foo", nil) - u, err := url.Parse("http://backend:8012") - if err != nil { - panic(err) - } - rr := httptest.NewRecorder() - - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - Proxy: &types.ProxyClaims{ - BackendOverride: ":qewr", - }, - } - err = a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - a.proxyModifyRequest(u)(req) - - assert.Equal(t, "/foo", req.URL.Path) - assert.Equal(t, "backend:8012", req.URL.Host) - assert.Equal(t, "frontend", req.Host) -} diff --git a/internal/outpost/proxyv2/application/oauth.go b/internal/outpost/proxyv2/application/oauth.go deleted file mode 100644 index ee59db06b0ff..000000000000 --- a/internal/outpost/proxyv2/application/oauth.go +++ /dev/null @@ -1,106 +0,0 @@ -package application - -import ( - "context" - "net/http" - "net/url" - "strings" - - "goauthentik.io/internal/outpost/proxyv2/constants" - api "goauthentik.io/packages/client-go" -) - -const ( - redirectParam = "rd" - CallbackSignature = "X-authentik-auth-callback" - LogoutSignature = "X-authentik-logout" -) - -func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, fwd string) { - state, err := a.createState(r, rw, fwd) - if err != nil { - a.log.WithError(err).Warning("failed to create state") - if !strings.HasPrefix(err.Error(), "failed to get session") { - rw.WriteHeader(400) - return - } - - // Client has a cookie but we're unable to load the session from - // storage (TMPDIR=/dev/shm). This can happen if the session file - // was deleted due to container restart or session invalidation - // (e.g., logout on auth server). - // - // Re-save an empty session and try again. - - session, err := a.sessions.Get(r, a.SessionName()) - if err != nil && !strings.HasSuffix(err.Error(), "no such file or directory") { - a.log.WithError(err).Warning("failed to get session") - rw.WriteHeader(400) - return - } - err = a.sessions.Save(r, rw, session) - if err != nil { - a.log.WithError(err).Warning("failed to save session") - rw.WriteHeader(400) - return - } - - // The registry caches the previous attempt to open the session so it - // needs to be cleared in order to get the session in createState(). - *r = *r.WithContext(context.Background()) - - state, err = a.createState(r, rw, fwd) - if err != nil { - a.log.WithError(err).Warning("failed to create state on retry") - rw.WriteHeader(400) - return - } - } - http.Redirect(rw, r, a.oauthConfig.AuthCodeURL(state), http.StatusFound) -} - -func (a *Application) redirectToStart(rw http.ResponseWriter, r *http.Request) { - s, err := a.sessions.Get(r, a.SessionName()) - if err != nil { - a.log.WithError(err).Warning("failed to decode session") - } - if r.Header.Get(constants.HeaderAuthorization) != "" && *a.proxyConfig.InterceptHeaderAuth { - rw.WriteHeader(401) - er := a.errorTemplates.Execute(rw, ErrorPageData{ - Title: "Unauthenticated", - Message: "Due to 'Receive header authentication' being set, no redirect is performed.", - ProxyPrefix: "/outpost.goauthentik.io", - }) - if er != nil { - http.Error(rw, "Internal Server Error", http.StatusInternalServerError) - } - } - - redirectUrl := urlJoin(a.proxyConfig.ExternalHost, r.URL.EscapedPath()) - if r.URL.RawQuery != "" { - redirectUrl += "?" + r.URL.RawQuery - } - - if a.Mode() == api.PROXYMODE_FORWARD_DOMAIN { - dom := strings.TrimPrefix(*a.proxyConfig.CookieDomain, ".") - // In forward_domain we only check that the current URL's host - // ends with the cookie domain (remove the leading period if set) - if !strings.HasSuffix(r.URL.Hostname(), dom) { - a.log.WithField("url", r.URL.String()).WithField("cd", dom).Warning("Invalid redirect found") - redirectUrl = a.proxyConfig.ExternalHost - } - } - if _, redirectSet := s.Values[constants.SessionRedirect]; !redirectSet { - s.Values[constants.SessionRedirect] = redirectUrl - err = s.Save(r, rw) - if err != nil { - a.log.WithError(err).Warning("failed to save session before redirect") - } - } - - urlArgs := url.Values{ - redirectParam: []string{redirectUrl}, - } - authUrl := urlJoin(a.proxyConfig.ExternalHost, "/outpost.goauthentik.io/start") - http.Redirect(rw, r, authUrl+"?"+urlArgs.Encode(), http.StatusFound) -} diff --git a/internal/outpost/proxyv2/application/oauth_callback.go b/internal/outpost/proxyv2/application/oauth_callback.go deleted file mode 100644 index 781eb67c5ec9..000000000000 --- a/internal/outpost/proxyv2/application/oauth_callback.go +++ /dev/null @@ -1,75 +0,0 @@ -package application - -import ( - "context" - "fmt" - "net/http" - "net/url" - "time" - - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" - "golang.org/x/oauth2" -) - -func (a *Application) handleAuthCallback(rw http.ResponseWriter, r *http.Request) { - state := a.stateFromRequest(rw, r) - if state == nil { - a.log.Warning("invalid state") - a.redirect(rw, r) - return - } - claims, err := a.redeemCallback(r.URL, r.Context()) - if err != nil { - a.log.WithError(err).Warning("failed to redeem code") - a.redirect(rw, r) - return - } - s, err := a.sessions.Get(r, a.SessionName()) - if err != nil { - a.log.WithError(err).Trace("failed to get session") - } - s.Options.MaxAge = int(time.Until(time.Unix(int64(claims.Exp), 0)).Seconds()) - s.Values[constants.SessionClaims] = claims - err = s.Save(r, rw) - if err != nil { - a.log.WithError(err).Warning("failed to save session") - rw.WriteHeader(400) - return - } - a.redirect(rw, r) -} - -func (a *Application) redeemCallback(u *url.URL, c context.Context) (*types.Claims, error) { - code := u.Query().Get("code") - if code == "" { - return nil, fmt.Errorf("blank code") - } - - ctx := context.WithValue(c, oauth2.HTTPClient, a.publicHostHTTPClient) - // Verify state and errors. - oauth2Token, err := a.oauthConfig.Exchange(ctx, code) - if err != nil { - return nil, err - } - - jwt := oauth2Token.AccessToken - a.log.WithField("jwt", jwt).Trace("access_token") - - // Parse and verify ID Token payload. - idToken, err := a.tokenVerifier.Verify(ctx, jwt) - if err != nil { - return nil, err - } - - // Extract custom claims - var claims *types.Claims - if err := idToken.Claims(&claims); err != nil { - return nil, err - } - if claims.Proxy == nil { - claims.Proxy = &types.ProxyClaims{} - } - claims.RawToken = jwt - return claims, nil -} diff --git a/internal/outpost/proxyv2/application/oauth_state.go b/internal/outpost/proxyv2/application/oauth_state.go deleted file mode 100644 index 7938df1a068e..000000000000 --- a/internal/outpost/proxyv2/application/oauth_state.go +++ /dev/null @@ -1,152 +0,0 @@ -package application - -import ( - "encoding/base32" - "encoding/base64" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/securecookie" - "github.com/mitchellh/mapstructure" - api "goauthentik.io/packages/client-go" -) - -type OAuthState struct { - Issuer string `json:"iss" mapstructure:"iss"` - SessionID string `json:"sid" mapstructure:"sid"` - State string `json:"state" mapstructure:"state"` - Redirect string `json:"redirect" mapstructure:"redirect"` -} - -func (oas *OAuthState) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil } -func (oas *OAuthState) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil } -func (oas *OAuthState) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil } -func (oas *OAuthState) GetIssuer() (string, error) { return oas.Issuer, nil } -func (oas *OAuthState) GetSubject() (string, error) { return oas.State, nil } -func (oas *OAuthState) GetAudience() (jwt.ClaimStrings, error) { return nil, nil } - -var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding) - -// Validate that the given redirect parameter (?rd=...) is valid and can be used -// For proxy/forward_single this checks that if the `rd` param has a Hostname (and is a full URL) -// the hostname matches what's configured, or no hostname must be given -// For forward_domain this checks if the domain of the URL in `rd` ends with the configured domain -func (a *Application) checkRedirectParam(r *http.Request) (string, bool) { - rd := r.URL.Query().Get(redirectParam) - if rd == "" { - return "", false - } - u, err := url.Parse(rd) - if err != nil { - a.log.WithError(err).Warning("Failed to parse redirect URL") - return "", false - } - // Check to make sure we only redirect to allowed places - if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE { - ext, err := url.Parse(a.proxyConfig.ExternalHost) - if err != nil { - return "", false - } - // Either hostname needs to match the configured domain, or host name must be empty for just a path - if u.Host == "" { - u.Host = ext.Host - u.Scheme = ext.Scheme - } - if u.Host != ext.Host { - a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host") - return "", false - } - } else { - if !strings.HasSuffix(u.Hostname(), *a.proxyConfig.CookieDomain) { - a.log.WithField("host", u.Hostname()).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Hostname was not included in cookie domain") - return "", false - } - } - return u.String(), true -} - -func (a *Application) createState(r *http.Request, w http.ResponseWriter, fwd string) (string, error) { - s, err := a.sessions.Get(r, a.SessionName()) - if err != nil { - // Session file may not exist (e.g., after outpost restart or logout) - // Delete the stale session cookie and continue with the new empty session - a.log.WithError(err).Debug("failed to get session, clearing stale cookie") - s.Options.MaxAge = -1 - if saveErr := s.Save(r, w); saveErr != nil { - a.log.WithError(saveErr).Warning("failed to delete stale session cookie") - } - // Get a fresh session after clearing the stale cookie - s, _ = a.sessions.Get(r, a.SessionName()) - } - if s.ID == "" { - // Ensure session has an ID - s.ID = base32RawStdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) - // Save the session immediately so it persists - err := s.Save(r, w) - if err != nil { - return "", fmt.Errorf("failed to save session: %w", err) - } - } - st := &OAuthState{ - Issuer: fmt.Sprintf("goauthentik.io/outpost/%s", a.proxyConfig.GetClientId()), - State: base64.RawURLEncoding.EncodeToString(securecookie.GenerateRandomKey(32)), - SessionID: s.ID, - Redirect: fwd, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, st) - tokenString, err := token.SignedString([]byte(a.proxyConfig.GetCookieSecret())) - if err != nil { - return "", err - } - return tokenString, nil -} - -func (a *Application) stateFromRequest(rw http.ResponseWriter, r *http.Request) *OAuthState { - stateJwt := r.URL.Query().Get("state") - token, err := jwt.Parse(stateJwt, func(token *jwt.Token) (any, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(a.proxyConfig.GetCookieSecret()), nil - }) - if err != nil { - a.log.WithError(err).Warning("failed to parse state jwt") - return nil - } - iss, err := token.Claims.GetIssuer() - if err != nil { - a.log.WithError(err).Warning("state jwt without issuer") - return nil - } - if iss != fmt.Sprintf("goauthentik.io/outpost/%s", a.proxyConfig.GetClientId()) { - a.log.WithField("issuer", iss).Warning("invalid state jwt issuer") - return nil - } - claims := &OAuthState{} - err = mapstructure.Decode(token.Claims, &claims) - if err != nil { - a.log.WithError(err).Warning("failed to mapdecode") - return nil - } - s, err := a.sessions.Get(r, a.SessionName()) - if err != nil { - a.log.WithError(err).Warning("failed to get session") - // Delete the stale session cookie if it exists - if rw != nil { - s.Options.MaxAge = -1 - if saveErr := s.Save(r, rw); saveErr != nil { - a.log.WithError(saveErr).Warning("failed to delete stale session cookie") - } - } - return nil - } - if claims.SessionID != s.ID { - a.log.WithField("is", claims.SessionID).WithField("should", s.ID).Warning("mismatched session ID") - return nil - } - return claims -} diff --git a/internal/outpost/proxyv2/application/oauth_test.go b/internal/outpost/proxyv2/application/oauth_test.go deleted file mode 100644 index c77088c223c6..000000000000 --- a/internal/outpost/proxyv2/application/oauth_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package application - -import ( - "net/http" - "testing" - - "github.com/stretchr/testify/assert" - api "goauthentik.io/packages/client-go" -) - -func TestCheckRedirectParam_None(t *testing.T) { - a := newTestApplication() - // Test no rd param - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil) - - rd, ok := a.checkRedirectParam(req) - - assert.Equal(t, false, ok) - assert.Equal(t, "", rd) -} - -func TestCheckRedirectParam_Invalid(t *testing.T) { - a := newTestApplication() - // Test invalid rd param - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil) - - rd, ok := a.checkRedirectParam(req) - - assert.Equal(t, false, ok) - assert.Equal(t, "", rd) -} - -func TestCheckRedirectParam_ValidFull(t *testing.T) { - a := newTestApplication() - // Test valid full rd param - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil) - - rd, ok := a.checkRedirectParam(req) - - assert.Equal(t, true, ok) - assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd) -} - -func TestCheckRedirectParam_ValidPartial(t *testing.T) { - a := newTestApplication() - // Test valid partial rd param - req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=/test?foo", nil) - - rd, ok := a.checkRedirectParam(req) - - assert.Equal(t, true, ok) - assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd) -} - -func TestCheckRedirectParam_Domain(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.CookieDomain = new("t.goauthentik.io") - req, _ := http.NewRequest("GET", "https://a.t.goauthentik.io/outpost.goauthentik.io/auth/start", nil) - - rd, ok := a.checkRedirectParam(req) - - assert.Equal(t, false, ok) - assert.Equal(t, "", rd) - req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test", nil) - - rd, ok = a.checkRedirectParam(req) - - assert.Equal(t, true, ok) - assert.Equal(t, "https://ext.t.goauthentik.io/test", rd) -} diff --git a/internal/outpost/proxyv2/application/session.go b/internal/outpost/proxyv2/application/session.go deleted file mode 100644 index 1ff35f5df68d..000000000000 --- a/internal/outpost/proxyv2/application/session.go +++ /dev/null @@ -1,143 +0,0 @@ -package application - -import ( - "context" - "math" - "net/http" - "net/url" - "os" - "path" - "strings" - - "github.com/gorilla/securecookie" - "github.com/gorilla/sessions" - - "goauthentik.io/internal/outpost/proxyv2/codecs" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/filesystemstore" - "goauthentik.io/internal/outpost/proxyv2/postgresstore" - "goauthentik.io/internal/outpost/proxyv2/types" - api "goauthentik.io/packages/client-go" -) - -const PostgresKeyPrefix = "authentik_proxy_session_" - -func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL) (sessions.Store, error) { - maxAge := 0 - if p.AccessTokenValidity.IsSet() { - t := p.AccessTokenValidity.Get() - // Add one to the validity to ensure we don't have a session with indefinite length - maxAge = int(*t) + 1 - } - - sessionBackend := a.srv.SessionBackend() - switch sessionBackend { - case "postgres": - // New PostgreSQL store - ps, err := postgresstore.NewPostgresStore(a.log) - if err != nil { - return nil, err - } - - ps.KeyPrefix(PostgresKeyPrefix) - ps.Options(sessions.Options{ - HttpOnly: true, - Secure: strings.ToLower(externalHost.Scheme) == "https", - Domain: *p.CookieDomain, - SameSite: http.SameSiteLaxMode, - MaxAge: maxAge, - Path: "/", - }) - - return ps, nil - case "filesystem": - dir := os.TempDir() - cs, err := filesystemstore.GetPersistentStore(dir) - if err != nil { - return nil, err - } - cs.Codecs = codecs.CodecsFromPairs(maxAge, []byte(*p.CookieSecret)) - // https://github.com/markbates/goth/commit/7276be0fdf719ddff753f3574ef0f967e4a5a5f7 - // set the maxLength of the cookies stored on the disk to a larger number to prevent issues with: - // securecookie: the value is too long - // when using OpenID Connect, since this can contain a large amount of extra information in the id_token - - // Note, when using the FilesystemStore only the session.ID is written to a browser cookie, so this is explicit for the storage on disk - cs.MaxLength(math.MaxInt) - cs.Options.HttpOnly = true - cs.Options.Secure = strings.ToLower(externalHost.Scheme) == "https" - cs.Options.Domain = *p.CookieDomain - cs.Options.SameSite = http.SameSiteLaxMode - cs.Options.MaxAge = maxAge - cs.Options.Path = "/" - return cs, nil - default: - a.log.WithField("backend", sessionBackend).Panic("unknown session backend type") - return nil, nil - } -} - -func (a *Application) SessionName() string { - return a.sessionName -} - -func (a *Application) getAllCodecs() []securecookie.Codec { - apps := a.srv.Apps() - cs := []securecookie.Codec{} - for _, app := range apps { - cs = append(cs, codecs.CodecsFromPairs(0, []byte(*app.proxyConfig.CookieSecret))...) - } - return cs -} - -func (a *Application) Logout(ctx context.Context, filter func(c types.Claims) bool) error { - if _, ok := a.sessions.(*filesystemstore.Store); ok { - files, err := os.ReadDir(os.TempDir()) - if err != nil { - return err - } - for _, file := range files { - s := sessions.Session{} - if !strings.HasPrefix(file.Name(), "session_") { - continue - } - fullPath := path.Join(os.TempDir(), file.Name()) - data, err := os.ReadFile(fullPath) - if err != nil { - a.log.WithError(err).Warning("failed to read file") - continue - } - err = securecookie.DecodeMulti( - a.SessionName(), string(data), - &s.Values, a.getAllCodecs()..., - ) - if err != nil { - a.log.WithError(err).Trace("failed to decode session") - continue - } - rc, ok := s.Values[constants.SessionClaims] - if !ok || rc == nil { - continue - } - claims := s.Values[constants.SessionClaims].(types.Claims) - if filter(claims) { - a.log.WithField("path", fullPath).Trace("deleting session") - err := os.Remove(fullPath) - if err != nil { - a.log.WithError(err).Warning("failed to delete session") - continue - } - } - } - } - if ps, ok := a.sessions.(*postgresstore.PostgresStore); ok { - err := ps.LogoutSessions(ctx, func(c types.Claims) bool { - return filter(types.Claims(c)) - }) - if err != nil { - a.log.WithError(err).Warning("failed to logout sessions from PostgreSQL") - return err - } - } - return nil -} diff --git a/internal/outpost/proxyv2/application/session_postgres_test.go b/internal/outpost/proxyv2/application/session_postgres_test.go deleted file mode 100644 index b9fd5c30e2b2..000000000000 --- a/internal/outpost/proxyv2/application/session_postgres_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package application - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - _ "gorm.io/driver/postgres" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/postgresstore" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -func SetupTestDB(t *testing.T) (*gorm.DB, *postgresstore.RefreshableConnPool) { - cfg := config.Get().PostgreSQL - - gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - NowFunc: func() time.Time { - return time.Now().UTC() - }, - } - - // Use standardized setup - db, pool, err := postgresstore.SetupGORMWithRefreshablePool(cfg, gormConfig, 10, 100, time.Hour) - require.NoError(t, err) - - return db, pool -} - -func CleanupTestDB(t *testing.T, db *gorm.DB, pool *postgresstore.RefreshableConnPool) { - assert.NoError(t, db.Exec("DELETE FROM authentik_providers_proxy_proxysession").Error) - assert.NoError(t, pool.Close()) -} - -func NewTestStore(db *gorm.DB, pool *postgresstore.RefreshableConnPool) *postgresstore.PostgresStore { - return postgresstore.NewTestStore(db, pool) -} - -func TestPostgresStore_SessionLifecycle(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - // Create sessions directly in the database for testing - userID := uuid.New() - sessionKey := "test_session_" + uuid.New().String() - - sessionData := map[string]any{ - constants.SessionClaims: map[string]any{ - "sub": userID.String(), - "email": "test@example.com", - "preferred_username": "testuser", - "custom_claim": "custom_value", - "groups": []any{"admin", "user"}, - }, - } - sessionDataJSON, err := json.Marshal(sessionData) - require.NoError(t, err) - - session := postgresstore.ProxySession{ - UUID: uuid.New(), - SessionKey: sessionKey, - UserID: &userID, - SessionData: string(sessionDataJSON), - Expires: time.Now().Add(time.Hour), - } - - err = db.Create(&session).Error - require.NoError(t, err) - - // Verify session was created - var count int64 - db.Model(&postgresstore.ProxySession{}).Where("session_key = ?", sessionKey).Count(&count) - assert.Equal(t, int64(1), count) - - // Verify session data - var retrievedSession postgresstore.ProxySession - err = db.First(&retrievedSession, "session_key = ?", sessionKey).Error - require.NoError(t, err) - - assert.Equal(t, userID, *retrievedSession.UserID) - - // Parse session data - var parsedData map[string]any - err = json.Unmarshal([]byte(retrievedSession.SessionData), &parsedData) - require.NoError(t, err) - - claims, ok := parsedData[constants.SessionClaims].(map[string]any) - assert.True(t, ok) - assert.Equal(t, "test@example.com", claims["email"]) - assert.Equal(t, "testuser", claims["preferred_username"]) - assert.Equal(t, "custom_value", claims["custom_claim"]) -} - -func TestPostgresStore_LogoutSessions(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - // Create multiple sessions for different users - user1 := uuid.New() - user2 := uuid.New() - - createSessionData := func(userID uuid.UUID, email string) string { - sessionData := map[string]any{ - constants.SessionClaims: map[string]any{ - "sub": userID.String(), - "email": email, - }, - } - sessionDataJSON, _ := json.Marshal(sessionData) - return string(sessionDataJSON) - } - - sessions := []postgresstore.ProxySession{ - { - UUID: uuid.New(), - SessionKey: "session_user1_1", - UserID: &user1, - SessionData: createSessionData(user1, "user1@example.com"), - Expires: time.Now().Add(time.Hour), - }, - { - UUID: uuid.New(), - SessionKey: "session_user1_2", - UserID: &user1, - SessionData: createSessionData(user1, "user1@example.com"), - Expires: time.Now().Add(time.Hour), - }, - { - UUID: uuid.New(), - SessionKey: "session_user2_1", - UserID: &user2, - SessionData: createSessionData(user2, "user2@example.com"), - Expires: time.Now().Add(time.Hour), - }, - } - - for _, session := range sessions { - err := db.Create(&session).Error - require.NoError(t, err) - } - - // Verify all sessions were created - var totalCount int64 - db.Model(&postgresstore.ProxySession{}).Count(&totalCount) - assert.Equal(t, int64(3), totalCount) - - // Logout user1 sessions using LogoutSessions method - store := NewTestStore(db, pool) - err := store.LogoutSessions(context.Background(), func(c types.Claims) bool { - return c.Sub == user1.String() - }) - require.NoError(t, err) - - // Verify only user2 session remains - var remainingCount int64 - db.Model(&postgresstore.ProxySession{}).Count(&remainingCount) - assert.Equal(t, int64(1), remainingCount) - - var remainingSession postgresstore.ProxySession - err = db.First(&remainingSession).Error - require.NoError(t, err) - assert.Equal(t, user2, *remainingSession.UserID) -} - -func TestPostgresStore_SessionExpiration(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - // Create expired and valid sessions - expiredSession := postgresstore.ProxySession{ - UUID: uuid.New(), - SessionKey: "expired_session", - SessionData: "{}", - Expires: time.Now().Add(-time.Hour), - } - validSession := postgresstore.ProxySession{ - UUID: uuid.New(), - SessionKey: "valid_session", - SessionData: "{}", - Expires: time.Now().Add(time.Hour), - } - - err := db.Create(&expiredSession).Error - require.NoError(t, err) - err = db.Create(&validSession).Error - require.NoError(t, err) - - // Clean up expired sessions (this is like what CleanupExpiredSessions would do) - var sessions []postgresstore.ProxySession - err = db.Find(&sessions).Error - require.NoError(t, err) - - var expiredKeys []string - now := time.Now() - for _, session := range sessions { - expTime := session.Expires - if now.After(expTime) { - expiredKeys = append(expiredKeys, session.SessionKey) - } - } - - result := db.Delete(&postgresstore.ProxySession{}, "session_key IN ?", expiredKeys) - require.NoError(t, result.Error) - assert.Equal(t, int64(1), result.RowsAffected) - - // Verify only valid session remains - var count int64 - db.Model(&postgresstore.ProxySession{}).Count(&count) - assert.Equal(t, int64(1), count) - - var remaining postgresstore.ProxySession - err = db.First(&remaining).Error - require.NoError(t, err) - assert.Equal(t, "valid_session", remaining.SessionKey) -} - -func TestPostgresStore_SessionClaims(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - // Create session with complex claims - userID := uuid.New() - sessionData := map[string]any{ - constants.SessionClaims: map[string]any{ - "sub": userID.String(), - "email": "test@example.com", - "preferred_username": "testuser", - "groups": []any{"admin", "user"}, - "entitlements": []any{"read", "write"}, - "custom_field": "custom_value", - }, - } - sessionDataJSON, err := json.Marshal(sessionData) - require.NoError(t, err) - - session := postgresstore.ProxySession{ - UUID: uuid.New(), - SessionKey: "claims_test_session", - UserID: &userID, - SessionData: string(sessionDataJSON), - Expires: time.Now().Add(time.Hour), - } - - err = db.Create(&session).Error - require.NoError(t, err) - - // Retrieve and verify claims can be parsed - var retrieved postgresstore.ProxySession - err = db.First(&retrieved, "session_key = ?", "claims_test_session").Error - require.NoError(t, err) - - assert.Equal(t, userID, *retrieved.UserID) - - // Parse and verify session data - var parsedData map[string]any - err = json.Unmarshal([]byte(retrieved.SessionData), &parsedData) - require.NoError(t, err) - - claims, ok := parsedData[constants.SessionClaims].(map[string]any) - assert.True(t, ok) - assert.Equal(t, "test@example.com", claims["email"]) - assert.Equal(t, "testuser", claims["preferred_username"]) - assert.Equal(t, "custom_value", claims["custom_field"]) - - // Verify groups array - groups, ok := claims["groups"].([]any) - assert.True(t, ok) - assert.Contains(t, groups, "admin") - assert.Contains(t, groups, "user") - - // Verify entitlements array - entitlements, ok := claims["entitlements"].([]any) - assert.True(t, ok) - assert.Contains(t, entitlements, "read") - assert.Contains(t, entitlements, "write") -} diff --git a/internal/outpost/proxyv2/application/session_test.go b/internal/outpost/proxyv2/application/session_test.go deleted file mode 100644 index 0b44c3c0effc..000000000000 --- a/internal/outpost/proxyv2/application/session_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package application - -import ( - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -func TestLogout(t *testing.T) { - a := newTestApplication() - _ = a.configureProxy() - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil) - rr := httptest.NewRecorder() - - // Login once - s, _ := a.sessions.Get(req, a.SessionName()) - s.ID = uuid.New().String() - s.Options.MaxAge = 86400 - s.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - } - err := a.sessions.Save(req, rr, s) - if err != nil { - panic(err) - } - - a.mux.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusBadGateway, rr.Code) - - // Login twice - s2, _ := a.sessions.Get(req, a.SessionName()) - s2.ID = uuid.New().String() - s2.Options.MaxAge = 86400 - s2.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - } - err = a.sessions.Save(req, rr, s2) - if err != nil { - panic(err) - } - - a.mux.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusBadGateway, rr.Code) - - // Logout - req, _ = http.NewRequest("GET", "https://ext.t.goauthentik.io/outpost.goauthentik.io/sign_out", nil) - s3, _ := a.sessions.Get(req, a.SessionName()) - s3.ID = uuid.New().String() - s3.Options.MaxAge = 86400 - s3.Values[constants.SessionClaims] = types.Claims{ - Sub: "foo", - } - err = a.sessions.Save(req, rr, s3) - if err != nil { - panic(err) - } - - rr = httptest.NewRecorder() - a.handleSignOut(rr, req) - assert.Equal(t, http.StatusFound, rr.Code) - - s1Name := filepath.Join(os.TempDir(), "session_"+s.ID) - _, err = os.Stat(s1Name) - assert.True(t, errors.Is(err, os.ErrNotExist)) - s2Name := filepath.Join(os.TempDir(), "session_"+s2.ID) - _, err = os.Stat(s2Name) - assert.True(t, errors.Is(err, os.ErrNotExist)) -} - -func TestStaleCookieDeletion(t *testing.T) { - a := newTestApplication() - _ = a.configureProxy() - - // Create a request with a session cookie that references a non-existent session file - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil) - - // Set a cookie for a session that doesn't exist (simulates pod restart) - nonExistentSessionID := uuid.New().String() - req.AddCookie(&http.Cookie{ - Name: a.SessionName(), - Value: "encoded_session_data_" + nonExistentSessionID, - Path: "/", - }) - - rr := httptest.NewRecorder() - - // Call getClaimsFromSession which should delete the stale cookie - claims := a.getClaimsFromSession(rr, req) - - // Verify no claims were returned (session doesn't exist) - assert.Nil(t, claims) - - // Verify the response includes a Set-Cookie header to delete the stale cookie - cookies := rr.Result().Cookies() - var foundDeleteCookie bool - for _, cookie := range cookies { - if cookie.Name == a.SessionName() && cookie.MaxAge < 0 { - foundDeleteCookie = true - break - } - } - assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted") -} - -func TestStateFromRequestDeletesStaleCookie(t *testing.T) { - a := newTestApplication() - _ = a.configureProxy() - - // Create a valid state JWT (from createState) - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/foo", nil) - rr := httptest.NewRecorder() - - state, err := a.createState(req, rr, "/redirect") - assert.NoError(t, err) - - // Create a new request with the state but a stale session cookie - req2, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/callback?state="+state, nil) - - // Add a cookie for a non-existent session - nonExistentSessionID := uuid.New().String() - req2.AddCookie(&http.Cookie{ - Name: a.SessionName(), - Value: "encoded_session_data_" + nonExistentSessionID, - Path: "/", - }) - - rr2 := httptest.NewRecorder() - - // Call stateFromRequest which should fail due to missing session - // but should also delete the stale cookie - claims := a.stateFromRequest(rr2, req2) - - // Verify no claims were returned - assert.Nil(t, claims) - - // Verify the response includes a Set-Cookie header to delete the stale cookie - cookies := rr2.Result().Cookies() - var foundDeleteCookie bool - for _, cookie := range cookies { - if cookie.Name == a.SessionName() && cookie.MaxAge < 0 { - foundDeleteCookie = true - break - } - } - assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted") -} - -func TestCreateStateWithStaleCookie(t *testing.T) { - a := newTestApplication() - _ = a.configureProxy() - - // Create a request with a stale session cookie (simulates outpost restart or user change) - req, _ := http.NewRequest("GET", "https://ext.t.goauthentik.io/outpost.goauthentik.io/start", nil) - - // Add a cookie for a non-existent session - nonExistentSessionID := uuid.New().String() - req.AddCookie(&http.Cookie{ - Name: a.SessionName(), - Value: "encoded_session_data_" + nonExistentSessionID, - Path: "/", - }) - - rr := httptest.NewRecorder() - - // Call createState which should succeed despite the stale cookie - state, err := a.createState(req, rr, "/redirect") - - // Verify createState succeeded - assert.NoError(t, err) - assert.NotEmpty(t, state) - - // Verify the response includes a Set-Cookie header to delete the stale cookie - cookies := rr.Result().Cookies() - var foundDeleteCookie bool - for _, cookie := range cookies { - if cookie.Name == a.SessionName() && cookie.MaxAge < 0 { - foundDeleteCookie = true - break - } - } - assert.True(t, foundDeleteCookie, "Expected stale session cookie to be deleted") -} diff --git a/internal/outpost/proxyv2/application/test.go b/internal/outpost/proxyv2/application/test.go deleted file mode 100644 index 718ac8272e9a..000000000000 --- a/internal/outpost/proxyv2/application/test.go +++ /dev/null @@ -1,99 +0,0 @@ -package application - -import ( - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "goauthentik.io/internal/outpost/ak" - api "goauthentik.io/packages/client-go" -) - -type testServer struct { - api *ak.APIController - apps []*Application -} - -func newTestServer() *testServer { - return &testServer{ - api: ak.MockAK( - api.Outpost{ - Config: map[string]any{ - "authentik_host": ak.TestSecret(), - }, - }, - ak.MockConfig(), - ), - apps: make([]*Application, 0), - } -} - -func (ts *testServer) API() *ak.APIController { - return ts.api -} - -func (ts *testServer) CryptoStore() *ak.CryptoStore { - return nil -} - -func (ts *testServer) Apps() []*Application { - return ts.apps -} - -func (ts *testServer) SessionBackend() string { - return "filesystem" -} - -func newTestApplication() *Application { - ts := newTestServer() - a, _ := NewApplication( - api.ProxyOutpostConfig{ - Name: ak.TestSecret(), - ClientId: new(ak.TestSecret()), - ClientSecret: new(ak.TestSecret()), - CookieDomain: new(""), - CookieSecret: new(ak.TestSecret()), - ExternalHost: "https://ext.t.goauthentik.io", - InternalHost: new("http://backend"), - InternalHostSslValidation: new(true), - Mode: api.PROXYMODE_FORWARD_SINGLE.Ptr(), - SkipPathRegex: new("/skip.*"), - BasicAuthEnabled: new(true), - BasicAuthUserAttribute: new("username"), - BasicAuthPasswordAttribute: new("password"), - OidcConfiguration: api.OpenIDConnectConfiguration{ - AuthorizationEndpoint: "http://fake-auth.t.goauthentik.io/auth", - TokenEndpoint: "http://fake-auth.t.goauthentik.io/token", - UserinfoEndpoint: "http://fake-auth.t.goauthentik.io/userinfo", - }, - }, - http.DefaultClient, - ts, - nil, - ) - ts.apps = append(ts.apps, a) - return a -} - -func (a *Application) assertState(t *testing.T, req *http.Request, response *httptest.ResponseRecorder) (*url.URL, *OAuthState) { - loc, _ := response.Result().Location() - q := loc.Query() - state := q.Get("state") - a.log.WithField("actual", state).Warning("actual state") - // modify request to set state so we can parse it - nr := req.Clone(req.Context()) - nrq := nr.URL.Query() - nrq.Set("state", state) - nr.URL.RawQuery = nrq.Encode() - // parse state - parsed := a.stateFromRequest(nil, nr) - if parsed == nil { - panic("Could not parse state") - } - - // Remove state from URL - q.Del("state") - loc.RawQuery = q.Encode() - return loc, parsed -} diff --git a/internal/outpost/proxyv2/application/utils.go b/internal/outpost/proxyv2/application/utils.go deleted file mode 100644 index 4f2045bdbff0..000000000000 --- a/internal/outpost/proxyv2/application/utils.go +++ /dev/null @@ -1,57 +0,0 @@ -package application - -import ( - "net/http" - "net/url" - "slices" - "strconv" -) - -func urlJoin(originalUrl string, newPath string) string { - u, err := url.JoinPath(originalUrl, newPath) - if err != nil { - return originalUrl - } - return u -} - -func (a *Application) redirect(rw http.ResponseWriter, r *http.Request) { - fallbackRedirect := a.proxyConfig.ExternalHost - state := a.stateFromRequest(rw, r) - if state == nil { - rw.WriteHeader(http.StatusBadRequest) - return - } - if state.Redirect == "" { - state.Redirect = fallbackRedirect - } - a.log.WithField("redirect", state.Redirect).Trace("final redirect") - http.Redirect(rw, r, state.Redirect, http.StatusFound) -} - -// toString Generic to string function, currently supports actual strings and integers -func toString(in any) string { - switch v := in.(type) { - case string: - return v - case *string: - return *v - case int: - return strconv.Itoa(v) - } - return "" -} - -func contains(s []string, e string) bool { - return slices.Contains(s, e) -} - -func cleanseHeaders(headers http.Header) map[string]string { - h := make(map[string]string) - for hk, hv := range headers { - if len(hv) > 0 { - h[hk] = hv[0] - } - } - return h -} diff --git a/internal/outpost/proxyv2/application/utils_test.go b/internal/outpost/proxyv2/application/utils_test.go deleted file mode 100644 index c84b37176753..000000000000 --- a/internal/outpost/proxyv2/application/utils_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package application - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" - "goauthentik.io/internal/outpost/proxyv2/constants" - api "goauthentik.io/packages/client-go" -) - -func TestRedirectToStart_Proxy(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - req, _ := http.NewRequest("GET", "/foo/bar/baz", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz", loc.String()) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect]) -} - -func TestRedirectToStart_Proxy_Query(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - req, _ := http.NewRequest("GET", "/foo/bar/baz?foo=bar&baz=qux", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz%3Ffoo%3Dbar%26baz%3Dqux", loc.String()) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz?foo=bar&baz=qux", s.Values[constants.SessionRedirect]) -} - -func TestRedirectToStart_Proxy_EncodedSlash(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_PROXY.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - // %2F is a URL-encoded forward slash, used by apps like RabbitMQ in queue paths - req, _ := http.NewRequest("GET", "/api/queues/%2F/MYChannelCreated", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Contains(t, loc.String(), "%252F", "encoded slash %2F must be preserved in redirect URL") - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Contains(t, s.Values[constants.SessionRedirect].(string), "%2F", "encoded slash %2F must be preserved in session redirect") -} - -func TestRedirectToStart_Forward(t *testing.T) { - a := newTestApplication() - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_SINGLE.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - req, _ := http.NewRequest("GET", "/foo/bar/baz", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io%2Ffoo%2Fbar%2Fbaz", loc.String()) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "https://test.goauthentik.io/foo/bar/baz", s.Values[constants.SessionRedirect]) -} - -func TestRedirectToStart_Forward_Domain_Invalid(t *testing.T) { - a := newTestApplication() - a.proxyConfig.CookieDomain = new("foo") - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - req, _ := http.NewRequest("GET", "/foo/bar/baz", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io", loc.String()) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect]) -} - -func TestRedirectToStart_Forward_Domain(t *testing.T) { - a := newTestApplication() - a.proxyConfig.CookieDomain = new("goauthentik.io") - a.proxyConfig.Mode = api.PROXYMODE_FORWARD_DOMAIN.Ptr() - a.proxyConfig.ExternalHost = "https://test.goauthentik.io" - req, _ := http.NewRequest("GET", "/foo/bar/baz", nil) - - rr := httptest.NewRecorder() - a.redirectToStart(rr, req) - - assert.Equal(t, http.StatusFound, rr.Code) - loc, _ := rr.Result().Location() - assert.Equal(t, "https://test.goauthentik.io/outpost.goauthentik.io/start?rd=https%3A%2F%2Ftest.goauthentik.io", loc.String()) - - s, _ := a.sessions.Get(req, a.SessionName()) - assert.Equal(t, "https://test.goauthentik.io", s.Values[constants.SessionRedirect]) -} diff --git a/internal/outpost/proxyv2/codecs/codec.go b/internal/outpost/proxyv2/codecs/codec.go deleted file mode 100644 index 7dfc0a68ada4..000000000000 --- a/internal/outpost/proxyv2/codecs/codec.go +++ /dev/null @@ -1,43 +0,0 @@ -package codecs - -import ( - "math" - - "github.com/gorilla/securecookie" - log "github.com/sirupsen/logrus" -) - -type Codec struct { - *securecookie.SecureCookie -} - -func New(maxAge int, hashKey, blockKey []byte) *Codec { - cookie := securecookie.New(hashKey, blockKey) - cookie.MaxAge(maxAge) - cookie.MaxLength(math.MaxInt) - return &Codec{ - SecureCookie: cookie, - } -} - -func CodecsFromPairs(maxAge int, keyPairs ...[]byte) []securecookie.Codec { - codecs := make([]securecookie.Codec, len(keyPairs)/2+len(keyPairs)%2) - for i := 0; i < len(keyPairs); i += 2 { - var blockKey []byte - if i+1 < len(keyPairs) { - blockKey = keyPairs[i+1] - } - codecs[i/2] = New(maxAge, keyPairs[i], blockKey) - } - return codecs -} - -func (s *Codec) Encode(name string, value any) (string, error) { - log.Trace("cookie encode") - return s.SecureCookie.Encode("authentik_proxy", value) -} - -func (s *Codec) Decode(name string, value string, dst any) error { - log.Trace("cookie decode") - return s.SecureCookie.Decode("authentik_proxy", value, dst) -} diff --git a/internal/outpost/proxyv2/constants/constants.go b/internal/outpost/proxyv2/constants/constants.go deleted file mode 100644 index 339d492684d5..000000000000 --- a/internal/outpost/proxyv2/constants/constants.go +++ /dev/null @@ -1,12 +0,0 @@ -package constants - -const ( - SessionOAuthState = "oauth_state" - SessionClaims = "claims" -) - -const SessionRedirect = "redirect" - -const HeaderAuthorization = "Authorization" - -const AuthBearer = "Bearer " diff --git a/internal/outpost/proxyv2/filesystemstore/filesystemstore.go b/internal/outpost/proxyv2/filesystemstore/filesystemstore.go deleted file mode 100644 index 259aba5dfbd0..000000000000 --- a/internal/outpost/proxyv2/filesystemstore/filesystemstore.go +++ /dev/null @@ -1,206 +0,0 @@ -package filesystemstore - -import ( - "context" - "errors" - "os" - "path" - "strings" - "sync" - "syscall" - "time" - - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" - - "goauthentik.io/internal/outpost/proxyv2/sessionstore" -) - -const ( - SessionCleanupInterval = 5 * time.Minute - SessionCleanupLockFileName = "session-cleanup.lock" - SessionFilePrefix = "session_" - SessionTestFile = SessionFilePrefix + "write_test" -) - -var ( - ErrSessionCleanupAlreadyRunning = errors.New("session cleanup is already running by another instance") - ErrSessionStoreNoPermission = errors.New("path is not writable") - ErrSessionStorePathNotExist = errors.New("path does not exist") -) - -type Store struct { - *sessions.FilesystemStore - storePath string - log *log.Entry - cleanupManager *sessionstore.CleanupManager -} - -// NewStore checks if the specified store path exists, is writable and creates a new filesystem session store. -func NewStore(storePath string, keyPairs ...[]byte) (*Store, error) { - if storePath == "" { - storePath = os.TempDir() - } - - // check if path exists - _, err := os.ReadDir(storePath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil, ErrSessionStorePathNotExist - } - return nil, err - } - - // check if path is writable - testPath := path.Join(storePath, SessionTestFile) - testFile, err := os.OpenFile(testPath, os.O_CREATE, 0600) - if err != nil { - if errors.Is(err, os.ErrPermission) { - return nil, ErrSessionStoreNoPermission - } - return nil, err - } - if err = testFile.Close(); err != nil { - return nil, err - } - if err = os.Remove(testPath); err != nil { - return nil, err - } - - store := &Store{ - FilesystemStore: sessions.NewFilesystemStore(storePath, keyPairs...), - storePath: storePath, - log: log.WithField("logger", "authentik.outpost.proxyv2.filesystemstore"), - } - - return store, nil -} - -// CleanupExpired implements the CleanupStore interface for use with CleanupManager -func (s *Store) CleanupExpired(ctx context.Context) error { - return s.SessionCleanup(ctx) -} - -// SessionCleanup acquires a file lock to ensure only one instance runs at a time, -// then checks and deletes expired session files from the filesystem session store. -// It supports context-based cancellation to allow graceful shutdowns or timeouts. -func (s *Store) SessionCleanup(ctx context.Context) error { - s.log.Info("Starting session cleanup") - lockPath := path.Join(s.storePath, SessionCleanupLockFileName) - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) - if err != nil { - return err - } - defer func() { - if closeErr := lockFile.Close(); closeErr != nil { - s.log.WithError(closeErr).Warn("failed to close lock file") - } - }() - - err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - if err != nil { - if errno, ok := err.(syscall.Errno); ok && errno == syscall.EWOULDBLOCK { - return ErrSessionCleanupAlreadyRunning - } - return err - } - defer func() { - if flockErr := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN); flockErr != nil { - s.log.WithError(flockErr).Warn("failed to unlock file") - } - - if removeErr := os.Remove(lockPath); removeErr != nil { - s.log.WithError(removeErr).Warn("failed to remove lock file") - } - }() - - return s.sessionCleanup(ctx) -} - -// sessionCleanup checks the modification time of all session files and removes them -// when they reach the configured maximum age in the session store. -// Since the FilesystemStore from Gorilla does not have a session cleanup function, -// it is only necessary for the filesystem session store. -func (s *Store) sessionCleanup(ctx context.Context) error { - files, err := os.ReadDir(s.storePath) - if err != nil { - return err - } - - var errs []error - for _, file := range files { - select { - case <-ctx.Done(): - s.log.Warn("session cleanup interrupted during file processing") - return ctx.Err() - default: - } - - if !strings.HasPrefix(file.Name(), SessionFilePrefix) { - continue - } - - fullPath := path.Join(s.storePath, file.Name()) - stat, err := os.Lstat(fullPath) - if err != nil { - s.log.WithError(err).WithField("path", fullPath).Warning("failed to read stats from file") - errs = append(errs, err) - continue - } - - modTime := stat.ModTime() - if time.Since(modTime) <= time.Duration(s.Options.MaxAge)*time.Second { - s.log.WithField("max-age", s.Options.MaxAge).WithField("modified", modTime.String()).Debug("session still valid") - continue - } - - s.log.WithField("path", fullPath).WithField("modified", modTime.String()).Info("cleanup expired session") - if err = os.Remove(fullPath); err != nil { - s.log.WithError(err).WithField("path", fullPath).Warn("failed to delete session") - errs = append(errs, err) - continue - } - } - return errors.Join(errs...) -} - -var ( - globalStore *Store - mu sync.Mutex -) - -// GetPersistentStore creates a new filesystem store if it is the first time the function has been called, -// or if the path string has changed. It then stores this in the globalStore variable. -// If the function is called multiple times, the store from the variable is returned to ensure that only one instance is running. -func GetPersistentStore(path string) (*Store, error) { - mu.Lock() - defer mu.Unlock() - if globalStore == nil || globalStore.storePath != path { - if globalStore != nil && globalStore.cleanupManager != nil { - globalStore.cleanupManager.Stop() - } - store, err := NewStore(path) - if err != nil { - return nil, err - } - globalStore = store - - // Initialize cleanup manager - globalStore.cleanupManager = sessionstore.NewCleanupManager( - globalStore, - globalStore.log, - ) - globalStore.cleanupManager.Start() - } - return globalStore, nil -} - -// StopPersistentStore stops the cleanup background job and clears the globalStore variable. -func StopPersistentStore() { - mu.Lock() - defer mu.Unlock() - if globalStore != nil && globalStore.cleanupManager != nil { - globalStore.cleanupManager.Stop() - } - globalStore = nil -} diff --git a/internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go b/internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go deleted file mode 100644 index f9fc949cb839..000000000000 --- a/internal/outpost/proxyv2/filesystemstore/filesystemstore_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package filesystemstore - -import ( - "context" - "os" - "path" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func createTempSessionFile(t *testing.T, dir string, modTime time.Time) string { - t.Helper() - path := filepath.Join(dir, "session_test") - err := os.WriteFile(path, []byte("session data"), 0600) - require.NoError(t, err) - err = os.Chtimes(path, modTime, modTime) - require.NoError(t, err) - return path -} - -func TestNewStore_PathNotExist(t *testing.T) { - _, err := NewStore("/invalid_path") - assert.ErrorIs(t, err, ErrSessionStorePathNotExist) -} - -func TestNewStore_PathNotWritable(t *testing.T) { - storePath := path.Join(os.TempDir(), "test") - err := os.Mkdir(storePath, 0400) - require.NoError(t, err) - - _, err = NewStore(storePath) - assert.ErrorIs(t, err, ErrSessionStoreNoPermission) - - _ = os.RemoveAll(storePath) -} - -func TestNewStore(t *testing.T) { - tmpDir := t.TempDir() - store, err := NewStore(tmpDir) - assert.NoError(t, err) - assert.NotEmpty(t, store) -} - -func TestSessionCleanup_RemovesExpired(t *testing.T) { - tmpDir := t.TempDir() - store, err := NewStore(tmpDir) - require.NoError(t, err) - store.Options.MaxAge = 1 // 1 second - - // Create an expired session file - oldTime := time.Now().Add(-10 * time.Second) - createTempSessionFile(t, tmpDir, oldTime) - - ctx := context.Background() - err = store.SessionCleanup(ctx) - assert.NoError(t, err) - - // File should be deleted - files, _ := os.ReadDir(tmpDir) - assert.Empty(t, files) -} - -func TestSessionCleanup_PreservesValid(t *testing.T) { - tmpDir := t.TempDir() - store, err := NewStore(tmpDir) - require.NoError(t, err) - store.Options.MaxAge = 3600 // 1 hour - - // Create a valid (non-expired) session file - modTime := time.Now().Add(-10 * time.Second) - createTempSessionFile(t, tmpDir, modTime) - - ctx := context.Background() - err = store.SessionCleanup(ctx) - assert.NoError(t, err) - - // File should still exist - files, _ := os.ReadDir(tmpDir) - assert.Len(t, files, 1) -} - -func TestSessionCleanup_ContextCancel(t *testing.T) { - tmpDir := t.TempDir() - store, err := NewStore(tmpDir) - require.NoError(t, err) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - err = store.SessionCleanup(ctx) - assert.ErrorIs(t, err, context.Canceled) -} - -func TestSessionCleanup_AlreadyRunning(t *testing.T) { - tmpDir := t.TempDir() - store, err := NewStore(tmpDir) - require.NoError(t, err) - - // Manually acquire the lock before calling SessionCleanup - lockPath := path.Join(tmpDir, SessionCleanupLockFileName) - lockFile, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0600) - require.NoError(t, err, "failed to create lock file") - - err = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) - require.NoError(t, err, "failed to acquire lock for test") - - // Run SessionCleanup while lock is held - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - err = store.SessionCleanup(ctx) - assert.ErrorIs(t, err, ErrSessionCleanupAlreadyRunning) - - // Unlock and clean up - _ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) - _ = lockFile.Close() - _ = os.Remove(lockPath) -} - -func TestPersistentStore_ReusesStore(t *testing.T) { - tmpDir := t.TempDir() - store1, err := GetPersistentStore(tmpDir) - require.NoError(t, err) - assert.NotNil(t, store1) - - store2, err := GetPersistentStore(tmpDir) - require.NoError(t, err) - assert.Equal(t, store1, store2) - - StopPersistentStore() -} - -func TestStopPersistentStore(t *testing.T) { - tmpDir := t.TempDir() - _, err := GetPersistentStore(tmpDir) - require.NoError(t, err) - StopPersistentStore() - - // call again should not panic - StopPersistentStore() -} diff --git a/internal/outpost/proxyv2/handlers.go b/internal/outpost/proxyv2/handlers.go deleted file mode 100644 index 7033052691ae..000000000000 --- a/internal/outpost/proxyv2/handlers.go +++ /dev/null @@ -1,130 +0,0 @@ -package proxyv2 - -import ( - "encoding/json" - "fmt" - "net/http" - "strings" - "time" - - "github.com/prometheus/client_golang/prometheus" - "goauthentik.io/internal/outpost/proxyv2/application" - "goauthentik.io/internal/outpost/proxyv2/metrics" - sentryutils "goauthentik.io/internal/utils/sentry" - "goauthentik.io/internal/utils/web" - api "goauthentik.io/packages/client-go" - staticWeb "goauthentik.io/web" -) - -func (ps *ProxyServer) HandlePing(rw http.ResponseWriter, r *http.Request) { - before := time.Now() - rw.WriteHeader(204) - elapsed := time.Since(before) - metrics.Requests.With(prometheus.Labels{ - "outpost_name": ps.akAPI.Outpost.Name, - "method": r.Method, - "host": web.GetHost(r), - "type": "ping", - }).Observe(float64(elapsed) / float64(time.Second)) -} - -func (ps *ProxyServer) HandleStatic(rw http.ResponseWriter, r *http.Request) { - before := time.Now() - web.DisableIndex(http.StripPrefix("/outpost.goauthentik.io/static/dist", staticWeb.StaticHandler)).ServeHTTP(rw, r) - elapsed := time.Since(before) - metrics.Requests.With(prometheus.Labels{ - "outpost_name": ps.akAPI.Outpost.Name, - "method": r.Method, - "host": web.GetHost(r), - "type": "static", - }).Observe(float64(elapsed) / float64(time.Second)) -} - -func (ps *ProxyServer) lookupApp(r *http.Request) (*application.Application, string) { - host := web.GetHost(r) - // Try to find application by directly looking up host first (proxy, forward_auth_single) - a, ok := ps.apps[host] - if ok { - ps.log.WithField("host", host).WithField("app", a.ProxyConfig().Name).Trace("Found app based direct host match") - return a, host - } - // For forward_auth_domain, we don't have a direct app to domain relationship - // Check through all apps, and check how much of their cookie domain matches the host - // Return the application that has the longest match - var longestMatch *application.Application - longestMatchLength := 0 - for _, app := range ps.apps { - if app.Mode() != api.PROXYMODE_FORWARD_DOMAIN { - continue - } - // Check if the cookie domain has a leading period for a wildcard - // This will decrease the weight of a wildcard domain, but a request to example.com - // with the cookie domain set to example.com will still be routed correctly. - cd := strings.TrimPrefix(*app.ProxyConfig().CookieDomain, ".") - if !strings.HasSuffix(host, cd) { - continue - } - if len(cd) < longestMatchLength { - continue - } - longestMatch = app - longestMatchLength = len(cd) - // Also for forward_auth_domain, we need to respond on the external domain - if app.ProxyConfig().ExternalHost == host { - ps.log.WithField("host", host).WithField("app", app.ProxyConfig().Name).Debug("Found app based on external_host") - return app, host - } - } - // Check if our longes match is 0, in which case we didn't match, so we - // manually return no app - if longestMatchLength == 0 { - return nil, host - } - ps.log.WithField("host", host).WithField("app", longestMatch.ProxyConfig().Name).Debug("Found app based on cookie domain") - return longestMatch, host -} - -func (ps *ProxyServer) Handle(rw http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") { - ps.HandleStatic(rw, r) - return - } - if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") { - sentryutils.SentryNoSample(ps.HandlePing)(rw, r) - return - } - a, host := ps.lookupApp(r) - if a == nil { - // If we only have one handler, host name switching doesn't matter - if len(ps.apps) == 1 { - ps.log.WithField("host", host).Trace("passing to single app mux") - for k := range ps.apps { - ps.apps[k].ServeHTTP(rw, r) - return - } - } - - ps.log.WithField("headers", r.Header).Trace("tracing headers for no hostname match") - ps.log.WithField("host", host).Warning("no app for hostname") - - rw.Header().Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusBadRequest) - j := json.NewEncoder(rw) - j.SetIndent("", "\t") - err := j.Encode(struct { - Message string - Host string - Detail string - }{ - Message: "no app for hostname", - Host: host, - Detail: fmt.Sprintf("Check the outpost settings and make sure '%s' is included.", host), - }) - if err != nil { - ps.log.WithError(err).Warning("Failed to write error body") - } - return - } - ps.log.WithField("host", host).Trace("passing to application mux") - a.ServeHTTP(rw, r) -} diff --git a/internal/outpost/proxyv2/hs256/hs256.go b/internal/outpost/proxyv2/hs256/hs256.go deleted file mode 100644 index 80ee332ae423..000000000000 --- a/internal/outpost/proxyv2/hs256/hs256.go +++ /dev/null @@ -1,38 +0,0 @@ -package hs256 - -import ( - "context" - "encoding/base64" - "fmt" - "strings" - - "github.com/golang-jwt/jwt/v5" -) - -type KeySet struct { - m jwt.SigningMethod - secret string -} - -func NewKeySet(secret string) *KeySet { - return &KeySet{ - m: jwt.SigningMethodHS256, - secret: secret, - } -} - -func (ks *KeySet) VerifySignature(ctx context.Context, rawJWT string) ([]byte, error) { - _, err := jwt.Parse(rawJWT, func(token *jwt.Token) (any, error) { - // Don't forget to validate the alg is what you expect: - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(ks.secret), nil - }) - if err != nil { - return nil, err - } - parts := strings.Split(rawJWT, ".") - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - return payload, err -} diff --git a/internal/outpost/proxyv2/metrics/metrics.go b/internal/outpost/proxyv2/metrics/metrics.go deleted file mode 100644 index f3fe10478580..000000000000 --- a/internal/outpost/proxyv2/metrics/metrics.go +++ /dev/null @@ -1,17 +0,0 @@ -package metrics - -import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" -) - -var ( - Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "authentik_outpost_proxy_request_duration_seconds", - Help: "Proxy request latencies in seconds", - }, []string{"outpost_name", "method", "host", "type"}) - UpstreamTiming = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "authentik_outpost_proxy_upstream_response_duration_seconds", - Help: "Proxy upstream response latencies in seconds", - }, []string{"outpost_name", "method", "scheme", "host", "upstream_host"}) -) diff --git a/internal/outpost/proxyv2/postgresstore/connpool.go b/internal/outpost/proxyv2/postgresstore/connpool.go deleted file mode 100644 index f1abd82ae73a..000000000000 --- a/internal/outpost/proxyv2/postgresstore/connpool.go +++ /dev/null @@ -1,289 +0,0 @@ -package postgresstore - -import ( - "context" - "database/sql" - "database/sql/driver" - "errors" - "sync" - "time" - - "github.com/jackc/pgx/v5/pgconn" - log "github.com/sirupsen/logrus" - "gorm.io/driver/postgres" - "gorm.io/gorm" - - "goauthentik.io/internal/config" -) - -// RefreshableConnPool wraps sql.DB and refreshes PostgreSQL credentials on authentication errors -// This implements gorm.ConnPool interface to allow credential rotation -type RefreshableConnPool struct { - mu sync.RWMutex - db *sql.DB - log *log.Entry - currentDSN string - gormConfig *gorm.Config - - // Connection pool settings (stored for reapplication after reconnection) - maxIdleConns int - maxOpenConns int - connMaxLifetime time.Duration - - // Reconnection management - reconnecting sync.Mutex // Prevent concurrent reconnections -} - -// NewRefreshableConnPool creates a new connection pool that refreshes credentials from config -func NewRefreshableConnPool(initialDSN string, gormConfig *gorm.Config, maxIdleConns, maxOpenConns int, connMaxLifetime time.Duration) (*RefreshableConnPool, error) { - db, err := sql.Open("pgx", initialDSN) - if err != nil { - return nil, err - } - - // Apply connection pool settings - db.SetMaxIdleConns(maxIdleConns) - db.SetMaxOpenConns(maxOpenConns) - db.SetConnMaxLifetime(connMaxLifetime) - - pool := &RefreshableConnPool{ - db: db, - log: log.WithField("logger", "authentik.outpost.proxyv2.postgresstore.connpool"), - currentDSN: initialDSN, - gormConfig: gormConfig, - maxIdleConns: maxIdleConns, - maxOpenConns: maxOpenConns, - connMaxLifetime: connMaxLifetime, - } - - return pool, nil -} - -// isAuthError checks if an error is a PostgreSQL authentication error -func isAuthError(err error) bool { - if err == nil { - return false - } - - // Unwrap the error to find the underlying pgconn.PgError - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - // Check for any PostgreSQL error code in Class 28 (Invalid Authorization Specification) - // See https://www.postgresql.org/docs/current/errcodes-appendix.html - return len(pgErr.Code) >= 2 && pgErr.Code[:2] == "28" - } - - return false -} - -// refreshCredentials checks if credentials have changed and reconnects if needed -func (p *RefreshableConnPool) refreshCredentials(ctx context.Context) error { - // Prevent concurrent reconnections - p.reconnecting.Lock() - defer p.reconnecting.Unlock() - - // Get fresh config - cfg := config.Get().RefreshPostgreSQLConfig() - newDSN, err := BuildDSN(cfg) - if err != nil { - p.log.WithError(err).Warn("Failed to build DSN with refreshed credentials") - return err - } - - p.mu.RLock() - dsnChanged := newDSN != p.currentDSN - p.mu.RUnlock() - - if !dsnChanged { - p.log.Debug("Credentials unchanged, skipping reconnection") - return nil - } - - p.mu.Lock() - defer p.mu.Unlock() - - // Double-check after acquiring write lock - if newDSN == p.currentDSN { - return nil - } - - p.log.Info("PostgreSQL credentials changed, reconnecting...") - - // Open new connection with fresh credentials - newDB, err := sql.Open("pgx", newDSN) - if err != nil { - p.log.WithError(err).Error("Failed to open new database connection with refreshed credentials") - return err - } - - // Reapply connection pool settings - newDB.SetMaxIdleConns(p.maxIdleConns) - newDB.SetMaxOpenConns(p.maxOpenConns) - newDB.SetConnMaxLifetime(p.connMaxLifetime) - - // Verify the connection works BEFORE closing old connection - if err := newDB.PingContext(ctx); err != nil { - p.log.WithError(err).Error("Failed to ping database with new credentials") - _ = newDB.Close() - // Old connection remains active, pool is still functional - return err - } - - // Only after successful verification, swap connections - oldDB := p.db - p.db = newDB - p.currentDSN = newDSN - - // Close old connection after swap - if oldDB != nil { - if err := oldDB.Close(); err != nil { - p.log.WithError(err).Warn("Failed to close old database connection") - // Not fatal cause new connection is already active - } - } - - p.log.Info("Successfully reconnected with new PostgreSQL credentials") - - return nil -} - -// tryWithRefresh attempts an operation, and if it fails with an auth error, refreshes credentials and retries -func (p *RefreshableConnPool) tryWithRefresh(ctx context.Context, op func() error) error { - err := op() - if err != nil && isAuthError(err) { - p.log.WithError(err).Info("Authentication error detected, attempting to refresh credentials") - if refreshErr := p.refreshCredentials(ctx); refreshErr == nil { - // Retry the operation once after successful refresh - p.log.Debug("Retrying operation after credential refresh") - return op() - } else { - p.log.WithError(refreshErr).Warn("Failed to refresh credentials, returning original error") - } - } - return err -} - -// PrepareContext implements gorm.ConnPool interface -func (p *RefreshableConnPool) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - var stmt *sql.Stmt - err := p.tryWithRefresh(ctx, func() error { - p.mu.RLock() - defer p.mu.RUnlock() - var err error - stmt, err = p.db.PrepareContext(ctx, query) - return err - }) - return stmt, err -} - -// ExecContext implements gorm.ConnPool interface -func (p *RefreshableConnPool) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { - var result sql.Result - err := p.tryWithRefresh(ctx, func() error { - p.mu.RLock() - defer p.mu.RUnlock() - var err error - result, err = p.db.ExecContext(ctx, query, args...) - return err - }) - return result, err -} - -// QueryContext implements gorm.ConnPool interface -func (p *RefreshableConnPool) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - var rows *sql.Rows - err := p.tryWithRefresh(ctx, func() error { - p.mu.RLock() - defer p.mu.RUnlock() - var err error - rows, err = p.db.QueryContext(ctx, query, args...) - return err - }) - return rows, err -} - -// QueryRowContext implements gorm.ConnPool interface -func (p *RefreshableConnPool) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { - // Note: sql.Row doesn't return errors until Scan() is called, so we can't detect auth errors here - // The error will be caught in higher-level GORM operations - p.mu.RLock() - defer p.mu.RUnlock() - return p.db.QueryRowContext(ctx, query, args...) -} - -// BeginTx implements gorm.TxBeginner and gorm.ConnPoolBeginner interfaces -func (p *RefreshableConnPool) BeginTx(ctx context.Context, opts *sql.TxOptions) (gorm.ConnPool, error) { - var tx *sql.Tx - err := p.tryWithRefresh(ctx, func() error { - p.mu.RLock() - defer p.mu.RUnlock() - var err error - tx, err = p.db.BeginTx(ctx, opts) - return err - }) - if err != nil { - return nil, err - } - return &refreshableTx{Tx: tx, pool: p}, nil -} - -// refreshableTx wraps sql.Tx to implement gorm.ConnPool -type refreshableTx struct { - *sql.Tx - pool *RefreshableConnPool -} - -func (tx *refreshableTx) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - return tx.Tx.PrepareContext(ctx, query) -} - -func (tx *refreshableTx) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { - return tx.Tx.ExecContext(ctx, query, args...) -} - -func (tx *refreshableTx) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { - return tx.Tx.QueryContext(ctx, query, args...) -} - -func (tx *refreshableTx) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { - return tx.Tx.QueryRowContext(ctx, query, args...) -} - -// Close closes the underlying database connection -func (p *RefreshableConnPool) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - if p.db != nil { - return p.db.Close() - } - return nil -} - -// Ping verifies the connection is alive -func (p *RefreshableConnPool) Ping(ctx context.Context) error { - p.mu.RLock() - defer p.mu.RUnlock() - return p.db.PingContext(ctx) -} - -// GetDB returns the underlying sql.DB for connection pool configuration -func (p *RefreshableConnPool) GetDB() *sql.DB { - p.mu.RLock() - defer p.mu.RUnlock() - return p.db -} - -// NewGORMDB creates a GORM DB instance using the refreshable connection pool -func (p *RefreshableConnPool) NewGORMDB() (*gorm.DB, error) { - dialector := postgres.New(postgres.Config{ - Conn: p, - }) - return gorm.Open(dialector, p.gormConfig) -} - -// Ensure RefreshableConnPool implements required interfaces -var ( - _ gorm.ConnPool = (*RefreshableConnPool)(nil) - _ gorm.ConnPoolBeginner = (*RefreshableConnPool)(nil) - _ driver.Pinger = (*RefreshableConnPool)(nil) -) diff --git a/internal/outpost/proxyv2/postgresstore/connpool_test.go b/internal/outpost/proxyv2/postgresstore/connpool_test.go deleted file mode 100644 index f7813f986048..000000000000 --- a/internal/outpost/proxyv2/postgresstore/connpool_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package postgresstore - -import ( - "context" - "os" - "path/filepath" - "sync" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgconn" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "goauthentik.io/internal/config" -) - -func TestRefreshableConnPool_CredentialRefresh(t *testing.T) { - // Create a temporary file for password rotation - tmpDir := t.TempDir() - passwordFile := filepath.Join(tmpDir, "db_password") - - cfg := config.Get() - initialConfig := cfg.RefreshPostgreSQLConfig() - - // Determine the current database password as the baseline for the rotation test. - initialPassword := initialConfig.Password - if initialPassword == "" { - initialPassword = "postgres" - } - - err := os.WriteFile(passwordFile, []byte(initialPassword), 0600) - require.NoError(t, err) - - // Set up config to use file:// URI for password - originalPassword := os.Getenv("AUTHENTIK_POSTGRESQL__PASSWORD") - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile)) - defer func() { - if originalPassword != "" { - _ = os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", originalPassword) - } else { - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD") - } - }() - - // Reload config - refreshedConfig := cfg.RefreshPostgreSQLConfig() - - // Build initial DSN - dsn, err := BuildDSN(refreshedConfig) - require.NoError(t, err) - - gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - NowFunc: func() time.Time { - return time.Now().UTC() - }, - } - - // Create refreshable connection pool - pool, err := NewRefreshableConnPool(dsn, gormConfig, 10, 100, time.Hour) - require.NoError(t, err) - defer func() { _ = pool.Close() }() - - // Test initial connection works - ctx := context.Background() - err = pool.Ping(ctx) - assert.NoError(t, err, "Initial connection should work") - - // Create GORM DB - db, err := pool.NewGORMDB() - require.NoError(t, err) - - // Execute a test query - var result int - err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error - assert.NoError(t, err, "Initial query should succeed") - assert.Equal(t, 1, result) - - // Simulate password change by writing to file - // In real scenario, this would be an external process updating the file - time.Sleep(100 * time.Millisecond) // Small delay to ensure file modification time changes - err = os.WriteFile(passwordFile, []byte(initialPassword), 0600) - require.NoError(t, err) - - // Execute another query - should trigger credential refresh check - err = db.WithContext(ctx).Raw("SELECT 2").Scan(&result).Error - assert.NoError(t, err, "Query after credential refresh should succeed") - assert.Equal(t, 2, result) -} - -func TestRefreshableConnPool_Interfaces(t *testing.T) { - // Verify that RefreshableConnPool implements required interfaces at compile time - // This test will fail to compile if interfaces are not properly implemented - var pool *RefreshableConnPool - - // Test gorm.ConnPool interface - var _ gorm.ConnPool = pool - - // Test gorm.ConnPoolBeginner interface - var _ gorm.ConnPoolBeginner = pool -} - -func TestRefreshableConnPool_ConcurrentAccess(t *testing.T) { - cfg := config.Get() - dsn, err := BuildDSN(cfg.PostgreSQL) - require.NoError(t, err) - - gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - } - - pool, err := NewRefreshableConnPool(dsn, gormConfig, 10, 100, time.Hour) - require.NoError(t, err) - defer func() { _ = pool.Close() }() - - db, err := pool.NewGORMDB() - require.NoError(t, err) - - // Test that the connection is working - ctx := context.Background() - var result int - err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error - require.NoError(t, err, "Initial connection test should succeed") - - // Test concurrent queries - numGoroutines := 10 - numQueries := 5 - - var wg sync.WaitGroup - errChan := make(chan error, numGoroutines*numQueries) - - for i := range numGoroutines { - wg.Add(1) - go func(goroutineID int) { - defer wg.Done() - for range numQueries { - var result int - err := db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error - if err != nil { - errChan <- err - } - } - }(i) - } - - // Wait for all goroutines to complete, then close the channel - wg.Wait() - close(errChan) - - // Check for any errors - for err := range errChan { - assert.NoError(t, err, "Concurrent queries should succeed") - } -} - -func TestRefreshableConnPool_InvalidCredentials(t *testing.T) { - // Create a pool with invalid credentials - invalidDSN := "host=localhost port=5432 user=invalid password=invalid dbname=invalid sslmode=disable" - - gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - } - - pool, err := NewRefreshableConnPool(invalidDSN, gormConfig, 10, 100, time.Hour) - if err != nil { - // sql.Open may succeed even with invalid credentials (lazy connection) - return - } - defer func() { _ = pool.Close() }() - - // Ping should fail with invalid credentials - ctx := context.Background() - err = pool.Ping(ctx) - assert.Error(t, err, "Ping with invalid credentials should fail") -} - -func TestConfig_RefreshPostgreSQLConfig_FileURI(t *testing.T) { - // Create temporary files for testing file:// URIs - tmpDir := t.TempDir() - - passwordFile := filepath.Join(tmpDir, "password") - userFile := filepath.Join(tmpDir, "user") - hostFile := filepath.Join(tmpDir, "host") - - err := os.WriteFile(passwordFile, []byte("secret_password"), 0600) - require.NoError(t, err) - err = os.WriteFile(userFile, []byte("dbuser"), 0600) - require.NoError(t, err) - err = os.WriteFile(hostFile, []byte("db.example.com"), 0600) - require.NoError(t, err) - - // Set up environment variables with file:// URIs - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile)) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "file://"+userFile)) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "file://"+hostFile)) - defer func() { - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST") - }() - - // Create and setup config - cfg := &config.Config{} - cfg.Setup() - - // Test initial values are parsed correctly - assert.Equal(t, "secret_password", cfg.PostgreSQL.Password, "Initial password should be parsed from file") - assert.Equal(t, "dbuser", cfg.PostgreSQL.User, "Initial user should be parsed from file") - assert.Equal(t, "db.example.com", cfg.PostgreSQL.Host, "Initial host should be parsed from file") - - // Test RefreshPostgreSQLConfig returns same values initially - refreshed := cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "secret_password", refreshed.Password) - assert.Equal(t, "dbuser", refreshed.User) - assert.Equal(t, "db.example.com", refreshed.Host) - - // Update password file (simulating credential rotation) - err = os.WriteFile(passwordFile, []byte("new_password"), 0600) - require.NoError(t, err) - - // Update user file - err = os.WriteFile(userFile, []byte("new_dbuser"), 0600) - require.NoError(t, err) - - // Refresh should pick up new values from files - refreshed = cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "new_password", refreshed.Password, "Password should be refreshed from file") - assert.Equal(t, "new_dbuser", refreshed.User, "User should be refreshed from file") - - // Original config struct should still have old values (not mutated) - assert.Equal(t, "secret_password", cfg.PostgreSQL.Password, "Original config should not be mutated") -} - -func TestConfig_RefreshPostgreSQLConfig_EnvURI(t *testing.T) { - // Test with env:// URIs (referencing other env vars) - require.NoError(t, os.Setenv("DB_PASSWORD", "env_password")) - require.NoError(t, os.Setenv("DB_USER", "env_user")) - require.NoError(t, os.Setenv("DB_HOST", "env_host")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "env://DB_PASSWORD")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "env://DB_USER")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "env://DB_HOST")) - defer func() { - _ = os.Unsetenv("DB_PASSWORD") - _ = os.Unsetenv("DB_USER") - _ = os.Unsetenv("DB_HOST") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST") - }() - - cfg := &config.Config{} - cfg.Setup() - - // Test initial values are parsed correctly - assert.Equal(t, "env_password", cfg.PostgreSQL.Password, "Initial password should be parsed from env") - assert.Equal(t, "env_user", cfg.PostgreSQL.User, "Initial user should be parsed from env") - assert.Equal(t, "env_host", cfg.PostgreSQL.Host, "Initial host should be parsed from env") - - // Test RefreshPostgreSQLConfig - refreshed := cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "env_password", refreshed.Password) - assert.Equal(t, "env_user", refreshed.User) - assert.Equal(t, "env_host", refreshed.Host) - - // Change referenced environment variables (simulating credential rotation) - require.NoError(t, os.Setenv("DB_PASSWORD", "new_env_password")) - require.NoError(t, os.Setenv("DB_USER", "new_env_user")) - - // Refresh should pick up new values - refreshed = cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "new_env_password", refreshed.Password, "Password should be refreshed from env") - assert.Equal(t, "new_env_user", refreshed.User, "User should be refreshed from env") - - // Original config struct should still have old values (not mutated) - assert.Equal(t, "env_password", cfg.PostgreSQL.Password, "Original config should not be mutated") -} - -func TestConfig_RefreshPostgreSQLConfig_PlainValues(t *testing.T) { - // Test with plain values (no URI scheme) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "plain_password")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "plain_user")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "localhost")) - defer func() { - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST") - }() - - cfg := &config.Config{} - cfg.Setup() - - // Test initial values - assert.Equal(t, "plain_password", cfg.PostgreSQL.Password) - assert.Equal(t, "plain_user", cfg.PostgreSQL.User) - assert.Equal(t, "localhost", cfg.PostgreSQL.Host) - - // Test refresh returns same values - refreshed := cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "plain_password", refreshed.Password) - assert.Equal(t, "plain_user", refreshed.User) - assert.Equal(t, "localhost", refreshed.Host) - - // Change env vars - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "new_plain_password")) - - // Refresh should pick up new plain value - refreshed = cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "new_plain_password", refreshed.Password, "Plain password should be refreshed") -} - -func TestConfig_RefreshPostgreSQLConfig_MixedSources(t *testing.T) { - // Test with mixed sources: file://, env://, and plain - tmpDir := t.TempDir() - passwordFile := filepath.Join(tmpDir, "password") - err := os.WriteFile(passwordFile, []byte("file_password"), 0600) - require.NoError(t, err) - - require.NoError(t, os.Setenv("DB_USER_VAR", "env_user")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__PASSWORD", "file://"+passwordFile)) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__USER", "env://DB_USER_VAR")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "plain_host")) - defer func() { - _ = os.Unsetenv("DB_USER_VAR") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__PASSWORD") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__USER") - _ = os.Unsetenv("AUTHENTIK_POSTGRESQL__HOST") - }() - - cfg := &config.Config{} - cfg.Setup() - - // Test initial values - assert.Equal(t, "file_password", cfg.PostgreSQL.Password) - assert.Equal(t, "env_user", cfg.PostgreSQL.User) - assert.Equal(t, "plain_host", cfg.PostgreSQL.Host) - - // Update all sources - err = os.WriteFile(passwordFile, []byte("new_file_password"), 0600) - require.NoError(t, err) - require.NoError(t, os.Setenv("DB_USER_VAR", "new_env_user")) - require.NoError(t, os.Setenv("AUTHENTIK_POSTGRESQL__HOST", "new_plain_host")) - - // Refresh should pick up all changes - refreshed := cfg.RefreshPostgreSQLConfig() - assert.Equal(t, "new_file_password", refreshed.Password, "File password should be refreshed") - assert.Equal(t, "new_env_user", refreshed.User, "Env user should be refreshed") - assert.Equal(t, "new_plain_host", refreshed.Host, "Plain host should be refreshed") -} - -func TestIsAuthError(t *testing.T) { - tests := []struct { - name string - err error - expected bool - }{ - { - name: "nil error", - err: nil, - expected: false, - }, - { - name: "generic error", - err: assert.AnError, - expected: false, - }, - { - name: "postgres error code 28000 - invalid_authorization_specification", - err: &pgconn.PgError{ - Code: "28000", - Message: "invalid authorization specification", - }, - expected: true, - }, - { - name: "postgres error code 28P01 - invalid_password", - err: &pgconn.PgError{ - Code: "28P01", - Message: "password authentication failed for user", - }, - expected: true, - }, - { - name: "postgres error code 28P02 - invalid_password (deprecated)", - err: &pgconn.PgError{ - Code: "28P02", - Message: "invalid password", - }, - expected: true, - }, - { - name: "postgres error code 42P01 - undefined_table (not auth error)", - err: &pgconn.PgError{ - Code: "42P01", - Message: "relation does not exist", - }, - expected: false, - }, - { - name: "postgres error code 23505 - unique_violation (not auth error)", - err: &pgconn.PgError{ - Code: "23505", - Message: "duplicate key value violates unique constraint", - }, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isAuthError(tt.err) - assert.Equal(t, tt.expected, result) - }) - } -} diff --git a/internal/outpost/proxyv2/postgresstore/logger.go b/internal/outpost/proxyv2/postgresstore/logger.go deleted file mode 100644 index f5a75c078a61..000000000000 --- a/internal/outpost/proxyv2/postgresstore/logger.go +++ /dev/null @@ -1,48 +0,0 @@ -package postgresstore - -import ( - "context" - "time" - - log "github.com/sirupsen/logrus" - gormlogger "gorm.io/gorm/logger" -) - -type logrusLogger struct { - logger *log.Entry -} - -func NewLogger(parent *log.Entry) *logrusLogger { - return &logrusLogger{ - logger: parent, - } -} - -func (l *logrusLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { - return l -} - -func (l *logrusLogger) Info(ctx context.Context, s string, args ...any) { - l.logger.WithContext(ctx).Infof(s, args...) -} - -func (l *logrusLogger) Warn(ctx context.Context, s string, args ...any) { - l.logger.WithContext(ctx).Warnf(s, args...) -} - -func (l *logrusLogger) Error(ctx context.Context, s string, args ...any) { - l.logger.WithContext(ctx).Errorf(s, args...) -} - -func (l *logrusLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { - elapsed := time.Since(begin) - sql, _ := fc() - fields := log.Fields{ - "elapsed": elapsed, - } - if err != nil { - l.logger.WithContext(ctx).WithFields(fields).WithError(err).Error(sql) - return - } - l.logger.WithContext(ctx).WithFields(fields).Trace(sql) -} diff --git a/internal/outpost/proxyv2/postgresstore/postgresstore.go b/internal/outpost/proxyv2/postgresstore/postgresstore.go deleted file mode 100644 index fcba534e7c5c..000000000000 --- a/internal/outpost/proxyv2/postgresstore/postgresstore.go +++ /dev/null @@ -1,676 +0,0 @@ -package postgresstore - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - "github.com/gorilla/sessions" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/stdlib" - "github.com/mitchellh/mapstructure" - log "github.com/sirupsen/logrus" - "gorm.io/gorm" - "gorm.io/gorm/clause" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -// PostgresStore stores gorilla sessions in PostgreSQL using GORM -type PostgresStore struct { - db *gorm.DB - pool *RefreshableConnPool // Keep reference to pool for cleanup - // default options to use when a new session is created - options sessions.Options - // key prefix with which the session will be stored - keyPrefix string - log *log.Entry -} - -// ProxySession represents the session data structure in PostgreSQL -type ProxySession struct { - UUID uuid.UUID `gorm:"type:uuid;primaryKey;column:uuid;default:gen_random_uuid()"` - SessionKey string `gorm:"column:session_key"` - UserID *uuid.UUID `gorm:"column:user_id"` - SessionData string `gorm:"type:jsonb;column:session_data"` - Expires time.Time `gorm:"column:expires"` - Expiring bool `gorm:"column:expiring"` -} - -// TableName specifies the table name for GORM -func (ProxySession) TableName() string { - return "authentik_providers_proxy_proxysession" -} - -// BuildConnConfig constructs a pgx.ConnConfig from PostgreSQL configuration. -func BuildConnConfig(cfg config.PostgreSQLConfig) (*pgx.ConnConfig, error) { - // Validate required fields - if cfg.Host == "" { - return nil, fmt.Errorf("PostgreSQL host is required") - } - if cfg.User == "" { - return nil, fmt.Errorf("PostgreSQL user is required") - } - if cfg.Name == "" { - return nil, fmt.Errorf("PostgreSQL database name is required") - } - if cfg.Port == "" { - return nil, fmt.Errorf("PostgreSQL port is required") - } - - // Start with a default config - connConfig, err := pgx.ParseConfig("") - if err != nil { - return nil, fmt.Errorf("failed to create default config: %w", err) - } - - // Parse comma-separated hosts and create fallbacks - // cfg.Host can be a comma-separated list like "host1,host2,host3" - hosts := strings.Split(cfg.Host, ",") - for i, host := range hosts { - hosts[i] = strings.TrimSpace(host) - } - - // Parse and validate comma-separated ports - portStrs := strings.Split(cfg.Port, ",") - ports := make([]uint16, len(portStrs)) - for i, portStr := range portStrs { - portStr = strings.TrimSpace(portStr) - port, err := strconv.Atoi(portStr) - if err != nil { - return nil, fmt.Errorf("invalid port value %q: %w", portStr, err) - } - if port <= 0 { - return nil, fmt.Errorf("PostgreSQL port %d must be positive", port) - } - if port > 65535 { - return nil, fmt.Errorf("PostgreSQL port %d is out of valid range", port) - } - ports[i] = uint16(port) - } - - // Get port for primary host - primaryHost := hosts[0] - primaryPort := ports[0] - - // Set connection parameters for primary host - connConfig.Host = primaryHost - connConfig.Port = primaryPort - connConfig.User = cfg.User - connConfig.Password = cfg.Password - connConfig.Database = cfg.Name - - // Configure TLS/SSL - if cfg.SSLMode != "" { - switch cfg.SSLMode { - case "disable": - connConfig.TLSConfig = nil - case "require", "verify-ca", "verify-full": - tlsConfig := &tls.Config{} - - // Load root CA certificate if provided - if cfg.SSLRootCert != "" { - caCert, err := os.ReadFile(cfg.SSLRootCert) - if err != nil { - return nil, fmt.Errorf("failed to read SSL root certificate: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse SSL root certificate") - } - tlsConfig.RootCAs = caCertPool - } - - // Load client certificate and key if provided - if cfg.SSLCert != "" && cfg.SSLKey != "" { - cert, err := tls.LoadX509KeyPair(cfg.SSLCert, cfg.SSLKey) - if err != nil { - return nil, fmt.Errorf("failed to load SSL client certificate: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - } - - // Set verification mode - switch cfg.SSLMode { - case "require": - // Don't verify the server certificate (just encrypt) - tlsConfig.InsecureSkipVerify = true - case "verify-ca": - // Verify the certificate is signed by a trusted CA - tlsConfig.InsecureSkipVerify = false - case "verify-full": - // Verify the certificate and hostname - tlsConfig.InsecureSkipVerify = false - tlsConfig.ServerName = primaryHost - } - - connConfig.TLSConfig = tlsConfig - } - } - - // Create fallback configurations for additional hosts - if len(hosts) > 1 { - connConfig.Fallbacks = make([]*pgconn.FallbackConfig, 0, len(hosts)-1) - for i, host := range hosts[1:] { - port := getPortForIndex(ports, i+1) - fallback := &pgconn.FallbackConfig{ - Host: host, - Port: port, - } - // Copy TLS config to fallback if present - if connConfig.TLSConfig != nil { - fallbackTLS := connConfig.TLSConfig.Clone() - // Update ServerName for verify-full mode - if cfg.SSLMode == "verify-full" { - fallbackTLS.ServerName = host - } - fallback.TLSConfig = fallbackTLS - } - connConfig.Fallbacks = append(connConfig.Fallbacks, fallback) - } - } - - // Set runtime params - if connConfig.RuntimeParams == nil { - connConfig.RuntimeParams = make(map[string]string) - } - effectiveSearchPath := cfg.DefaultSchema - - // Parse and apply connection options if specified - if cfg.ConnOptions != "" { - connOpts, err := parseConnOptions(cfg.ConnOptions) - if err != nil { - return nil, fmt.Errorf("failed to parse connection options: %w", err) - } - // search_path from ConnOptions is not supported here; Django controls schema selection. - // Always remove it so it cannot end up in startup RuntimeParams via applyConnOptions. - delete(connOpts, "search_path") - - if err := applyConnOptions(connConfig, connOpts); err != nil { - return nil, fmt.Errorf("failed to apply connection options: %w", err) - } - } - - // search_path may already be present via pgx/libpq inherited defaults (e.g. service files). - // Always remove it from startup RuntimeParams; apply it via AfterConnect instead. - if inheritedSearchPath, hasInheritedSearchPath := connConfig.RuntimeParams["search_path"]; hasInheritedSearchPath { - if effectiveSearchPath == "" { - effectiveSearchPath = inheritedSearchPath - } - delete(connConfig.RuntimeParams, "search_path") - } - - // Set search_path after connection startup to avoid startup-parameter issues with PgBouncer. - if effectiveSearchPath != "" { - connConfig.AfterConnect = func(ctx context.Context, pgConn *pgconn.PgConn) error { - result := pgConn.ExecParams( - ctx, - "select pg_catalog.set_config('search_path', $1, false)", - [][]byte{[]byte(effectiveSearchPath)}, - nil, - nil, - nil, - ).Read() - return result.Err - } - } - - return connConfig, nil -} - -// getPortForIndex returns the port for the given host index. -// If there are fewer ports than needed, returns the last port (libpq behavior). -func getPortForIndex(ports []uint16, i int) uint16 { - if i >= len(ports) { - return ports[len(ports)-1] - } - return ports[i] -} - -// parseConnOptions decodes a base64-encoded JSON string into a map of connection options. -// This matches the Python behavior in authentik/lib/config.py:get_dict_from_b64_json -func parseConnOptions(encoded string) (map[string]string, error) { - // Base64 decode - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("invalid base64 encoding: %w", err) - } - - // Parse JSON - var opts map[string]any - if err := json.Unmarshal(decoded, &opts); err != nil { - return nil, fmt.Errorf("invalid JSON: %w", err) - } - - // Convert all values to strings - result := make(map[string]string) - for k, v := range opts { - switch val := v.(type) { - case string: - result[k] = val - case float64: - // JSON numbers are float64 - if val == float64(int(val)) { - result[k] = strconv.Itoa(int(val)) - } else { - result[k] = strconv.FormatFloat(val, 'f', -1, 64) - } - case bool: - result[k] = strconv.FormatBool(val) - default: - result[k] = fmt.Sprintf("%v", v) - } - } - - return result, nil -} - -// applyConnOptions applies parsed connection options to the pgx.ConnConfig. -func applyConnOptions(connConfig *pgx.ConnConfig, opts map[string]string) error { - for key, value := range opts { - // connect_timeout needs special handling as it's a connection-level timeout - if key == "connect_timeout" { - timeout, err := strconv.Atoi(value) - if err != nil { - return fmt.Errorf("invalid connect_timeout value: %w", err) - } - connConfig.ConnectTimeout = time.Duration(timeout) * time.Second - continue - } - // target_session_attrs needs special handling to set ValidateConnect function - if key == "target_session_attrs" { - switch value { - case "read-write": - connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsReadWrite - case "read-only": - connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsReadOnly - case "primary": - connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsPrimary - case "standby": - connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsStandby - case "prefer-standby": - connConfig.ValidateConnect = pgconn.ValidateConnectTargetSessionAttrsPreferStandby - case "any": - // "any" is the default (no validation needed) - connConfig.ValidateConnect = nil - default: - return fmt.Errorf("unknown target_session_attrs value: %s", value) - } - // Do not add target_session_attrs to RuntimeParams - continue - } - // All other options go to RuntimeParams - connConfig.RuntimeParams[key] = value - } - return nil -} - -// BuildDSN constructs a PostgreSQL connection string from a ConnConfig. -func BuildDSN(cfg config.PostgreSQLConfig) (string, error) { - connConfig, err := BuildConnConfig(cfg) - if err != nil { - return "", err - } - - // Register the config and get a connection string - // (This approach lets pgx handle all the escaping internally which is quite convenient for say spaces in the password) - return stdlib.RegisterConnConfig(connConfig), nil -} - -// SetupGORMWithRefreshablePool creates a GORM DB with a refreshable connection pool. -// This is the standardized way to create database connections for both production and tests. -// -// The RefreshableConnPool wraps database/sql and automatically detects PostgreSQL -// authentication errors (SQLSTATE 28xxx), refreshes credentials from config sources -// (file://, env://, or plain environment variables), and reconnects without downtime. -// -// Parameters: -// - cfg: PostgreSQL configuration (host, port, user, password, etc.) -// - gormConfig: GORM configuration (logger, naming strategy, etc.) -// - maxIdleConns: Maximum number of idle connections in the pool -// - maxOpenConns: Maximum number of open connections to the database -// - connMaxLifetime: Maximum lifetime of a connection -// -// Returns: -// - *gorm.DB: GORM database instance for ORM operations -// - *RefreshableConnPool: Connection pool reference (caller must Close when done) -// - error: Any error encountered during setup -func SetupGORMWithRefreshablePool(cfg config.PostgreSQLConfig, gormConfig *gorm.Config, maxIdleConns, maxOpenConns int, connMaxLifetime time.Duration) (*gorm.DB, *RefreshableConnPool, error) { - // Build connection string - dsn, err := BuildDSN(cfg) - if err != nil { - return nil, nil, fmt.Errorf("failed to build DSN: %w", err) - } - - // Create refreshable connection pool - pool, err := NewRefreshableConnPool(dsn, gormConfig, maxIdleConns, maxOpenConns, connMaxLifetime) - if err != nil { - return nil, nil, fmt.Errorf("failed to create connection pool: %w", err) - } - - // Create GORM DB using the refreshable connection pool - db, err := pool.NewGORMDB() - if err != nil { - _ = pool.Close() - return nil, nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err) - } - - // Test the connection with a simple query - // This will trigger the connection pool's tryWithRefresh logic if there's an auth error - ctx := context.Background() - var result int - err = db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error - if err != nil { - _ = pool.Close() - return nil, nil, fmt.Errorf("failed to connect to PostgreSQL: %w", err) - } - - return db, pool, nil -} - -// NewPostgresStore returns a new PostgresStore -func NewPostgresStore(log *log.Entry) (*PostgresStore, error) { - cfg := config.Get().PostgreSQL - - // Configure GORM - gormConfig := &gorm.Config{ - Logger: NewLogger(log), - NowFunc: func() time.Time { - return time.Now().UTC() - }, - } - - // Determine connection pool settings - maxIdleConns := 4 - maxOpenConns := 4 - var connMaxLifetime time.Duration - if cfg.ConnMaxAge > 0 { - connMaxLifetime = time.Duration(cfg.ConnMaxAge) * time.Second - } else { - connMaxLifetime = time.Hour // Default 1 hour - } - - // Use standardized setup - db, pool, err := SetupGORMWithRefreshablePool(cfg, gormConfig, maxIdleConns, maxOpenConns, connMaxLifetime) - if err != nil { - return nil, fmt.Errorf("failed to setup database: %w", err) - } - - ps := &PostgresStore{ - db: db, - pool: pool, - options: sessions.Options{ - Path: "/", - MaxAge: 86400 * 30, // 30 days default (but overwritten in postgresstore creation based on token validation) - }, - keyPrefix: "authentik_proxy_session_", - log: log.WithField("logger", "authentik.outpost.proxyv2.postgresstore"), - } - - return ps, nil -} - -// Get returns a session for the given name after adding it to the registry. -func (s *PostgresStore) Get(r *http.Request, name string) (*sessions.Session, error) { - return sessions.GetRegistry(r).Get(s, name) -} - -// New returns a session for the given name without adding it to the registry. -func (s *PostgresStore) New(r *http.Request, name string) (*sessions.Session, error) { - session := sessions.NewSession(s, name) - opts := s.options - session.Options = &opts - session.IsNew = true - - c, err := r.Cookie(name) - if err != nil { - return session, nil - } - session.ID = c.Value - - err = s.load(r.Context(), session) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return session, nil - } - return session, err - } - session.IsNew = false - return session, err -} - -// Save adds a single session to the response. -func (s *PostgresStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error { - // Delete if max-age is <= 0 - if session.Options.MaxAge <= 0 { - if err := s.delete(r.Context(), session); err != nil { - return fmt.Errorf("failed to delete session: %w", err) - } - http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options)) - return nil - } - - if session.ID == "" { - // Generate new session ID - session.ID = s.keyPrefix + generateSessionID() - } - - if err := s.save(r.Context(), session); err != nil { - return fmt.Errorf("failed to save session: %w", err) - } - - http.SetCookie(w, sessions.NewCookie(session.Name(), session.ID, session.Options)) - return nil -} - -// Options set options to use when a new session is created -func (s *PostgresStore) Options(opts sessions.Options) { - s.options = opts -} - -// KeyPrefix sets the key prefix to store session in PostgreSQL -func (s *PostgresStore) KeyPrefix(keyPrefix string) { - s.keyPrefix = keyPrefix -} - -// Close closes the PostgreSQL store -func (s *PostgresStore) Close() error { - if s.pool != nil { - return s.pool.Close() - } - return nil -} - -// save writes session to PostgreSQL -func (s *PostgresStore) save(ctx context.Context, session *sessions.Session) error { - // Convert session.Values (map[interface{}]interface{}) to map[string]interface{} for JSON marshaling - stringKeyedValues := make(map[string]any) - for k, v := range session.Values { - if key, ok := k.(string); ok { - stringKeyedValues[key] = v - } - } - - // Serialize all session values to JSON - sessionData, err := json.Marshal(stringKeyedValues) - if err != nil { - return fmt.Errorf("failed to marshal session values: %w", err) - } - - // Extract user ID from claims if it exists - var userID *uuid.UUID - if claims, hasClaims := session.Values[constants.SessionClaims]; hasClaims { - if claimsMap, ok := claims.(map[string]any); ok { - if sub, exists := claimsMap["sub"]; exists { - if subStr, ok := sub.(string); ok { - if parsedUUID, err := uuid.Parse(subStr); err == nil { - userID = &parsedUUID - } - } - } - } - } - - proxySession := ProxySession{ - UUID: uuid.New(), - SessionKey: session.ID, - UserID: userID, - SessionData: string(sessionData), - Expiring: true, - } - - // Add expiration timestamp to session data - if session.Options != nil && session.Options.MaxAge > 0 { - expiresAt := time.Now().UTC().Add(time.Duration(session.Options.MaxAge) * time.Second) - proxySession.Expires = expiresAt - } - - return s.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "session_key"}}, - DoUpdates: clause.AssignmentColumns([]string{"user_id", "session_data", "expires"}), - }).Create(&proxySession).Error -} - -// load reads session from PostgreSQL -func (s *PostgresStore) load(ctx context.Context, session *sessions.Session) error { - var proxySession ProxySession - err := s.db.WithContext(ctx).Where("session_key = ?", session.ID).First(&proxySession).Error - - if err != nil { - return fmt.Errorf("failed to load session: %w", err) - } - - // Check if session is expired - if time.Now().UTC().After(proxySession.Expires) { - // Session is expired, delete it and return not found error - s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key = ?", session.ID) - return gorm.ErrRecordNotFound - } - - // Deserialize session data from JSON - if proxySession.SessionData != "" { - // First unmarshal to map[string]interface{} - var stringKeyedValues map[string]any - err = json.Unmarshal([]byte(proxySession.SessionData), &stringKeyedValues) - if err != nil { - return fmt.Errorf("failed to unmarshal session data: %w", err) - } - - // Convert back to map[interface{}]interface{} for gorilla/sessions compatibility - session.Values = make(map[any]any) - for k, v := range stringKeyedValues { - session.Values[k] = v - } - } - - return nil -} - -// delete removes session from PostgreSQL -func (s *PostgresStore) delete(ctx context.Context, session *sessions.Session) error { - return s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key = ?", session.ID).Error -} - -// CleanupExpired removes expired sessions by checking MaxAge in session_data -func (s *PostgresStore) CleanupExpired(ctx context.Context) error { - result := s.db.WithContext(ctx).Where(`"expires" < ?`, time.Now().UTC()).Delete(&ProxySession{}) - if result.Error != nil { - return fmt.Errorf("failed to delete expired sessions: %w", result.Error) - } - - if result.RowsAffected > 0 { - s.log.WithField("count", result.RowsAffected).Info("Cleaned up expired sessions") - } - - return nil -} - -// LogoutSessions removes sessions that match the given filter criteria -// The filter function should return true for sessions that should be deleted -func (s *PostgresStore) LogoutSessions(ctx context.Context, filter func(c types.Claims) bool) error { - // First, try to use JSONB operators for common filter patterns to avoid N+1 queries - // If the filter is too complex, fall back to client-side filtering - - // Pre-filter sessions using JSONB operators where possible - // Only fetch sessions that have claims (session_data->'claims' IS NOT NULL) - var sessions []ProxySession - err := s.db.WithContext(ctx).Where(fmt.Sprintf("session_data::jsonb ? '%s'", constants.SessionClaims)).Find(&sessions).Error - if err != nil { - return fmt.Errorf("failed to fetch sessions: %w", err) - } - - var sessionKeysToDelete []string - - for _, session := range sessions { - if session.SessionData == "" { - continue - } - - var sessionData map[string]any - if err := json.Unmarshal([]byte(session.SessionData), &sessionData); err != nil { - continue - } - - claimsData, hasClaims := sessionData[constants.SessionClaims] - if !hasClaims { - continue - } - - claimsMap, ok := claimsData.(map[string]any) - if !ok { - continue - } - - // Only decode Sub and Sid fields since those are the only ones used in filters - var claims types.Claims - if err := mapstructure.Decode(claimsMap, &claims); err != nil { - continue - } - - if filter(claims) { - sessionKeysToDelete = append(sessionKeysToDelete, session.SessionKey) - } - } - - if len(sessionKeysToDelete) > 0 { - err = s.db.WithContext(ctx).Delete(&ProxySession{}, "session_key IN ?", sessionKeysToDelete).Error - if err != nil { - return fmt.Errorf("failed to delete sessions: %w", err) - } - } - - return nil -} - -// generateSessionID generates a random session ID -func generateSessionID() string { - return uuid.New().String() -} - -// NewTestStore creates a PostgresStore for testing with the given database and pool. -// The pool reference is required to properly close connections in test cleanup. -func NewTestStore(db *gorm.DB, pool *RefreshableConnPool) *PostgresStore { - return &PostgresStore{ - db: db, - pool: pool, - options: sessions.Options{ - Path: "/", - MaxAge: 3600, - }, - keyPrefix: "test_session_", - log: log.WithField("logger", "test"), - } -} diff --git a/internal/outpost/proxyv2/postgresstore/postgresstore_test.go b/internal/outpost/proxyv2/postgresstore/postgresstore_test.go deleted file mode 100644 index 0b48d69b1973..000000000000 --- a/internal/outpost/proxyv2/postgresstore/postgresstore_test.go +++ /dev/null @@ -1,1843 +0,0 @@ -package postgresstore - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "math/big" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "runtime" - "slices" - "testing" - "time" - - "github.com/google/uuid" - "github.com/gorilla/sessions" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/outpost/proxyv2/constants" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -// SetupTestDB creates a test database connection for testing -func SetupTestDB(t *testing.T) (*gorm.DB, *RefreshableConnPool) { - cfg := config.Get().PostgreSQL - - t.Logf("PostgreSQL config: Host=%s Port=%s User=%s DBName=%s SSLMode=%s", - cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.SSLMode) - t.Logf("Password length: %d", len(cfg.Password)) - if cfg.Password == "" { - t.Logf("WARNING: Password is empty!") - } - - gormConfig := &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - NowFunc: func() time.Time { - return time.Now().UTC() - }, - } - - // Use standardized setup - db, pool, err := SetupGORMWithRefreshablePool(cfg, gormConfig, 10, 100, time.Hour) - require.NoError(t, err) - - return db, pool -} - -// CleanupTestDB removes test sessions from the database -func CleanupTestDB(t *testing.T, db *gorm.DB, pool *RefreshableConnPool) { - assert.NoError(t, db.Exec("DELETE FROM authentik_providers_proxy_proxysession").Error) - assert.NoError(t, pool.Close()) -} - -func TestPostgresStore_New(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - req := httptest.NewRequest("GET", "/", nil) - session, err := store.New(req, "test_session") - - assert.NoError(t, err) - assert.True(t, session.IsNew) - assert.Equal(t, "test_session", session.Name()) -} - -func TestPostgresStore_Save(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - session, err := store.New(req, "test_session") - require.NoError(t, err) - - // Set up session claims - userID := uuid.New() - claims := map[string]any{ - "sub": userID.String(), - "email": "test@example.com", - "preferred_username": "testuser", - "exp": time.Now().Add(time.Hour).Unix(), - "custom_claim": "custom_value", - } - session.Values[constants.SessionClaims] = claims - - err = store.Save(req, w, session) - assert.NoError(t, err) - - // Verify session was saved to database - var savedSession ProxySession - err = db.First(&savedSession, "session_key = ?", session.ID).Error - assert.NoError(t, err) - assert.Equal(t, userID, *savedSession.UserID) - - // Verify session data contains claims - var sessionData map[string]any - err = json.Unmarshal([]byte(savedSession.SessionData), &sessionData) - assert.NoError(t, err) - - claimsData, ok := sessionData[constants.SessionClaims].(map[string]any) - assert.True(t, ok) - assert.Equal(t, "test@example.com", claimsData["email"]) - assert.Equal(t, "testuser", claimsData["preferred_username"]) - assert.Equal(t, "custom_value", claimsData["custom_claim"]) -} - -func TestPostgresStore_Load(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create a session directly in the database - userID := uuid.New() - sessionKey := "test_session_123" - - sessionData := map[string]any{ - constants.SessionClaims: map[string]any{ - "sub": userID.String(), - "email": "test@example.com", - "preferred_username": "testuser", - "exp": time.Now().Add(time.Hour).Unix(), - "custom_claim": "custom_value", - }, - } - - sessionDataJSON, err := json.Marshal(sessionData) - require.NoError(t, err) - - proxySession := ProxySession{ - UUID: uuid.New(), - SessionKey: sessionKey, - UserID: &userID, - SessionData: string(sessionDataJSON), - Expires: time.Now().Add(time.Hour), - } - err = db.Create(&proxySession).Error - require.NoError(t, err) - - // Load the session - session := sessions.NewSession(store, "test_session") - session.ID = sessionKey - err = store.load(context.Background(), session) - assert.NoError(t, err) - - // Verify claims were loaded correctly - claims, ok := session.Values[constants.SessionClaims].(map[string]any) - assert.True(t, ok) - assert.Equal(t, userID.String(), claims["sub"]) - assert.Equal(t, "test@example.com", claims["email"]) - assert.Equal(t, "testuser", claims["preferred_username"]) - assert.Equal(t, "custom_value", claims["custom_claim"]) -} - -func TestPostgresStore_Delete(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create a session in the database - sessionKey := "test_session_456" - - proxySession := ProxySession{ - UUID: uuid.New(), - SessionKey: sessionKey, - SessionData: "{}", - Expires: time.Now().Add(time.Hour), - } - err := db.Create(&proxySession).Error - require.NoError(t, err) - - // Delete the session - session := sessions.NewSession(store, "test_session") - session.ID = sessionKey - err = store.delete(context.Background(), session) - assert.NoError(t, err) - - // Verify session was deleted - var count int64 - db.Model(&ProxySession{}).Where("session_key = ?", sessionKey).Count(&count) - assert.Equal(t, int64(0), count) -} - -func TestPostgresStore_LogoutSessions_ByUserID(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create multiple sessions for different users - user1 := uuid.New() - user2 := uuid.New() - - sessions := []ProxySession{ - { - UUID: uuid.New(), - SessionKey: "test_session_user1_1", - UserID: &user1, - SessionData: createSessionData(t, map[string]any{ - "sub": user1.String(), - "email": "user1@example.com", - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_user1_2", - UserID: &user1, - SessionData: createSessionData(t, map[string]any{ - "sub": user1.String(), - "email": "user1@example.com", - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_user2_1", - UserID: &user2, - SessionData: createSessionData(t, map[string]any{ - "sub": user2.String(), - "email": "user2@example.com", - }), - }, - } - - for _, session := range sessions { - err := db.Create(&session).Error - require.NoError(t, err) - } - - // Test filtering by user ID - ctx := context.Background() - err := store.LogoutSessions(ctx, func(c types.Claims) bool { - return c.Sub == user1.String() - }) - assert.NoError(t, err) - - // Verify only user2 session remains - var count int64 - db.Model(&ProxySession{}).Where("session_key LIKE 'test_%'").Count(&count) - assert.Equal(t, int64(1), count) - - var remaining ProxySession - err = db.Where("session_key LIKE 'test_%'").First(&remaining).Error - assert.NoError(t, err) - assert.Equal(t, user2, *remaining.UserID) -} - -func TestPostgresStore_LogoutSessions_ByEmail(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create sessions with different emails - sessions := []ProxySession{ - { - UUID: uuid.New(), - SessionKey: "test_session_admin_1", - SessionData: createSessionData(t, map[string]any{ - "email": "admin@example.com", - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_admin_2", - SessionData: createSessionData(t, map[string]any{ - "email": "admin@example.com", - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_user_1", - SessionData: createSessionData(t, map[string]any{ - "email": "user@example.com", - }), - }, - } - - for _, session := range sessions { - err := db.Create(&session).Error - require.NoError(t, err) - } - - // Logout all admin sessions - ctx := context.Background() - err := store.LogoutSessions(ctx, func(c types.Claims) bool { - return c.Email == "admin@example.com" - }) - assert.NoError(t, err) - - // Verify only user session remains - var count int64 - db.Model(&ProxySession{}).Where("session_key LIKE 'test_%'").Count(&count) - assert.Equal(t, int64(1), count) - - var remaining ProxySession - err = db.Where("session_key LIKE 'test_%'").First(&remaining).Error - assert.NoError(t, err) - - var sessionData map[string]any - err = json.Unmarshal([]byte(remaining.SessionData), &sessionData) - require.NoError(t, err) - claims := sessionData[constants.SessionClaims].(map[string]any) - assert.Equal(t, "user@example.com", claims["email"]) -} - -func TestPostgresStore_LogoutSessions_WithGroups(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create sessions with different group memberships - sessions := []ProxySession{ - { - UUID: uuid.New(), - SessionKey: "test_session_admin_user", - SessionData: createSessionData(t, map[string]any{ - "email": "admin@example.com", - "groups": []any{"admin", "user"}, - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_regular_user", - SessionData: createSessionData(t, map[string]any{ - "email": "user@example.com", - "groups": []any{"user"}, - }), - }, - { - UUID: uuid.New(), - SessionKey: "test_session_guest", - SessionData: createSessionData(t, map[string]any{ - "email": "guest@example.com", - "groups": []any{"guest"}, - }), - }, - } - - for _, session := range sessions { - err := db.Create(&session).Error - require.NoError(t, err) - } - - // Logout all sessions that have "admin" group - ctx := context.Background() - err := store.LogoutSessions(ctx, func(c types.Claims) bool { - return slices.Contains(c.Groups, "admin") - }) - assert.NoError(t, err) - - // Verify admin user session was removed - var count int64 - db.Model(&ProxySession{}).Where("session_key LIKE 'test_%'").Count(&count) - assert.Equal(t, int64(2), count) - - // Verify remaining sessions don't have admin group - var remainingSessions []ProxySession - err = db.Where("session_key LIKE 'test_%'").Find(&remainingSessions).Error - assert.NoError(t, err) - - for _, session := range remainingSessions { - var sessionData map[string]any - err := json.Unmarshal([]byte(session.SessionData), &sessionData) - require.NoError(t, err) - claims := sessionData[constants.SessionClaims].(map[string]any) - assert.NotEqual(t, "admin@example.com", claims["email"]) - } -} - -func TestPostgresStore_LoadExpiredSession(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Create an expired session - sessionKey := "test_expired_load" - expiredData := map[string]any{ - constants.SessionClaims: map[string]any{ - "sub": "test-user", - }, - } - expiredDataJSON, _ := json.Marshal(expiredData) - - proxySession := ProxySession{ - UUID: uuid.New(), - SessionKey: sessionKey, - SessionData: string(expiredDataJSON), - Expires: time.Now().Add(-time.Hour), - } - err := db.Create(&proxySession).Error - require.NoError(t, err) - - // Try to load the expired session - session := sessions.NewSession(store, "test_session") - session.ID = sessionKey - err = store.load(context.Background(), session) - - // Should return ErrRecordNotFound because session is expired - assert.Error(t, err) - assert.Equal(t, gorm.ErrRecordNotFound, err) - - // Verify the expired session was deleted - var count int64 - db.Model(&ProxySession{}).Where("session_key = ?", sessionKey).Count(&count) - assert.Equal(t, int64(0), count) -} - -func TestPostgresStore_ConcurrentSessionAccess(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - // Test concurrent access by creating separate sessions for each goroutine - // This tests that the connection pool handles concurrent operations correctly - const numGoroutines = 10 - done := make(chan error, numGoroutines) - - for i := range numGoroutines { - go func(id int) { - // Each goroutine creates its own unique session - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - - session, err := store.New(req, "test_session") - if err != nil { - done <- fmt.Errorf("goroutine %d failed to create session: %w", id, err) - return - } - - // Set some data - session.Values["goroutine_id"] = id - session.Values["timestamp"] = time.Now().Unix() - - // Save session - err = store.Save(req, w, session) - if err != nil { - done <- fmt.Errorf("goroutine %d failed to save: %w", id, err) - return - } - - // Load it back - session2, err := store.New(req, "test_session") - if err != nil { - done <- fmt.Errorf("goroutine %d failed to create session for load: %w", id, err) - return - } - session2.ID = session.ID - err = store.load(context.Background(), session2) - if err != nil { - done <- fmt.Errorf("goroutine %d failed to load: %w", id, err) - return - } - - done <- nil - }(i) - } - - // Wait for all goroutines to complete - for range numGoroutines { - err := <-done - assert.NoError(t, err) - } -} - -func TestBuildDSN_Validation(t *testing.T) { - tests := []struct { - name string - cfg config.PostgreSQLConfig - expectError bool - errorMsg string - }{ - { - name: "missing host", - cfg: config.PostgreSQLConfig{ - Port: "5432", - User: "testuser", - Name: "testdb", - }, - expectError: true, - errorMsg: "PostgreSQL host is required", - }, - { - name: "missing user", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - Name: "testdb", - }, - expectError: true, - errorMsg: "PostgreSQL user is required", - }, - { - name: "missing database name", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - }, - expectError: true, - errorMsg: "PostgreSQL database name is required", - }, - { - name: "invalid port (zero)", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "0", - User: "testuser", - Name: "testdb", - }, - expectError: true, - errorMsg: "PostgreSQL port 0 must be positive", - }, - { - name: "invalid port (negative)", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "-1", - User: "testuser", - Name: "testdb", - }, - expectError: true, - errorMsg: "PostgreSQL port -1 must be positive", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := BuildDSN(tt.cfg) - if tt.expectError { - assert.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - assert.Empty(t, result) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestBuildConnConfig(t *testing.T) { - tests := []struct { - name string - cfg config.PostgreSQLConfig - validate func(*testing.T, *pgx.ConnConfig) - }{ - { - name: "basic configuration", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "localhost", cc.Host) - assert.Equal(t, uint16(5432), cc.Port) - assert.Equal(t, "testuser", cc.User) - assert.Equal(t, "testdb", cc.Database) - assert.Equal(t, "", cc.Password) - }, - }, - { - name: "with simple password", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: "testpass", - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "testpass", cc.Password) - }, - }, - { - name: "with password containing spaces", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: "my secure password", - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "my secure password", cc.Password) - }, - }, - { - name: "with password containing single quotes", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: "pass'word", - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "pass'word", cc.Password) - }, - }, - { - name: "with password containing backslashes", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: `pass\word`, - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, `pass\word`, cc.Password) - }, - }, - { - name: "with password containing special characters", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: `p@ss w0rd!#$%^&*()`, - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, `p@ss w0rd!#$%^&*()`, cc.Password) - }, - }, - { - name: "with password containing quotes and backslashes", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: `my'pass\word"here`, - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, `my'pass\word"here`, cc.Password) - }, - }, - { - name: "with passphrase (multiple spaces)", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: "the quick brown fox jumps over", - Name: "testdb", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "the quick brown fox jumps over", cc.Password) - }, - }, - { - name: "with sslmode=disable", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - SSLMode: "disable", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Nil(t, cc.TLSConfig) - }, - }, - { - name: "with sslmode=require (no certs)", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - SSLMode: "require", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.NotNil(t, cc.TLSConfig) - assert.True(t, cc.TLSConfig.InsecureSkipVerify) - }, - }, - { - name: "with custom schema", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - DefaultSchema: "custom_schema", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.NotNil(t, cc.AfterConnect) - }, - }, - { - name: "with connection options", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":"10","application_name":"authentik"}`)), - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, 10*time.Second, cc.ConnectTimeout) - assert.Equal(t, "authentik", cc.RuntimeParams["application_name"]) - }, - }, - { - name: "with target_session_attrs", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write"}`)), - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - // target_session_attrs should NOT be in RuntimeParams - _, hasTargetSessionAttrs := cc.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not appear in RuntimeParams") - // It should set ValidateConnect instead - assert.NotNil(t, cc.ValidateConnect, "ValidateConnect should be set for target_session_attrs") - // Verify it's the correct validator function - expectedValidator := pgconn.ValidateConnectTargetSessionAttrsReadWrite - assert.Equal(t, runtime.FuncForPC(reflect.ValueOf(expectedValidator).Pointer()).Name(), - runtime.FuncForPC(reflect.ValueOf(cc.ValidateConnect).Pointer()).Name()) - }, - }, - { - name: "full configuration with special password", - cfg: config.PostgreSQLConfig{ - Host: "db.example.com", - Port: "5433", - User: "admin", - Password: "my super secret password!@#", - Name: "production", - SSLMode: "require", - DefaultSchema: "app_schema", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"application_name":"authentik"}`)), - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "db.example.com", cc.Host) - assert.Equal(t, uint16(5433), cc.Port) - assert.Equal(t, "admin", cc.User) - assert.Equal(t, "my super secret password!@#", cc.Password) - assert.Equal(t, "production", cc.Database) - assert.NotNil(t, cc.AfterConnect) - assert.Equal(t, "authentik", cc.RuntimeParams["application_name"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := BuildConnConfig(tt.cfg) - require.NoError(t, err) - require.NotNil(t, result) - tt.validate(t, result) - }) - } -} - -// TestBuildConnConfig_WithSSLCertificates tests SSL certificate configuration -func TestBuildConnConfig_WithSSLCertificates(t *testing.T) { - rootCertPath, clientCertPath, clientKeyPath, cleanup := generateTestCerts(t) - defer cleanup() - - tests := []struct { - name string - cfg config.PostgreSQLConfig - validate func(*testing.T, *pgx.ConnConfig) - }{ - { - name: "verify-full with all certificates", - cfg: config.PostgreSQLConfig{ - Host: "db.example.com", - Port: "5432", - User: "testuser", - Password: "my secure password", - Name: "testdb", - SSLMode: "verify-full", - SSLRootCert: rootCertPath, - SSLCert: clientCertPath, - SSLKey: clientKeyPath, - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - require.NotNil(t, cc.TLSConfig) - assert.False(t, cc.TLSConfig.InsecureSkipVerify) - assert.Equal(t, "db.example.com", cc.TLSConfig.ServerName) - assert.NotNil(t, cc.TLSConfig.RootCAs) - assert.Len(t, cc.TLSConfig.Certificates, 1) - }, - }, - { - name: "verify-ca with root cert only", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - SSLMode: "verify-ca", - SSLRootCert: rootCertPath, - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - require.NotNil(t, cc.TLSConfig) - assert.False(t, cc.TLSConfig.InsecureSkipVerify) - assert.NotNil(t, cc.TLSConfig.RootCAs) - assert.Empty(t, cc.TLSConfig.Certificates) - }, - }, - { - name: "require with client cert", - cfg: config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - SSLMode: "require", - SSLCert: clientCertPath, - SSLKey: clientKeyPath, - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - require.NotNil(t, cc.TLSConfig) - assert.True(t, cc.TLSConfig.InsecureSkipVerify) - assert.Len(t, cc.TLSConfig.Certificates, 1) - }, - }, - { - name: "full configuration with SSL and special password", - cfg: config.PostgreSQLConfig{ - Host: "db.example.com", - Port: "5433", - User: "admin", - Password: "my super secret password!@#", - Name: "production", - SSLMode: "verify-full", - SSLRootCert: rootCertPath, - SSLCert: clientCertPath, - SSLKey: clientKeyPath, - DefaultSchema: "app_schema", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"application_name":"authentik"}`)), - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "db.example.com", cc.Host) - assert.Equal(t, uint16(5433), cc.Port) - assert.Equal(t, "admin", cc.User) - assert.Equal(t, "my super secret password!@#", cc.Password) - assert.Equal(t, "production", cc.Database) - require.NotNil(t, cc.TLSConfig) - assert.False(t, cc.TLSConfig.InsecureSkipVerify) - assert.Equal(t, "db.example.com", cc.TLSConfig.ServerName) - assert.NotNil(t, cc.TLSConfig.RootCAs) - assert.Len(t, cc.TLSConfig.Certificates, 1) - assert.NotNil(t, cc.AfterConnect) - assert.Equal(t, "authentik", cc.RuntimeParams["application_name"]) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := BuildConnConfig(tt.cfg) - require.NoError(t, err) - require.NotNil(t, result) - tt.validate(t, result) - }) - } -} - -// TestBuildDSN_WithSpecialPasswords tests that BuildDSN can handle passwords with special characters -// by verifying the DSN can actually be used to connect to a database -func TestBuildDSN_WithSpecialPasswords(t *testing.T) { - tests := []struct { - name string - password string - }{ - {"space in password", "my password"}, - {"multiple spaces", "the quick brown fox"}, - {"single quote", "pass'word"}, - {"backslash", `pass\word`}, - {"double quote", `pass"word`}, - {"special chars", `p@ss!#$%^&*()`}, - {"mixed special", `my'pass\word"here`}, - {"unicode", "pässwörd"}, - {"leading/trailing spaces", " password "}, - {"tab character", "pass\tword"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Password: tt.password, - Name: "testdb", - } - - // Test that BuildDSN doesn't error - dsn, err := BuildDSN(cfg) - require.NoError(t, err) - require.NotEmpty(t, dsn) - - // Test that BuildConnConfig preserves the password exactly - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - assert.Equal(t, tt.password, connConfig.Password, "Password should be preserved exactly") - }) - } -} - -func TestPostgresStore_ConnectionPoolSettings(t *testing.T) { - db, pool := SetupTestDB(t) - defer CleanupTestDB(t, db, pool) - - store := NewTestStore(db, pool) - sqlDB := pool.GetDB() - require.NotNil(t, sqlDB) - - // Verify connection pool is configured - stats := sqlDB.Stats() - assert.GreaterOrEqual(t, stats.MaxOpenConnections, 1, "Connection pool should be configured") - - // Test that we can create multiple sessions concurrently - // This indirectly tests connection pool handling - const numConcurrentOps = 20 - done := make(chan error, numConcurrentOps) - - for i := range numConcurrentOps { - go func(id int) { - req := httptest.NewRequest("GET", "/", nil) - w := httptest.NewRecorder() - - session, err := store.New(req, "test_session") - if err != nil { - done <- err - return - } - - session.Values["test"] = id - err = store.Save(req, w, session) - done <- err - }(i) - } - - // Collect results - for i := range numConcurrentOps { - err := <-done - assert.NoError(t, err, "Concurrent operation %d should succeed", i) - } -} - -// TestParseConnOptions tests the base64 JSON parsing of connection options -func TestParseConnOptions(t *testing.T) { - tests := []struct { - name string - input string - expected map[string]string - expectError bool - errorMsg string - }{ - { - name: "simple key-value", - input: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write"}`)), - expected: map[string]string{"target_session_attrs": "read-write"}, - }, - { - name: "multiple options", - input: base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":"10","application_name":"authentik"}`)), - expected: map[string]string{"connect_timeout": "10", "application_name": "authentik"}, - }, - { - name: "numeric value as number", - input: base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":10}`)), - expected: map[string]string{"connect_timeout": "10"}, - }, - { - name: "boolean value", - input: base64.StdEncoding.EncodeToString([]byte(`{"default_transaction_read_only":true}`)), - expected: map[string]string{"default_transaction_read_only": "true"}, - }, - { - name: "empty object", - input: base64.StdEncoding.EncodeToString([]byte(`{}`)), - expected: map[string]string{}, - }, - { - name: "invalid base64", - input: "not-valid-base64!!!", - expectError: true, - errorMsg: "invalid base64 encoding", - }, - { - name: "invalid JSON", - input: base64.StdEncoding.EncodeToString([]byte(`not json`)), - expectError: true, - errorMsg: "invalid JSON", - }, - { - name: "JSON array instead of object", - input: base64.StdEncoding.EncodeToString([]byte(`["value1", "value2"]`)), - expectError: true, - errorMsg: "invalid JSON", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseConnOptions(tt.input) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - require.NoError(t, err) - assert.Equal(t, tt.expected, result) - } - }) - } -} - -// TestApplyConnOptions tests that connection options are applied correctly to pgx.ConnConfig -func TestApplyConnOptions(t *testing.T) { - tests := []struct { - name string - opts map[string]string - validate func(*testing.T, *pgx.ConnConfig) - expectError bool - errorMsg string - }{ - { - name: "connect_timeout sets ConnectTimeout", - opts: map[string]string{"connect_timeout": "30"}, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, 30*time.Second, cc.ConnectTimeout) - }, - }, - { - name: "target_session_attrs sets ValidateConnect", - opts: map[string]string{"target_session_attrs": "read-write"}, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - // target_session_attrs should NOT be in RuntimeParams - _, hasTargetSessionAttrs := cc.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not be in RuntimeParams") - // It should set ValidateConnect instead - assert.NotNil(t, cc.ValidateConnect, "ValidateConnect should be set") - expectedValidator := pgconn.ValidateConnectTargetSessionAttrsReadWrite - assert.Equal(t, runtime.FuncForPC(reflect.ValueOf(expectedValidator).Pointer()).Name(), - runtime.FuncForPC(reflect.ValueOf(cc.ValidateConnect).Pointer()).Name()) - }, - }, - { - name: "application_name goes to RuntimeParams", - opts: map[string]string{"application_name": "my-app"}, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "my-app", cc.RuntimeParams["application_name"]) - }, - }, - { - name: "statement_timeout goes to RuntimeParams", - opts: map[string]string{"statement_timeout": "5000"}, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "5000", cc.RuntimeParams["statement_timeout"]) - }, - }, - { - name: "unknown options go to RuntimeParams", - opts: map[string]string{"custom_param": "custom_value"}, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, "custom_value", cc.RuntimeParams["custom_param"]) - }, - }, - { - name: "multiple options", - opts: map[string]string{ - "connect_timeout": "10", - "target_session_attrs": "read-write", - "application_name": "authentik", - }, - validate: func(t *testing.T, cc *pgx.ConnConfig) { - assert.Equal(t, 10*time.Second, cc.ConnectTimeout) - // target_session_attrs should NOT be in RuntimeParams - _, hasTargetSessionAttrs := cc.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not be in RuntimeParams") - // It should set ValidateConnect instead - assert.NotNil(t, cc.ValidateConnect, "ValidateConnect should be set") - assert.Equal(t, "authentik", cc.RuntimeParams["application_name"]) - }, - }, - { - name: "invalid connect_timeout", - opts: map[string]string{"connect_timeout": "not-a-number"}, - expectError: true, - errorMsg: "invalid connect_timeout value", - }, - { - name: "invalid target_session_attrs", - opts: map[string]string{"target_session_attrs": "invalid-mode"}, - expectError: true, - errorMsg: "unknown target_session_attrs value", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a base config - connConfig, err := pgx.ParseConfig("") - require.NoError(t, err) - connConfig.RuntimeParams = make(map[string]string) - - err = applyConnOptions(connConfig, tt.opts) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorMsg) - } else { - require.NoError(t, err) - tt.validate(t, connConfig) - } - }) - } -} - -// TestBuildConnConfig_Base64JSONConnOptions tests the full integration of base64 JSON connection options -func TestBuildConnConfig_Base64JSONConnOptions(t *testing.T) { - t.Run("bug report scenario - target_session_attrs", func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - ConnOptions: "eyJ0YXJnZXRfc2Vzc2lvbl9hdHRycyI6InJlYWQtd3JpdGUifQ==", - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - // target_session_attrs should NOT be in RuntimeParams - _, hasTargetSessionAttrs := connConfig.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not appear in RuntimeParams") - // It should set ValidateConnect instead - assert.NotNil(t, connConfig.ValidateConnect, "ValidateConnect should be set") - expectedValidator := pgconn.ValidateConnectTargetSessionAttrsReadWrite - assert.Equal(t, runtime.FuncForPC(reflect.ValueOf(expectedValidator).Pointer()).Name(), - runtime.FuncForPC(reflect.ValueOf(connConfig.ValidateConnect).Pointer()).Name()) - }) - - t.Run("complex connection options", func(t *testing.T) { - // {"connect_timeout":10,"target_session_attrs":"read-write","application_name":"authentik-proxy"} - connOpts := base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":10,"target_session_attrs":"read-write","application_name":"authentik-proxy"}`)) - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - ConnOptions: connOpts, - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - assert.Equal(t, 10*time.Second, connConfig.ConnectTimeout) - // target_session_attrs should NOT be in RuntimeParams - _, hasTargetSessionAttrs := connConfig.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not appear in RuntimeParams") - // It should set ValidateConnect instead - assert.NotNil(t, connConfig.ValidateConnect, "ValidateConnect should be set") - assert.Equal(t, "authentik-proxy", connConfig.RuntimeParams["application_name"]) - }) -} - -// Helper function to create session data JSON -func createSessionData(t *testing.T, claims map[string]any) string { - sessionData := map[string]any{ - constants.SessionClaims: claims, - } - sessionDataJSON, err := json.Marshal(sessionData) - require.NoError(t, err) - return string(sessionDataJSON) -} - -// generateTestCerts creates temporary SSL certificates for testing -func generateTestCerts(t *testing.T) (rootCertPath, clientCertPath, clientKeyPath string, cleanup func()) { - tmpDir := t.TempDir() - - // Generate CA certificate - caKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - caTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Test CA"}, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - BasicConstraintsValid: true, - IsCA: true, - } - - caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) - require.NoError(t, err) - - // Write CA certificate - rootCertPath = filepath.Join(tmpDir, "root.crt") - rootCertFile, err := os.Create(rootCertPath) - require.NoError(t, err) - defer func() { - if closeErr := rootCertFile.Close(); closeErr != nil { - t.Logf("failed to close root cert file: %v", closeErr) - } - }() - err = pem.Encode(rootCertFile, &pem.Block{Type: "CERTIFICATE", Bytes: caCertDER}) - require.NoError(t, err) - - // Generate client key - clientKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - // Generate client certificate - clientTemplate := &x509.Certificate{ - SerialNumber: big.NewInt(2), - Subject: pkix.Name{ - Organization: []string{"Test Client"}, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - } - - clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caTemplate, &clientKey.PublicKey, caKey) - require.NoError(t, err) - - // Write client certificate - clientCertPath = filepath.Join(tmpDir, "client.crt") - clientCertFile, err := os.Create(clientCertPath) - require.NoError(t, err) - defer func() { - if closeErr := clientCertFile.Close(); closeErr != nil { - t.Logf("failed to close client cert file: %v", closeErr) - } - }() - err = pem.Encode(clientCertFile, &pem.Block{Type: "CERTIFICATE", Bytes: clientCertDER}) - require.NoError(t, err) - - // Write client key - clientKeyPath = filepath.Join(tmpDir, "client.key") - clientKeyFile, err := os.Create(clientKeyPath) - require.NoError(t, err) - defer func() { - if closeErr := clientKeyFile.Close(); closeErr != nil { - t.Logf("failed to close client key file: %v", closeErr) - } - }() - clientKeyBytes := x509.MarshalPKCS1PrivateKey(clientKey) - err = pem.Encode(clientKeyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: clientKeyBytes}) - require.NoError(t, err) - - cleanup = func() { - // TempDir cleanup is automatic in Go tests - } - - return rootCertPath, clientCertPath, clientKeyPath, cleanup -} - -// TestBuildConnConfig_WithBase64EncodedConnOptions demonstrates that ConnOptions -// should be base64-encoded JSON but is currently being parsed as key=value pairs -func TestBuildConnConfig_WithBase64EncodedConnOptions(t *testing.T) { - tests := []struct { - name string - connOptions string - expected map[string]string - expectError bool - }{ - { - name: "base64 encoded JSON with single parameter", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":"10"}`)), - expected: map[string]string{ - // connect_timeout is handled specially and NOT added to RuntimeParams - }, - }, - { - name: "base64 encoded JSON with multiple parameters", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"connect_timeout":"10","application_name":"authentik","statement_timeout":"30000"}`)), - expected: map[string]string{ - // connect_timeout is handled specially and NOT added to RuntimeParams - "application_name": "authentik", - "statement_timeout": "30000", - }, - }, - { - name: "base64 encoded JSON with special characters in values", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"application_name":"authentik proxy v2"}`)), - expected: map[string]string{ - "application_name": "authentik proxy v2", - }, - }, - { - name: "base64 encoded JSON with target_session_attrs", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write","application_name":"authentik"}`)), - expected: map[string]string{ - "application_name": "authentik", - // target_session_attrs should NOT appear in RuntimeParams - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - ConnOptions: tt.connOptions, - } - - result, err := BuildConnConfig(cfg) - - if tt.expectError { - assert.Error(t, err) - return - } - - require.NoError(t, err) - require.NotNil(t, result) - - // Verify that all expected parameters are present in RuntimeParams - for key, expectedValue := range tt.expected { - actualValue, exists := result.RuntimeParams[key] - assert.True(t, exists, "Expected runtime parameter %s to exist", key) - assert.Equal(t, expectedValue, actualValue, "Runtime parameter %s should have value %s", key, expectedValue) - } - - // Verify that connect_timeout is handled specially (sets ConnectTimeout field, not RuntimeParams) - if tt.name == "base64 encoded JSON with single parameter" || tt.name == "base64 encoded JSON with multiple parameters" { - _, hasConnectTimeout := result.RuntimeParams["connect_timeout"] - assert.False(t, hasConnectTimeout, "connect_timeout should not appear in RuntimeParams") - assert.Equal(t, 10*time.Second, result.ConnectTimeout, "connect_timeout should be set as ConnectTimeout duration") - } - - // Verify that target_session_attrs is NOT in RuntimeParams - // (it affects connection behavior, not a runtime param) - _, hasTargetSessionAttrs := result.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, "target_session_attrs should not appear in RuntimeParams") - }) - } -} - -// Verifies DefaultSchema is applied via AfterConnect and never via startup RuntimeParams. -func TestBuildConnConfig_SearchPath_DefaultSchema(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - DefaultSchema: "default_schema", - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, connConfig.AfterConnect) - _, hasSearchPath := connConfig.RuntimeParams["search_path"] - assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams") -} - -// Verifies ConnOptions search_path is ignored and excluded from startup RuntimeParams. -func TestBuildConnConfig_SearchPath_ConnOptions(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"search_path":"connopt_schema"}`)), - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - assert.Nil(t, connConfig.AfterConnect) - _, hasSearchPath := connConfig.RuntimeParams["search_path"] - assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams") -} - -// Verifies ConnOptions search_path does not override DefaultSchema and other conn options still apply. -func TestBuildConnConfig_SearchPath_ConnOptionsIgnoredWhenDefaultSchemaSet(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - DefaultSchema: "default_schema", - ConnOptions: base64.StdEncoding.EncodeToString([]byte(`{"search_path":"override_schema","application_name":"authentik-proxy"}`)), - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, connConfig.AfterConnect) - assert.Equal(t, "authentik-proxy", connConfig.RuntimeParams["application_name"]) - _, hasSearchPath := connConfig.RuntimeParams["search_path"] - assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams") -} - -// Verifies inherited search_path from pgx/libpq defaults is removed from startup RuntimeParams. -func TestBuildConnConfig_SearchPath_InheritedServiceSetting(t *testing.T) { - serviceFile := filepath.Join(t.TempDir(), "pg_service.conf") - err := os.WriteFile(serviceFile, []byte("[authentik-test]\nsearch_path=service_schema\n"), 0o600) - require.NoError(t, err) - - t.Setenv("PGSERVICE", "authentik-test") - t.Setenv("PGSERVICEFILE", serviceFile) - - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "authentik", - Name: "authentik", - } - - connConfig, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, connConfig.AfterConnect) - - _, hasSearchPath := connConfig.RuntimeParams["search_path"] - assert.False(t, hasSearchPath, "search_path should not appear in RuntimeParams") -} - -// TestBuildConnConfig_TargetSessionAttrs demonstrates how target_session_attrs -// should be properly handled using pgx's ValidateConnect callback -func TestBuildConnConfig_TargetSessionAttrs(t *testing.T) { - tests := []struct { - name string - connOptions string - targetSessionAttrs string - expectedValidator pgconn.ValidateConnectFunc - validatorDescription string - }{ - { - name: "target_session_attrs=read-write", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write"}`)), - targetSessionAttrs: "read-write", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadWrite, - validatorDescription: "should validate connection is read-write by checking transaction_read_only=off", - }, - { - name: "target_session_attrs=read-only", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-only"}`)), - targetSessionAttrs: "read-only", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadOnly, - validatorDescription: "should validate connection is read-only by checking transaction_read_only=on", - }, - { - name: "target_session_attrs=primary", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"primary"}`)), - targetSessionAttrs: "primary", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsPrimary, - validatorDescription: "should validate connection is to primary by checking in_hot_standby=off", - }, - { - name: "target_session_attrs=standby", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"standby"}`)), - targetSessionAttrs: "standby", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsStandby, - validatorDescription: "should validate connection is to standby by checking in_hot_standby=on", - }, - { - name: "target_session_attrs=prefer-standby", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"prefer-standby"}`)), - targetSessionAttrs: "prefer-standby", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsPreferStandby, - validatorDescription: "should prefer standby connections (affects fallback logic)", - }, - { - name: "target_session_attrs=any (default)", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"any"}`)), - targetSessionAttrs: "any", - expectedValidator: nil, - validatorDescription: "should not set validator as any connection is acceptable", - }, - { - name: "no target_session_attrs", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"application_name":"authentik"}`)), - targetSessionAttrs: "", - expectedValidator: nil, - validatorDescription: "should not set validator when target_session_attrs is not specified", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "localhost", - Port: "5432", - User: "testuser", - Name: "testdb", - ConnOptions: tt.connOptions, - } - - result, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, result) - - // Verify target_session_attrs is NOT in RuntimeParams - _, hasTargetSessionAttrs := result.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, - "target_session_attrs should not appear in RuntimeParams") - - // Verify ValidateConnect callback is set to the correct standard pgx function - if tt.expectedValidator != nil { - require.NotNil(t, result.ValidateConnect, - "ValidateConnect should be set for target_session_attrs=%s: %s", - tt.targetSessionAttrs, tt.validatorDescription) - - // Compare function pointers using reflect to check if it's the same function - actualFuncPtr := runtime.FuncForPC(reflect.ValueOf(result.ValidateConnect).Pointer()) - expectedFuncPtr := runtime.FuncForPC(reflect.ValueOf(tt.expectedValidator).Pointer()) - - assert.Equal(t, expectedFuncPtr.Name(), actualFuncPtr.Name(), - "ValidateConnect should be set to %s for target_session_attrs=%s", - expectedFuncPtr.Name(), tt.targetSessionAttrs) - - t.Logf("Expected validator: %s", expectedFuncPtr.Name()) - t.Logf("Actual validator: %s", actualFuncPtr.Name()) - } else { - assert.Nil(t, result.ValidateConnect, - "ValidateConnect should not be set: %s", tt.validatorDescription) - } - }) - } -} - -// TestBuildConnConfig_TargetSessionAttrs_WithMultipleHosts tests that when multiple -// hosts are specified, fallbacks are properly configured along with the validator -func TestBuildConnConfig_TargetSessionAttrs_WithMultipleHosts(t *testing.T) { - tests := []struct { - name string - host string - port string - sslMode string - connOptions string - targetSessionAttrs string - expectedValidator pgconn.ValidateConnectFunc - expectedPrimaryHost string - expectedPrimaryPort uint16 - expectedFallbacks []*pgconn.FallbackConfig - expectTLS bool - validatorDescription string - }{ - { - name: "multiple hosts with read-write", - host: "db1.local,db2.local,db3.local", - port: "5432", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write"}`)), - targetSessionAttrs: "read-write", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadWrite, - expectedPrimaryHost: "db1.local", - expectedPrimaryPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{ - {Host: "db2.local", Port: 5432, TLSConfig: nil}, - {Host: "db3.local", Port: 5432, TLSConfig: nil}, - }, - expectTLS: false, - validatorDescription: "should set validator and create fallbacks for additional hosts", - }, - { - name: "multiple hosts with ports specified", - host: "db1.local,db2.local,db3.local", - port: "5432,5433,5434", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write"}`)), - targetSessionAttrs: "read-write", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadWrite, - expectedPrimaryHost: "db1.local", - expectedPrimaryPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{ - {Host: "db2.local", Port: 5433, TLSConfig: nil}, - {Host: "db3.local", Port: 5434, TLSConfig: nil}, - }, - expectTLS: false, - validatorDescription: "should handle hosts with explicit ports", - }, - { - name: "multiple hosts with TLS required", - host: "db1.local,db2.local,db3.local", - port: "5432", - sslMode: "require", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write", "sslmode":"require"}`)), - targetSessionAttrs: "read-write", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadWrite, - expectedPrimaryHost: "db1.local", - expectedPrimaryPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{ - {Host: "db2.local", Port: 5432}, // TLSConfig should be set (non-nil) - {Host: "db3.local", Port: 5432}, // TLSConfig should be set (non-nil) - }, - expectTLS: true, - validatorDescription: "should set TLS config for all hosts when sslmode=require", - }, - { - name: "multiple hosts with TLS verify-full", - host: "db1.local,db2.local,db3.local", - port: "5432", - sslMode: "require", - connOptions: base64.StdEncoding.EncodeToString([]byte(`{"target_session_attrs":"read-write", "sslmode":"verify-full"}`)), - targetSessionAttrs: "read-write", - expectedValidator: pgconn.ValidateConnectTargetSessionAttrsReadWrite, - expectedPrimaryHost: "db1.local", - expectedPrimaryPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{ - {Host: "db2.local", Port: 5432}, // TLSConfig should be set (non-nil) - {Host: "db3.local", Port: 5432}, // TLSConfig should be set (non-nil) - }, - expectTLS: true, - validatorDescription: "should set TLS config host name for all hosts when sslmode=verify-full", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: tt.host, - Port: tt.port, - User: "testuser", - Name: "testdb", - SSLMode: tt.sslMode, - ConnOptions: tt.connOptions, - } - - result, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, result) - - // Verify target_session_attrs is NOT in RuntimeParams - _, hasTargetSessionAttrs := result.RuntimeParams["target_session_attrs"] - assert.False(t, hasTargetSessionAttrs, - "target_session_attrs should not appear in RuntimeParams") - - // Verify ValidateConnect is set to the correct function - require.NotNil(t, result.ValidateConnect, - "ValidateConnect should be set for target_session_attrs=%s with multiple hosts", - tt.targetSessionAttrs) - - actualFuncPtr := runtime.FuncForPC(reflect.ValueOf(result.ValidateConnect).Pointer()) - expectedFuncPtr := runtime.FuncForPC(reflect.ValueOf(tt.expectedValidator).Pointer()) - - assert.Equal(t, expectedFuncPtr.Name(), actualFuncPtr.Name(), - "ValidateConnect should be %s for target_session_attrs=%s", - expectedFuncPtr.Name(), tt.targetSessionAttrs) - - // Verify the primary host and port - assert.Equal(t, tt.expectedPrimaryHost, result.Host, - "Primary host should be %s", tt.expectedPrimaryHost) - assert.Equal(t, tt.expectedPrimaryPort, result.Port, - "Primary port should be %d", tt.expectedPrimaryPort) - - // Verify primary TLSConfig based on sslmode - if tt.expectTLS { - assert.NotNil(t, result.TLSConfig, - "Primary connection should have TLSConfig set when sslmode=%s", tt.sslMode) - } else { - assert.Nil(t, result.TLSConfig, - "Primary connection should not have TLSConfig when sslmode is not set") - } - - // Verify Fallbacks are configured for the additional hosts - require.Len(t, result.Fallbacks, len(tt.expectedFallbacks), - "Should have %d fallback configs for the additional hosts", len(tt.expectedFallbacks)) - - // Verify each fallback configuration - for i, expectedFb := range tt.expectedFallbacks { - actualFb := result.Fallbacks[i] - - assert.Equal(t, expectedFb.Host, actualFb.Host, - "Fallback %d host should be %s", i+1, expectedFb.Host) - assert.Equal(t, expectedFb.Port, actualFb.Port, - "Fallback %d port should be %d", i+1, expectedFb.Port) - - // Verify TLSConfig is set appropriately for fallbacks - if tt.expectTLS { - assert.NotNil(t, actualFb.TLSConfig, - "Fallback %d should have TLSConfig set when sslmode=%s", i+1, tt.sslMode) - // Verify InsecureSkipVerify for sslmode=require - switch tt.sslMode { - case "require": - assert.True(t, actualFb.TLSConfig.InsecureSkipVerify, - "Fallback %d TLSConfig should have InsecureSkipVerify=true for sslmode=require", i+1) - case "verify-full": - assert.False(t, actualFb.TLSConfig.InsecureSkipVerify, - "Fallback %d TLSConfig should have InsecureSkipVerify=false for sslmode=verify-full", i+1) - assert.Equal(t, actualFb.Host, actualFb.TLSConfig.ServerName, - "Fallback %d TLSConfig ServerName should match host for sslmode=verify-full", i+1) - } - } else { - assert.Nil(t, actualFb.TLSConfig, - "Fallback %d should not have TLSConfig when sslmode is not set", i+1) - } - } - - // Log the configuration for debugging - t.Logf("Primary host: %s:%d", result.Host, result.Port) - t.Logf("Validator: %s", actualFuncPtr.Name()) - for i, fb := range result.Fallbacks { - t.Logf("Fallback %d: %s:%d", i+1, fb.Host, fb.Port) - } - }) - } -} - -// TestBuildConnConfig_MultipleHosts_WithoutTargetSessionAttrs tests that multiple hosts -// create fallbacks even without target_session_attrs -func TestBuildConnConfig_MultipleHosts_WithoutTargetSessionAttrs(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: "db1.local,db2.local,db3.local", - Port: "5432", - User: "testuser", - Name: "testdb", - } - - result, err := BuildConnConfig(cfg) - require.NoError(t, err) - require.NotNil(t, result) - - // Verify primary host - assert.Equal(t, "db1.local", result.Host) - assert.Equal(t, uint16(5432), result.Port) - - // Verify fallbacks are created - require.Len(t, result.Fallbacks, 2, "Should have 2 fallback configs") - assert.Equal(t, "db2.local", result.Fallbacks[0].Host) - assert.Equal(t, uint16(5432), result.Fallbacks[0].Port) - assert.Equal(t, "db3.local", result.Fallbacks[1].Host) - assert.Equal(t, uint16(5432), result.Fallbacks[1].Port) - - // Verify no ValidateConnect is set (no target_session_attrs) - assert.Nil(t, result.ValidateConnect) -} - -// TestBuildConnConfig_CommaSeparatedPorts_EdgeCases tests edge cases and error scenarios for comma-separated ports -func TestBuildConnConfig_CommaSeparatedPorts_EdgeCases(t *testing.T) { - tests := []struct { - name string - host string - port string - expectError bool - errorContains string - expectedHost string - expectedPort uint16 - expectedFallbacks []*pgconn.FallbackConfig - }{ - { - name: "invalid port in comma-separated list", - host: "db1.local,db2.local", - port: "5432,abc", - expectError: true, - errorContains: "invalid port value", - }, - { - name: "port out of range (too high)", - host: "db1.local,db2.local", - port: "5432,99999", - expectError: true, - errorContains: "PostgreSQL port 99999 is out of valid range", - }, - { - name: "port out of range (zero)", - host: "db1.local,db2.local", - port: "5432,0", - expectError: true, - errorContains: "PostgreSQL port 0 must be positive", - }, - { - name: "empty port string", - host: "db1.local", - port: "", - expectError: true, - errorContains: "PostgreSQL port is required", - }, - { - name: "port with only whitespace", - host: "db1.local", - port: " ", - expectError: true, - errorContains: "invalid port value", - }, - { - name: "mismatched number of hosts and ports", - host: "db1.local,db2.local", - port: "5432", - expectError: false, - expectedHost: "db1.local", - expectedPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{ - {Host: "db2.local", Port: 5432}, - }, - }, - { - name: "extra ports than hosts", - host: "db1.local", - port: "5432,5433", - expectError: false, - expectedHost: "db1.local", - expectedPort: 5432, - expectedFallbacks: []*pgconn.FallbackConfig{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := config.PostgreSQLConfig{ - Host: tt.host, - Port: tt.port, - User: "testuser", - Name: "testdb", - } - - c, err := BuildConnConfig(cfg) - if tt.expectError { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.errorContains) - } else { - require.NoError(t, err) - require.NotNil(t, c) - - assert.Equal(t, tt.expectedHost, c.Host) - assert.Equal(t, tt.expectedPort, c.Port) - require.Len(t, c.Fallbacks, len(tt.expectedFallbacks)) - for i, expectedFb := range tt.expectedFallbacks { - actualFb := c.Fallbacks[i] - assert.Equal(t, expectedFb.Host, actualFb.Host) - assert.Equal(t, expectedFb.Port, actualFb.Port) - } - } - }) - } -} diff --git a/internal/outpost/proxyv2/proxyv2.go b/internal/outpost/proxyv2/proxyv2.go deleted file mode 100644 index eda14d974ec0..000000000000 --- a/internal/outpost/proxyv2/proxyv2.go +++ /dev/null @@ -1,229 +0,0 @@ -package proxyv2 - -import ( - "context" - "crypto/tls" - "errors" - "net" - "net/http" - "strings" - "sync" - - sentryhttp "github.com/getsentry/sentry-go/http" - "github.com/gorilla/mux" - "github.com/pires/go-proxyproto" - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/config" - "goauthentik.io/internal/crypto" - "goauthentik.io/internal/outpost/ak" - "goauthentik.io/internal/outpost/proxyv2/application" - "goauthentik.io/internal/utils" - sentryutils "goauthentik.io/internal/utils/sentry" - "goauthentik.io/internal/utils/web" - api "goauthentik.io/packages/client-go" -) - -type ProxyServer struct { - defaultCert tls.Certificate - stop chan struct{} // channel for waiting shutdown - - cryptoStore *ak.CryptoStore - apps map[string]*application.Application - log *log.Entry - mux *mux.Router - akAPI *ak.APIController -} - -func NewProxyServer(ac *ak.APIController) ak.Outpost { - l := log.WithField("logger", "authentik.outpost.proxyv2") - defaultCert, err := crypto.GenerateSelfSignedCert() - if err != nil { - l.Fatal(err) - } - - rootMux := mux.NewRouter() - rootMux.Use(func(h http.Handler) http.Handler { - return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - h.ServeHTTP(rw, r) - rw.Header().Set("X-Powered-By", "authentik_proxy2") - }) - }) - - globalMux := rootMux.NewRoute().Subrouter() - globalMux.Use(web.NewLoggingHandler(l.WithField("logger", "authentik.outpost.proxyv2.http"), nil)) - if ac.GlobalConfig.ErrorReporting.Enabled { - globalMux.Use(sentryhttp.New(sentryhttp.Options{}).Handle) - } - if ac.IsEmbedded() { - l.Info("using PostgreSQL session backend") - } else { - l.Info("using filesystem session backend") - } - s := &ProxyServer{ - cryptoStore: ak.NewCryptoStore(ac.Client.CryptoAPI), - apps: make(map[string]*application.Application), - log: l, - mux: rootMux, - akAPI: ac, - defaultCert: defaultCert, - } - globalMux.PathPrefix("/outpost.goauthentik.io/static").HandlerFunc(s.HandleStatic) - globalMux.Path("/outpost.goauthentik.io/ping").HandlerFunc(sentryutils.SentryNoSample(s.HandlePing)) - rootMux.PathPrefix("/").HandlerFunc(s.Handle) - ac.AddEventHandler(s.handleWSMessage) - return s -} - -func (ps *ProxyServer) HandleHost(rw http.ResponseWriter, r *http.Request) bool { - // Always handle requests for outpost paths that should answer regardless of hostname - if strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/ping") || - strings.HasPrefix(r.URL.Path, "/outpost.goauthentik.io/static") { - ps.mux.ServeHTTP(rw, r) - return true - } - // lookup app by hostname - a, _ := ps.lookupApp(r) - if a == nil { - return false - } - // check if the app should handle this URL, or is setup in proxy mode - if a.ShouldHandleURL(r) || a.Mode() == api.PROXYMODE_PROXY { - ps.mux.ServeHTTP(rw, r) - return true - } - return false -} - -func (ps *ProxyServer) Type() string { - return "proxy" -} - -func (ps *ProxyServer) TimerFlowCacheExpiry(context.Context) {} - -func (ps *ProxyServer) GetCertificate(serverName string) *tls.Certificate { - app, ok := ps.apps[serverName] - if !ok { - ps.log.WithField("server-name", serverName).Debug("failed to get certificate for ServerName") - return nil - } - if app.Cert == nil { - ps.log.WithField("server-name", serverName).Debug("app does not have a certificate") - return nil - } - return app.Cert -} - -func (ps *ProxyServer) getCertificates(info *tls.ClientHelloInfo) (*tls.Certificate, error) { - sn := info.ServerName - if sn == "" { - return &ps.defaultCert, nil - } - appCert := ps.GetCertificate(sn) - if appCert == nil { - return &ps.defaultCert, nil - } - return appCert, nil -} - -// ServeHTTP constructs a net.Listener and starts handling HTTP requests -func (ps *ProxyServer) ServeHTTP(listen string) { - listener, err := net.Listen("tcp", listen) - if err != nil { - ps.log.WithField("listen", listen).WithError(err).Warning("Failed to listen") - return - } - proxyListener := &proxyproto.Listener{Listener: listener, ConnPolicy: utils.GetProxyConnectionPolicy()} - defer func() { - err := proxyListener.Close() - if err != nil { - ps.log.WithError(err).Warning("failed to close proxy listener") - } - }() - - ps.log.WithField("listen", listen).Info("Starting HTTP server") - ps.serve(proxyListener) - ps.log.WithField("listen", listen).Info("Stopping HTTP server") -} - -// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests -func (ps *ProxyServer) ServeHTTPS(listen string) { - tlsConfig := utils.GetTLSConfig() - tlsConfig.GetCertificate = ps.getCertificates - - ln, err := net.Listen("tcp", listen) - if err != nil { - ps.log.WithError(err).Warning("Failed to listen (TLS)") - return - } - proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()} - defer func() { - err := proxyListener.Close() - if err != nil { - ps.log.WithError(err).Warning("failed to close proxy listener") - } - }() - - tlsListener := tls.NewListener(proxyListener, tlsConfig) - ps.log.WithField("listen", listen).Info("Starting HTTPS server") - ps.serve(tlsListener) - ps.log.WithField("listen", listen).Info("Stopping HTTPS server") -} - -func (ps *ProxyServer) Start() error { - listenHttp := config.Get().Listen.HTTP - listenHttps := config.Get().Listen.HTTPS - listenMetrics := config.Get().Listen.Metrics - metricsRouter := ak.MetricsRouter() - wg := sync.WaitGroup{} - wg.Add(len(listenHttp) + len(listenHttps) + 1 + len(listenMetrics)) - for _, listen := range listenHttp { - go func() { - defer wg.Done() - ps.ServeHTTP(listen) - }() - } - for _, listen := range listenHttps { - go func() { - defer wg.Done() - ps.ServeHTTPS(listen) - }() - } - go func() { - defer wg.Done() - ak.RunMetricsUnix(metricsRouter) - }() - for _, listen := range listenMetrics { - go func() { - defer wg.Done() - ak.RunMetricsServer(listen, metricsRouter) - }() - } - return nil -} - -func (ps *ProxyServer) Stop() error { - return nil -} - -func (ps *ProxyServer) serve(listener net.Listener) { - srv := web.Server(ps.mux) - - // See https://golang.org/pkg/net/http/#Server.Shutdown - idleConnsClosed := make(chan struct{}) - go func() { - <-ps.stop // wait notification for stopping server - - // We received an interrupt signal, shut down. - if err := srv.Shutdown(context.Background()); err != nil { - // Error from closing listeners, or context timeout: - ps.log.WithError(err).Info("HTTP server Shutdown") - } - close(idleConnsClosed) - }() - - err := srv.Serve(listener) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - ps.log.Errorf("ERROR: http.Serve() - %s", err) - } - <-idleConnsClosed -} diff --git a/internal/outpost/proxyv2/refresh.go b/internal/outpost/proxyv2/refresh.go deleted file mode 100644 index 00211374f963..000000000000 --- a/internal/outpost/proxyv2/refresh.go +++ /dev/null @@ -1,106 +0,0 @@ -package proxyv2 - -import ( - "context" - "fmt" - "net" - "net/http" - "net/url" - "os" - "path" - - "github.com/getsentry/sentry-go" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/outpost/ak" - "goauthentik.io/internal/outpost/proxyv2/application" - "goauthentik.io/internal/utils/web" - "golang.org/x/exp/maps" -) - -func (ps *ProxyServer) Refresh() error { - req := ps.akAPI.Client.OutpostsAPI.OutpostsProxyList(context.Background()) - ps.log.WithField("outpost_pk", ps.akAPI.Outpost.Pk).Debug("Requesting providers for outpost") - providers, err := ak.Paginator(req, ak.PaginatorOptions{ - PageSize: 100, - Logger: ps.log, - }) - if err != nil { - ps.log.WithError(err).Error("Failed to fetch providers") - } - if err != nil { - return err - } - ps.log.WithField("count", len(providers)).Debug("Fetched providers") - if len(providers) == 0 && !ps.akAPI.IsEmbedded() { - ps.log.Warning("No providers assigned to this outpost, check outpost configuration in authentik") - } - for i, p := range providers { - ps.log.WithField("index", i).WithField("name", p.Name).WithField("external_host", p.ExternalHost).WithField("assigned_to_app", p.AssignedApplicationName).Debug("Provider details") - } - apps := make(map[string]*application.Application) - for _, provider := range providers { - rsp := sentry.StartSpan(context.Background(), "authentik.outposts.proxy.application_ss") - ua := fmt.Sprintf(" (provider=%s)", provider.Name) - var transport http.RoundTripper - if ps.akAPI.IsEmbedded() { - transport = &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", path.Join(os.TempDir(), "authentik.sock")) - }, - } - } else { - transport = ak.GetTLSTransport() - } - hc := &http.Client{ - Transport: web.NewUserAgentTransport( - constants.UserAgentOutpost()+ua, - web.NewTracingTransport( - rsp.Context(), - transport, - ), - ), - } - externalHost, err := url.Parse(provider.ExternalHost) - if err != nil { - ps.log.WithError(err).Warning("failed to parse URL, skipping provider") - continue - } - existing, ok := ps.apps[externalHost.Host] - a, err := application.NewApplication(provider, hc, ps, existing) - if ok { - existing.Stop() - } - if err != nil { - ps.log.WithError(err).Warning("failed to setup application") - continue - } - ps.log.WithField("name", provider.Name).WithField("host", externalHost.Host).Info("Loaded application") - apps[externalHost.Host] = a - } - ps.apps = apps - ps.log.Debug("Swapped maps") - return nil -} - -func (ps *ProxyServer) API() *ak.APIController { - return ps.akAPI -} - -func (ps *ProxyServer) CryptoStore() *ak.CryptoStore { - return ps.cryptoStore -} - -func (ps *ProxyServer) Apps() []*application.Application { - return maps.Values(ps.apps) -} - -func (ps *ProxyServer) SessionBackend() string { - if ps.akAPI.IsEmbedded() { - return "postgres" - } - if !ps.akAPI.IsEmbedded() { - return "filesystem" - } - ps.log.Panic("failed to determine session backend type") - return "" -} diff --git a/internal/outpost/proxyv2/sessionstore/cleanup.go b/internal/outpost/proxyv2/sessionstore/cleanup.go deleted file mode 100644 index 2bec926445c0..000000000000 --- a/internal/outpost/proxyv2/sessionstore/cleanup.go +++ /dev/null @@ -1,113 +0,0 @@ -package sessionstore - -import ( - "context" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -const SessionCleanupInterval = 5 * time.Minute - -// CleanupStore defines the interface for stores that support cleanup -type CleanupStore interface { - CleanupExpired(ctx context.Context) error -} - -// CleanupManager manages periodic cleanup for session stores -type CleanupManager struct { - store CleanupStore - log *log.Entry - cancel context.CancelFunc - done chan struct{} - mu sync.Mutex - cleanupCtx context.Context - cleanupCancel context.CancelFunc -} - -// NewCleanupManager creates a new cleanup manager for the given store -func NewCleanupManager(store CleanupStore, logger *log.Entry) *CleanupManager { - return &CleanupManager{ - store: store, - log: logger, - } -} - -// Start begins the periodic cleanup goroutine -func (cm *CleanupManager) Start() { - cm.mu.Lock() - defer cm.mu.Unlock() - - if cm.cancel != nil { - return // Already running - } - - ctx, cancel := context.WithCancel(context.Background()) - cm.cancel = cancel - cm.done = make(chan struct{}) - - go func() { - defer close(cm.done) - cm.log.Info("Scheduling session cleanup job") - ticker := time.NewTicker(SessionCleanupInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - cm.log.Info("Stopping session cleanup job") - return - case <-ticker.C: - cm.runCleanup() - } - } - }() -} - -// runCleanup executes a single cleanup operation -func (cm *CleanupManager) runCleanup() { - cm.mu.Lock() - if cm.cleanupCtx != nil { - cm.mu.Unlock() - cm.log.Warn("Cleanup already in progress, skipping") - return - } - - cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) - cm.cleanupCtx = cleanupCtx - cm.cleanupCancel = cleanupCancel - cm.mu.Unlock() - - defer func() { - cm.mu.Lock() - if cm.cleanupCancel != nil { - cm.cleanupCancel() - } - cm.cleanupCtx = nil - cm.cleanupCancel = nil - cm.mu.Unlock() - }() - - cm.log.Debug("Running session cleanup") - if err := cm.store.CleanupExpired(cleanupCtx); err != nil { - cm.log.WithError(err).Warn("Session cleanup returned error") - } else { - cm.log.Debug("Session cleanup completed successfully") - } -} - -// Stop halts the periodic cleanup goroutine -func (cm *CleanupManager) Stop() { - cm.mu.Lock() - defer cm.mu.Unlock() - - if cm.cancel != nil { - cm.cancel() - if cm.done != nil { - <-cm.done - } - cm.cancel = nil - cm.done = nil - } -} diff --git a/internal/outpost/proxyv2/sessionstore/cleanup_test.go b/internal/outpost/proxyv2/sessionstore/cleanup_test.go deleted file mode 100644 index 45f394900166..000000000000 --- a/internal/outpost/proxyv2/sessionstore/cleanup_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package sessionstore - -import ( - "context" - "sync" - "testing" - - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" -) - -// mockSessionStore is a test implementation of SessionStore -type mockSessionStore struct { - mu sync.Mutex - cleanupCount int - shouldFailNext bool -} - -func (m *mockSessionStore) CleanupExpired(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - - if m.shouldFailNext { - m.shouldFailNext = false - return assert.AnError - } - - m.cleanupCount++ - return nil -} - -func (m *mockSessionStore) GetCleanupCount() int { - m.mu.Lock() - defer m.mu.Unlock() - return m.cleanupCount -} - -func (m *mockSessionStore) ResetCleanupCount() { - m.mu.Lock() - defer m.mu.Unlock() - m.cleanupCount = 0 -} - -func (m *mockSessionStore) SetShouldFail(shouldFail bool) { - m.mu.Lock() - defer m.mu.Unlock() - m.shouldFailNext = shouldFail -} - -func TestCleanupManager_StartStop(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - - // Manager should not be running initially - manager.mu.Lock() - running := manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) - - // Start the manager - manager.Start() - - // Manager should be running - manager.mu.Lock() - running = manager.cancel != nil - manager.mu.Unlock() - assert.True(t, running) - - // Stop the manager - manager.Stop() - - // Manager should not be running - manager.mu.Lock() - running = manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) -} - -func TestCleanupManager_PeriodicCleanup(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - // we can't easily test periodic cleanup without modifying SessionCleanupInterval - // which is a const. This test verifies the manager starts/stops correctly. - manager := NewCleanupManager(store, logger) - manager.Start() - - // Verify it's running - manager.mu.Lock() - running := manager.cancel != nil - manager.mu.Unlock() - assert.True(t, running) - - manager.Stop() - - // Verify it stopped - manager.mu.Lock() - running = manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) -} - -func TestCleanupManager_ManualCleanup(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - - // Run cleanup manually - manager.runCleanup() - - // Verify cleanup was called - count := store.GetCleanupCount() - assert.Equal(t, 1, count) -} - -func TestCleanupManager_StopWhileRunning(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - manager.Start() - - // Stop immediately - manager.Stop() - - // Manager should stop cleanly - manager.mu.Lock() - running := manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) -} - -func TestCleanupManager_MultipleStarts(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - - // Start multiple times - manager.Start() - manager.Start() // Should be no-op - manager.Start() // Should be no-op - - // Stop - manager.Stop() - - // Should still stop cleanly - manager.mu.Lock() - running := manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) -} - -func TestCleanupManager_MultipleStops(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - manager.Start() - - // Stop multiple times - manager.Stop() - manager.Stop() // Should be no-op - manager.Stop() // Should be no-op - - // Should still be stopped - manager.mu.Lock() - running := manager.cancel != nil - manager.mu.Unlock() - assert.False(t, running) -} - -func TestCleanupManager_ErrorHandling(t *testing.T) { - store := &mockSessionStore{} - logger := log.WithField("test", "cleanup") - - manager := NewCleanupManager(store, logger) - - // Set the store to fail - store.SetShouldFail(true) - - // Run cleanup manually: should handle error gracefully - manager.runCleanup() - - // Should not panic and cleanup count should be 0 - count := store.GetCleanupCount() - assert.Equal(t, 0, count) -} diff --git a/internal/outpost/proxyv2/templates/error.html b/internal/outpost/proxyv2/templates/error.html deleted file mode 100644 index 7477ce2afc9e..000000000000 --- a/internal/outpost/proxyv2/templates/error.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - {{.Title}} - - - - - -
- -
-

{{ .Title }}

-

{{ .Message }}

-
- - diff --git a/internal/outpost/proxyv2/templates/templates.go b/internal/outpost/proxyv2/templates/templates.go deleted file mode 100644 index f9ae7bcad924..000000000000 --- a/internal/outpost/proxyv2/templates/templates.go +++ /dev/null @@ -1,19 +0,0 @@ -package templates - -import ( - _ "embed" - "html/template" - - log "github.com/sirupsen/logrus" -) - -//go:embed error.html -var ErrorTemplate string - -func GetTemplates() *template.Template { - t, err := template.New("authentik.outpost.proxy.errors").Parse(ErrorTemplate) - if err != nil { - log.Fatalf("failed parsing template %s", err) - } - return t -} diff --git a/internal/outpost/proxyv2/types/claims.go b/internal/outpost/proxyv2/types/claims.go deleted file mode 100644 index c58ee139c5bc..000000000000 --- a/internal/outpost/proxyv2/types/claims.go +++ /dev/null @@ -1,23 +0,0 @@ -package types - -type ProxyClaims struct { - UserAttributes map[string]any `json:"user_attributes" mapstructure:"user_attributes"` - BackendOverride string `json:"backend_override" mapstructure:"backend_override"` - HostHeader string `json:"host_header" mapstructure:"host_header"` - IsSuperuser bool `json:"is_superuser" mapstructure:"is_superuser"` -} - -type Claims struct { - Sub string `json:"sub" mapstructure:"sub"` - Exp int `json:"exp" mapstructure:"exp"` - Email string `json:"email" mapstructure:"email"` - Verified bool `json:"email_verified" mapstructure:"email_verified"` - Name string `json:"name" mapstructure:"name"` - PreferredUsername string `json:"preferred_username" mapstructure:"preferred_username"` - Groups []string `json:"groups" mapstructure:"groups"` - Entitlements []string `json:"entitlements" mapstructure:"entitlements"` - Sid string `json:"sid" mapstructure:"sid"` - Proxy *ProxyClaims `json:"ak_proxy" mapstructure:"ak_proxy"` - - RawToken string `json:"raw_token" mapstructure:"raw_token"` -} diff --git a/internal/outpost/proxyv2/ws.go b/internal/outpost/proxyv2/ws.go deleted file mode 100644 index 187d78a3b5db..000000000000 --- a/internal/outpost/proxyv2/ws.go +++ /dev/null @@ -1,29 +0,0 @@ -package proxyv2 - -import ( - "context" - - "goauthentik.io/internal/outpost/ak" - "goauthentik.io/internal/outpost/proxyv2/types" -) - -func (ps *ProxyServer) handleWSMessage(ctx context.Context, msg ak.Event) error { - if msg.Instruction != ak.EventKindSessionEnd { - return nil - } - mmsg := ak.EventArgsSessionEnd{} - err := msg.ArgsAs(&mmsg) - if err != nil { - return err - } - for _, p := range ps.apps { - ps.log.WithField("provider", p.Host).Debug("Logging out") - err := p.Logout(ctx, func(c types.Claims) bool { - return c.Sid == mmsg.SessionID - }) - if err != nil { - ps.log.WithField("provider", p.Host).WithError(err).Warning("failed to logout") - } - } - return nil -} diff --git a/internal/utils/web/http_compress.go b/internal/utils/web/http_compress.go deleted file mode 100644 index bd49000472fc..000000000000 --- a/internal/utils/web/http_compress.go +++ /dev/null @@ -1,91 +0,0 @@ -// https://github.com/gorilla/handlers/issues/259#issuecomment-2671695039 -package web - -import ( - "bufio" - "net" - "net/http" - - "github.com/gorilla/handlers" -) - -// compressHandler is an HTTP handler that adds the Content-Encoding header -// back to responses when removed by the http.FileServer. -// -// handlers.CompressHandler(newCompressHandler(http.FileServer(...))) -type compressHandler struct { - // handler is an HTTP handler, usually an http.FileServer. - handler http.Handler -} - -var _ http.Handler = &compressHandler{} - -func NewCompressHandler(handler http.Handler) http.Handler { - h := &compressHandler{ - handler: handler, - } - return handlers.CompressHandler(h) -} - -func (h *compressHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // The wrapped response writer saves the incoming content encoding so - // it can be restored when writing the response headers. - cw := &compressedResponseWriter{ - encoding: w.Header().Get("Content-Encoding"), - fixed: false, - responseWriter: w, - } - h.handler.ServeHTTP(cw, r) -} - -// compressedResponseWriter is an http.ResponseWriter that ensures that a -// previously-set Content-Encoding header is in place before writing the -// response. -type compressedResponseWriter struct { - encoding string - fixed bool - responseWriter http.ResponseWriter -} - -var _ http.ResponseWriter = &compressedResponseWriter{} - -func (w *compressedResponseWriter) Header() http.Header { - return w.responseWriter.Header() -} - -func (w *compressedResponseWriter) fixContentEncoding() { - if w.fixed { - return - } - w.fixed = true - // The Go 1.23 http.FileServer() removes headers like Content-Encoding - // from error responses. This breaks gzip and deflate encoding. - // https://github.com/gorilla/handlers/issues/259 - // https://github.com/golang/go/issues/66343 - if w.encoding == "gzip" || w.encoding == "deflate" { - if w.Header().Get("Content-Encoding") == "" { - w.Header().Set("Content-Encoding", w.encoding) - } - } -} - -func (w *compressedResponseWriter) Write(data []byte) (int, error) { - w.fixContentEncoding() - return w.responseWriter.Write(data) -} - -func (w *compressedResponseWriter) WriteHeader(statusCode int) { - w.fixContentEncoding() - w.responseWriter.WriteHeader(statusCode) -} - -func (w *compressedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { - if hj, ok := w.responseWriter.(http.Hijacker); ok { - return hj.Hijack() - } - return nil, nil, http.ErrNotSupported -} - -// Ensure our compressedResponseWriter implements the necessary interfaces. -var _ http.ResponseWriter = &compressedResponseWriter{} -var _ http.Hijacker = &compressedResponseWriter{} diff --git a/internal/utils/web/http_forwarded.go b/internal/utils/web/http_forwarded.go deleted file mode 100644 index 95571dead80b..000000000000 --- a/internal/utils/web/http_forwarded.go +++ /dev/null @@ -1,53 +0,0 @@ -package web - -import ( - "context" - "net" - "net/http" - - "github.com/gorilla/handlers" - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/config" -) - -type allowedProxyRequestContext string - -const allowedProxyRequest allowedProxyRequestContext = "" - -func IsRequestFromTrustedProxy(r *http.Request) bool { - return r.Context().Value(allowedProxyRequest) != nil -} - -// ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection -// comes from a client that's in a list of trusted CIDRs -func ProxyHeaders() func(http.Handler) http.Handler { - nets := []*net.IPNet{} - for _, rn := range config.Get().Listen.TrustedProxyCIDRs { - _, cidr, err := net.ParseCIDR(rn) - if err != nil { - continue - } - nets = append(nets, cidr) - } - return func(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err == nil { - // remoteAddr will be nil if the IP cannot be parsed - remoteAddr := net.ParseIP(host) - for _, allowedCidr := range nets { - if remoteAddr != nil && allowedCidr.Contains(remoteAddr) { - log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers") - rr := r.WithContext(context.WithValue(r.Context(), allowedProxyRequest, true)) - handlers.ProxyHeaders(h).ServeHTTP(w, rr) - return - } - } - } - // Request is not directly coming from a CIDR we "trust" - // so set XFF to the direct host IP - r.Header.Set("X-Forwarded-For", host) - h.ServeHTTP(w, r) - }) - } -} diff --git a/internal/utils/web/http_host_interceptor.go b/internal/utils/web/http_host_interceptor.go deleted file mode 100644 index 3ca4f407145f..000000000000 --- a/internal/utils/web/http_host_interceptor.go +++ /dev/null @@ -1,36 +0,0 @@ -package web - -import ( - "net/http" - "net/url" - - log "github.com/sirupsen/logrus" -) - -type hostInterceptor struct { - inner http.RoundTripper - host string - scheme string -} - -func (t hostInterceptor) RoundTrip(r *http.Request) (*http.Response, error) { - if r.Host != t.host { - r.Host = t.host - r.Header.Set("X-Forwarded-Proto", t.scheme) - } - return t.inner.RoundTrip(r) -} - -func NewHostInterceptor(inner *http.Client, host string) *http.Client { - aku, err := url.Parse(host) - if err != nil { - log.WithField("host", host).WithError(err).Warn("failed to parse host") - } - return &http.Client{ - Transport: hostInterceptor{ - inner: inner.Transport, - host: aku.Host, - scheme: aku.Scheme, - }, - } -} diff --git a/internal/utils/web/keepalive.go b/internal/utils/web/keepalive.go deleted file mode 100644 index 5ac75549aed3..000000000000 --- a/internal/utils/web/keepalive.go +++ /dev/null @@ -1,32 +0,0 @@ -package web - -import ( - "net" - "time" - - log "github.com/sirupsen/logrus" -) - -// tcpKeepAliveListener sets TCP keep-alive timeouts on accepted -// connections. It's used by ListenAndServe and ListenAndServeTLS so -// dead TCP connections (e.g. closing laptop mid-download) eventually -// go away. -type TCPKeepAliveListener struct { - *net.TCPListener -} - -func (ln TCPKeepAliveListener) Accept() (net.Conn, error) { - tc, err := ln.AcceptTCP() - if err != nil { - return nil, err - } - err = tc.SetKeepAlive(true) - if err != nil { - log.WithError(err).Warning("Error setting Keep-Alive") - } - err = tc.SetKeepAlivePeriod(3 * time.Minute) - if err != nil { - log.WithError(err).Warning("Error setting Keep-Alive period") - } - return tc, nil -} diff --git a/internal/utils/web/server.go b/internal/utils/web/server.go deleted file mode 100644 index 148ee569375e..000000000000 --- a/internal/utils/web/server.go +++ /dev/null @@ -1,28 +0,0 @@ -package web - -import ( - "net/http" - "time" - - "goauthentik.io/internal/config" -) - -func durationOrFallback(raw string, fallback time.Duration) time.Duration { - p, err := time.ParseDuration(raw) - if err != nil { - return fallback - } - return p -} - -func Server(h http.Handler) *http.Server { - c := config.Get() - return &http.Server{ - Handler: h, - ReadHeaderTimeout: durationOrFallback(c.Web.TimeoutHttpReadHeader, 5*time.Second), - ReadTimeout: durationOrFallback(c.Web.TimeoutHttpRead, 30*time.Second), - WriteTimeout: durationOrFallback(c.Web.TimeoutHttpWrite, 60*time.Second), - IdleTimeout: durationOrFallback(c.Web.TimeoutHttpIdle, 120*time.Second), - MaxHeaderBytes: http.DefaultMaxHeaderBytes, - } -} diff --git a/internal/utils/web/static.go b/internal/utils/web/static.go deleted file mode 100644 index 4f6c2ee85c59..000000000000 --- a/internal/utils/web/static.go +++ /dev/null @@ -1,17 +0,0 @@ -package web - -import ( - "net/http" - "strings" -) - -func DisableIndex(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "/") { - http.NotFound(w, r) - return - } - - next.ServeHTTP(w, r) - }) -} diff --git a/internal/web/brand_tls/brand_tls.go b/internal/web/brand_tls/brand_tls.go deleted file mode 100644 index a6e7595ad310..000000000000 --- a/internal/web/brand_tls/brand_tls.go +++ /dev/null @@ -1,117 +0,0 @@ -package brand_tls - -import ( - "context" - "crypto/tls" - "crypto/x509" - "strings" - "time" - - log "github.com/sirupsen/logrus" - - "goauthentik.io/internal/crypto" - "goauthentik.io/internal/outpost/ak" - api "goauthentik.io/packages/client-go" -) - -type Watcher struct { - client *api.APIClient - log *log.Entry - cs *ak.CryptoStore - fallback *tls.Certificate - brands []api.Brand -} - -func NewWatcher(client *api.APIClient) *Watcher { - cs := ak.NewCryptoStore(client.CryptoAPI) - l := log.WithField("logger", "authentik.router.brand_tls") - cert, err := crypto.GenerateSelfSignedCert() - if err != nil { - l.WithError(err).Error("failed to generate default cert") - } - - return &Watcher{ - client: client, - log: l, - cs: cs, - fallback: &cert, - } -} - -func (w *Watcher) Start() { - ticker := time.NewTicker(time.Minute * 3) - w.log.Info("Starting Brand TLS Checker") - for ; true; <-ticker.C { - w.Check() - } -} - -func (w *Watcher) Check() { - w.log.Info("updating brand certificates") - brands, err := ak.Paginator(w.client.CoreAPI.CoreBrandsList(context.Background()), ak.PaginatorOptions{ - PageSize: 100, - Logger: w.log, - }) - if err != nil { - w.log.WithError(err).Warning("failed to get brands") - return - } - for _, b := range brands { - kp := b.GetWebCertificate() - if kp != "" { - err := w.cs.AddKeypair(kp) - if err != nil { - w.log.WithError(err).WithField("kp", kp).Warning("failed to add web certificate") - } - } - for _, crt := range b.GetClientCertificates() { - if crt != "" { - err := w.cs.AddKeypair(crt) - if err != nil { - w.log.WithError(err).WithField("kp", kp).Warning("failed to add client certificate") - } - } - } - } - w.brands = brands -} - -type CertificateConfig struct { - Web *tls.Certificate - Client *x509.CertPool -} - -func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) *CertificateConfig { - var bestSelection *api.Brand - config := CertificateConfig{ - Web: w.fallback, - } - for _, t := range w.brands { - if !t.WebCertificate.IsSet() && len(t.GetClientCertificates()) < 1 { - continue - } - if *t.Default { - bestSelection = &t - } - if strings.HasSuffix(ch.ServerName, t.Domain) { - bestSelection = &t - } - } - if bestSelection == nil { - return &config - } - if bestSelection.GetWebCertificate() != "" { - if cert := w.cs.Get(bestSelection.GetWebCertificate()); cert != nil { - config.Web = cert - } - } - if len(bestSelection.GetClientCertificates()) > 0 { - config.Client = x509.NewCertPool() - for _, kp := range bestSelection.GetClientCertificates() { - if cert := w.cs.Get(kp); cert != nil { - config.Client.AddCert(cert.Leaf) - } - } - } - return &config -} diff --git a/internal/web/metrics.go b/internal/web/metrics.go deleted file mode 100644 index d4aad84ec463..000000000000 --- a/internal/web/metrics.go +++ /dev/null @@ -1,57 +0,0 @@ -package web - -import ( - "fmt" - "io" - "net/http" - - "github.com/gorilla/mux" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" - log "github.com/sirupsen/logrus" - "goauthentik.io/internal/config" - "goauthentik.io/internal/utils/sentry" -) - -var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ - Name: "authentik_main_request_duration_seconds", - Help: "API request latencies in seconds", -}, []string{"dest"}) - -func (ws *WebServer) runMetricsServer(listen string) { - l := log.WithField("logger", "authentik.router.metrics") - - m := mux.NewRouter() - m.Use(sentry.SentryNoSampleMiddleware) - m.Path("/metrics").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - promhttp.InstrumentMetricHandler( - prometheus.DefaultRegisterer, promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ - DisableCompression: true, - }), - ).ServeHTTP(rw, r) - - // Get upstream metrics - re, err := http.NewRequest("GET", fmt.Sprintf("%s%s-/metrics/", ws.upstreamURL.String(), config.Get().Web.Path), nil) - if err != nil { - l.WithError(err).Warning("failed to get upstream metrics") - return - } - res, err := ws.upstreamHttpClient().Do(re) - if err != nil { - l.WithError(err).Warning("failed to get upstream metrics") - return - } - _, err = io.Copy(rw, res.Body) - if err != nil { - l.WithError(err).Warning("failed to get upstream metrics") - return - } - }) - l.WithField("listen", listen).Info("Starting Metrics server") - err := http.ListenAndServe(listen, m) - if err != nil { - l.WithError(err).Warning("Failed to start metrics server") - } - l.WithField("listen", listen).Info("Stopping Metrics server") -} diff --git a/internal/web/proxy.go b/internal/web/proxy.go deleted file mode 100644 index fb6add10e645..000000000000 --- a/internal/web/proxy.go +++ /dev/null @@ -1,208 +0,0 @@ -package web - -import ( - "encoding/json" - "encoding/pem" - "errors" - "fmt" - "io" - "net/http" - "net/http/httputil" - "net/url" - "strings" - "time" - - "github.com/prometheus/client_golang/prometheus" - "goauthentik.io/internal/config" - "goauthentik.io/internal/utils/sentry" - "goauthentik.io/internal/utils/web" - staticWeb "goauthentik.io/web" -) - -var ErrAuthentikStarting = errors.New("authentik starting") - -const ( - maxBodyBytes = 32 * 1024 * 1024 -) - -var djangoHTTPMethods = map[string]struct{}{ - http.MethodGet: {}, - http.MethodHead: {}, - http.MethodPost: {}, - http.MethodPut: {}, - http.MethodPatch: {}, - http.MethodDelete: {}, - http.MethodOptions: {}, - http.MethodTrace: {}, -} - -func handleUnsupportedHTTPMethod(rw http.ResponseWriter, r *http.Request) bool { - if _, ok := djangoHTTPMethods[r.Method]; ok { - return false - } - http.Error(rw, "Unsupported HTTP method.", http.StatusNotImplemented) - return true -} - -func (ws *WebServer) configureProxy() { - // Reverse proxy to the application server - director := func(req *http.Request) { - req.URL.Scheme = ws.upstreamURL.Scheme - req.URL.Host = ws.upstreamURL.Host - if _, ok := req.Header["User-Agent"]; !ok { - // explicitly disable User-Agent so it's not set to default value - req.Header.Set("User-Agent", "") - } - if !web.IsRequestFromTrustedProxy(req) { - // If the request isn't coming from a trusted proxy, delete MTLS headers - req.Header.Del("SSL-Client-Cert") // nginx-ingress - req.Header.Del("X-Forwarded-TLS-Client-Cert") // traefik - req.Header.Del("X-Forwarded-Client-Cert") // envoy - } - if req.TLS != nil { - req.Header.Set("X-Forwarded-Proto", "https") - if len(req.TLS.PeerCertificates) > 0 { - pems := make([]string, len(req.TLS.PeerCertificates)) - for i, crt := range req.TLS.PeerCertificates { - pem := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: crt.Raw, - }) - pems[i] = "Cert=" + url.QueryEscape(string(pem)) - } - req.Header.Set("X-Forwarded-Client-Cert", strings.Join(pems, ",")) - } - } - ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend") - } - rp := &httputil.ReverseProxy{ - Director: director, - Transport: ws.upstreamHttpClient().Transport, - } - rp.ErrorHandler = ws.proxyErrorHandler - rp.ModifyResponse = ws.proxyModifyResponse - ws.mainRouter.Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) { - if ws.upstreamHealthcheck() { - rw.WriteHeader(200) - } else { - rw.WriteHeader(502) - } - })) - ws.mainRouter.PathPrefix(config.Get().Web.Path).Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) { - if ws.upstreamHealthcheck() { - rw.WriteHeader(200) - } else { - rw.WriteHeader(502) - } - })) - ws.mainRouter.PathPrefix(config.Get().Web.Path).HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) { - if !ws.g.IsRunning() { - ws.proxyErrorHandler(rw, r, ErrAuthentikStarting) - return - } - before := time.Now() - - if ws.ProxyServer != nil && ws.ProxyServer.HandleHost(rw, r) { - elapsed := time.Since(before) - Requests.With(prometheus.Labels{ - "dest": "embedded_outpost", - }).Observe(float64(elapsed) / float64(time.Second)) - return - } - - if handleUnsupportedHTTPMethod(rw, r) { - return - } - - r.Body = http.MaxBytesReader(rw, r.Body, maxBodyBytes) - rp.ServeHTTP(rw, r) - - elapsed := time.Since(before) - Requests.With(prometheus.Labels{ - "dest": "core", - }).Observe(float64(elapsed) / float64(time.Second)) - })) -} - -func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) { - accept := req.Header.Get("Accept") - - header := rw.Header() - - if errors.Is(err, ErrAuthentikStarting) { - header.Set("Retry-After", "5") - - if strings.Contains(accept, "application/json") { - header.Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusServiceUnavailable) - - err = json.NewEncoder(rw).Encode(map[string]string{ - "error": "authentik starting", - }) - if err != nil { - ws.log.WithError(err).Warning("failed to write error message") - return - } - } else if strings.Contains(accept, "text/html") { - header.Set("Content-Type", "text/html") - rw.WriteHeader(http.StatusServiceUnavailable) - - loadingSplashFile, err := staticWeb.StaticDir.Open("standalone/loading/startup.html") - if err != nil { - ws.log.WithError(err).Warning("failed to open startup splash screen") - return - } - - loadingSplashHTML, err := io.ReadAll(loadingSplashFile) - if err != nil { - ws.log.WithError(err).Warning("failed to read startup splash screen") - return - } - - _, err = rw.Write(loadingSplashHTML) - if err != nil { - ws.log.WithError(err).Warning("failed to write startup splash screen") - return - } - } else { - header.Set("Content-Type", "text/plain") - rw.WriteHeader(http.StatusServiceUnavailable) - - // Fallback to just a status message - _, err = rw.Write([]byte("authentik starting")) - if err != nil { - ws.log.WithError(err).Warning("failed to write initializing HTML") - } - } - - return - } - - ws.log.WithError(err).Warning("failed to proxy to backend") - - em := fmt.Sprintf("failed to connect to authentik backend: %v", err) - - if strings.Contains(accept, "application/json") { - header.Set("Content-Type", "application/json") - rw.WriteHeader(http.StatusBadGateway) - - err = json.NewEncoder(rw).Encode(map[string]string{ - "error": em, - }) - } else { - header.Set("Content-Type", "text/plain") - rw.WriteHeader(http.StatusBadGateway) - - _, err = rw.Write([]byte(em)) - } - - if err != nil { - ws.log.WithError(err).Warning("failed to write error message") - } -} - -func (ws *WebServer) proxyModifyResponse(r *http.Response) error { - r.Header.Set("X-Powered-By", "authentik") - r.Header.Del("Server") - return nil -} diff --git a/internal/web/static.go b/internal/web/static.go deleted file mode 100644 index 79c97503b00c..000000000000 --- a/internal/web/static.go +++ /dev/null @@ -1,195 +0,0 @@ -package web - -import ( - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - "time" - - "github.com/go-http-utils/etag" - "github.com/golang-jwt/jwt/v5" - "github.com/gorilla/mux" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/utils/web" - staticWeb "goauthentik.io/web" -) - -type StorageClaims struct { - jwt.RegisteredClaims - Path string `json:"path,omitempty"` -} - -func storageTokenIsValid(usage string, r *http.Request) bool { - tokenString := r.URL.Query().Get("token") - if tokenString == "" { - return false - } - claims := &StorageClaims{} - - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - key := fmt.Appendf(nil, "%s:%s", config.Get().SecretKey, usage) - hash := sha256.Sum256(key) - hexDigest := hex.EncodeToString(hash[:]) - return []byte(hexDigest), nil - }) - if err != nil || !token.Valid { - return false - } - - now := time.Now() - - if claims.ExpiresAt != nil && claims.ExpiresAt.Before(now) { - return false - } - if claims.NotBefore != nil && claims.NotBefore.After(now) { - return false - } - - if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) { - return false - } - - return true -} - -func (ws *WebServer) configureStatic() { - // Setup routers - staticRouter := ws.loggingRouter.NewRoute().Subrouter() - staticRouter.Use(ws.staticHeaderMiddleware) - staticRouter.Use(web.DisableIndex) - - distFs := http.FileServer(http.Dir("./web/dist")) - - pathStripper := func(handler http.Handler, paths ...string) http.Handler { - h := handler - for _, path := range paths { - h = http.StripPrefix(path, h) - } - return h - } - - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/dist/").Handler(pathStripper( - distFs, - "static/dist/", - config.Get().Web.Path, - )) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/authentik/").Handler(pathStripper( - http.FileServer(http.Dir("./web/authentik")), - "static/authentik/", - config.Get().Web.Path, - )) - - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/flow/{flow_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - pathStripper( - distFs, - "if/flow/"+vars["flow_slug"], - config.Get().Web.Path, - ).ServeHTTP(rw, r) - }) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/admin/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/admin", config.Get().Web.Path), distFs)) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/user/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/user", config.Get().Web.Path), distFs)) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/rac/{app_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - - pathStripper( - distFs, - "if/rac/"+vars["app_slug"], - config.Get().Web.Path, - ).ServeHTTP(rw, r) - }) - - // Files, if backend is file - defaultBackend := config.Get().Storage.Backend - if defaultBackend == "" { - defaultBackend = "file" - } - mediaBackend := config.Get().Storage.Media.Backend - if mediaBackend == "" { - mediaBackend = defaultBackend - } - reportsBackend := config.Get().Storage.Reports.Backend - if reportsBackend == "" { - reportsBackend = defaultBackend - } - - defaultStoragePath := config.Get().Storage.File.Path - if defaultStoragePath == "" { - defaultStoragePath = "./data" - } - - if mediaBackend == "file" { - mediaPath := config.Get().Storage.Media.File.Path - if mediaPath == "" { - mediaPath = defaultStoragePath - } - mediaPath = mediaPath + "/media" - fsMedia := http.FileServer(http.Dir(mediaPath)) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/media/").Handler(pathStripper( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !storageTokenIsValid("media", r) { - http.Error(w, "404 page not found", http.StatusNotFound) - return - } - - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox") - fsMedia.ServeHTTP(w, r) - }), - "files/media/", - config.Get().Web.Path, - )) - } - - if reportsBackend == "file" { - reportsPath := config.Get().Storage.Reports.File.Path - if reportsPath == "" { - reportsPath = defaultStoragePath - } - reportsPath = reportsPath + "/reports" - fsReports := http.FileServer(http.Dir(reportsPath)) - staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/files/reports/").Handler(pathStripper( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !storageTokenIsValid("reports", r) { - http.Error(w, "404 page not found", http.StatusNotFound) - return - } - fsReports.ServeHTTP(w, r) - }), - "files/reports/", - config.Get().Web.Path, - )) - } - - staticRouter.PathPrefix(config.Get().Web.Path).Path("/robots.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header()["Content-Type"] = []string{"text/plain"} - rw.WriteHeader(200) - _, err := rw.Write(staticWeb.RobotsTxt) - if err != nil { - ws.log.WithError(err).Warning("failed to write response") - } - }) - staticRouter.PathPrefix(config.Get().Web.Path).Path("/.well-known/security.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - rw.Header()["Content-Type"] = []string{"text/plain"} - rw.WriteHeader(200) - _, err := rw.Write(staticWeb.SecurityTxt) - if err != nil { - ws.log.WithError(err).Warning("failed to write response") - } - }) -} - -func (ws *WebServer) staticHeaderMiddleware(h http.Handler) http.Handler { - etagHandler := etag.Handler(h, false) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "public, no-transform") - w.Header().Set("X-authentik-version", constants.VERSION()) - etagHandler.ServeHTTP(w, r) - }) -} diff --git a/internal/web/web.go b/internal/web/web.go deleted file mode 100644 index ad9c47c7faa9..000000000000 --- a/internal/web/web.go +++ /dev/null @@ -1,288 +0,0 @@ -package web - -import ( - "context" - "encoding/base64" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "os" - "path" - "time" - - "github.com/gorilla/mux" - "github.com/gorilla/securecookie" - "github.com/pires/go-proxyproto" - log "github.com/sirupsen/logrus" - - "goauthentik.io/internal/config" - "goauthentik.io/internal/constants" - "goauthentik.io/internal/gounicorn" - "goauthentik.io/internal/outpost/proxyv2" - "goauthentik.io/internal/utils" - "goauthentik.io/internal/utils/unix" - "goauthentik.io/internal/utils/web" - "goauthentik.io/internal/web/brand_tls" - api "goauthentik.io/packages/client-go" -) - -const ( - SocketName = "authentik.sock" - IPCKeyFile = "authentik-core-ipc.key" - CoreSocketName = "authentik-core.sock" -) - -type WebServer struct { - Bind string - BindTLS bool - - stop chan struct{} // channel for waiting shutdown - - ProxyServer *proxyv2.ProxyServer - BrandTLS *brand_tls.Watcher - - g *gounicorn.GoUnicorn - gunicornReady bool - mainRouter *mux.Router - loggingRouter *mux.Router - log *log.Entry - upstreamClient *http.Client - upstreamURL *url.URL - - ipcKey string -} - -func NewWebServer() *WebServer { - l := log.WithField("logger", "authentik.router") - mainHandler := mux.NewRouter() - mainHandler.Use(web.ProxyHeaders()) - mainHandler.Use(web.NewCompressHandler) - loggingHandler := mainHandler.NewRoute().Subrouter() - loggingHandler.Use(web.NewLoggingHandler(l, nil)) - - tmp := os.TempDir() - socketPath := path.Join(tmp, CoreSocketName) - - // create http client to talk to backend, normal client if we're in debug more - // and a client that connects to our socket when in non debug mode - var upstreamClient *http.Client - if config.Get().Debug { - upstreamClient = http.DefaultClient - } else { - upstreamClient = &http.Client{ - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, - } - } - - u, _ := url.Parse("http://localhost:8000") - - ws := &WebServer{ - mainRouter: mainHandler, - loggingRouter: loggingHandler, - log: l, - gunicornReady: false, - upstreamClient: upstreamClient, - upstreamURL: u, - } - ws.mainRouter.PathPrefix(config.Get().Web.Path).Path("/-/metrics/").Handler(http.NotFoundHandler()) - ws.configureStatic() - ws.configureProxy() - // Redirect for sub-folder - if sp := config.Get().Web.Path; sp != "/" { - ws.mainRouter.Path("/").Handler(http.RedirectHandler(sp, http.StatusFound)) - } - ws.g = gounicorn.New(func() bool { - return ws.upstreamHealthcheck() - }) - return ws -} - -func (ws *WebServer) upstreamHealthcheck() bool { - hcUrl := fmt.Sprintf("%s%s-/health/live/", ws.upstreamURL.String(), config.Get().Web.Path) - req, err := http.NewRequest(http.MethodGet, hcUrl, nil) - if err != nil { - ws.log.WithError(err).Warning("failed to create request for healthcheck") - return false - } - req.Header.Set("User-Agent", "goauthentik.io/router/healthcheck") - res, err := ws.upstreamHttpClient().Do(req) - if err == nil && res.StatusCode >= 200 && res.StatusCode < 300 { - return true - } - return false -} - -func (ws *WebServer) prepareKeys() { - tmp := os.TempDir() - key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) - err := os.WriteFile(path.Join(tmp, IPCKeyFile), []byte(key), 0o600) - if err != nil { - ws.log.WithError(err).Warning("failed to save ipc key") - return - } - ws.ipcKey = key -} - -func (ws *WebServer) Start() { - ws.prepareKeys() - - socketPath := path.Join(os.TempDir(), SocketName) - u, err := url.Parse(fmt.Sprintf("http://localhost%s", config.Get().Web.Path)) - if err != nil { - panic(err) - } - apiConfig := api.NewConfiguration() - apiConfig.Host = u.Host - apiConfig.Scheme = u.Scheme - apiConfig.HTTPClient = &http.Client{ - Transport: web.NewUserAgentTransport( - constants.UserAgentIPC(), - &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, - ), - } - apiConfig.Servers = api.ServerConfigurations{ - { - URL: fmt.Sprintf("%sapi/v3", u.Path), - }, - } - apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", ws.ipcKey)) - - // create the API client, with the transport - apiClient := api.NewAPIClient(apiConfig) - - // Init brand_tls here too since it requires an API Client, - // so we just reuse the same one as the outpost uses - tw := brand_tls.NewWatcher(apiClient) - ws.BrandTLS = tw - ws.g.AddHealthyCallback(func() { - go tw.Start() - }) - - for _, listen := range config.Get().Listen.Metrics { - go ws.runMetricsServer(listen) - } - go ws.attemptStartBackend() - _ = os.Remove(socketPath) - go ws.listenUnix(socketPath) - for _, listen := range config.Get().Listen.HTTP { - go ws.listenPlain(listen) - } - for _, listen := range config.Get().Listen.HTTPS { - go ws.listenTLS(listen) - } -} - -func (ws *WebServer) attemptStartBackend() { - for { - if ws.gunicornReady { - return - } - err := ws.g.Start() - ws.log.WithError(err).Warning("gunicorn process died, restarting") - if err != nil { - ws.log.WithError(err).Error("gunicorn failed to start, restarting") - continue - } - failedChecks := 0 - for range time.NewTicker(30 * time.Second).C { - if !ws.g.IsRunning() { - ws.log.Warningf("gunicorn process failed healthcheck %d times", failedChecks) - failedChecks += 1 - } - if failedChecks >= 3 { - ws.log.WithError(err).Error("gunicorn process failed healthcheck three times, restarting") - break - } - } - } -} - -func (ws *WebServer) Core() *gounicorn.GoUnicorn { - return ws.g -} - -func (ws *WebServer) upstreamHttpClient() *http.Client { - return ws.upstreamClient -} - -func (ws *WebServer) Shutdown() { - ws.log.Info("shutting down gunicorn") - ws.g.Kill() - tmp := os.TempDir() - err := os.Remove(path.Join(tmp, IPCKeyFile)) - if err != nil { - ws.log.WithError(err).Warning("failed to remove ipc key file") - } - ws.stop <- struct{}{} -} - -func (ws *WebServer) listenUnix(listen string) { - ln, err := unix.Listen(listen) - if err != nil { - ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen") - return - } - defer func() { - err := ln.Close() - _ = os.Remove(listen) - if err != nil { - ws.log.WithField("listen", listen).WithError(err).Warning("failed to close listener") - } - }() - - ws.log.WithField("listen", listen).Info("Starting HTTP server") - ws.serve(ln) - ws.log.WithField("listen", listen).Info("Stopping HTTP server") -} - -func (ws *WebServer) listenPlain(listen string) { - ln, err := net.Listen("tcp", listen) - if err != nil { - ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen") - return - } - proxyListener := &proxyproto.Listener{Listener: ln, ConnPolicy: utils.GetProxyConnectionPolicy()} - defer func() { - err := proxyListener.Close() - if err != nil { - ws.log.WithField("listen", listen).WithError(err).Warning("failed to close proxy listener") - } - }() - - ws.log.WithField("listen", listen).Info("Starting HTTP server") - ws.serve(proxyListener) - ws.log.WithField("listen", listen).Info("Stopping HTTP server") -} - -func (ws *WebServer) serve(listener net.Listener) { - srv := web.Server(ws.mainRouter) - - // See https://golang.org/pkg/net/http/#Server.Shutdown - idleConnsClosed := make(chan struct{}) - go func() { - <-ws.stop // wait notification for stopping server - - // We received an interrupt signal, shut down. - if err := srv.Shutdown(context.Background()); err != nil { - // Error from closing listeners, or context timeout: - ws.log.WithError(err).Warning("HTTP server Shutdown") - } - close(idleConnsClosed) - }() - - err := srv.Serve(listener) - if err != nil && !errors.Is(err, http.ErrServerClosed) { - ws.log.WithError(err).Error("ERROR: http.Serve()") - } - <-idleConnsClosed -} diff --git a/internal/web/web_tls.go b/internal/web/web_tls.go deleted file mode 100644 index c907c65711b6..000000000000 --- a/internal/web/web_tls.go +++ /dev/null @@ -1,72 +0,0 @@ -package web - -import ( - "crypto/tls" - "net" - - "github.com/pires/go-proxyproto" - - "goauthentik.io/internal/crypto" - "goauthentik.io/internal/utils" - "goauthentik.io/internal/utils/web" -) - -func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Config, error) { - fallback, err := crypto.GenerateSelfSignedCert() - if err != nil { - ws.log.WithError(err).Error("failed to generate default cert") - } - return func(ch *tls.ClientHelloInfo) (*tls.Config, error) { - cfg := utils.GetTLSConfig() - if ch.ServerName != "" && ws.ProxyServer != nil { - appCert := ws.ProxyServer.GetCertificate(ch.ServerName) - if appCert != nil { - cfg.Certificates = []tls.Certificate{*appCert} - return cfg, nil - } - } - if ws.BrandTLS != nil { - bcert := ws.BrandTLS.GetCertificate(ch) - cfg.Certificates = []tls.Certificate{*bcert.Web} - ws.log.Trace("using brand web Certificate") - if bcert.Client != nil { - cfg.ClientCAs = bcert.Client - cfg.ClientAuth = tls.RequestClientCert - ws.log.Trace("using brand client Certificate") - } - return cfg, nil - } - ws.log.Trace("using default, self-signed certificate") - cfg.Certificates = []tls.Certificate{fallback} - return cfg, nil - } -} - -// ServeHTTPS constructs a net.Listener and starts handling HTTPS requests -func (ws *WebServer) listenTLS(listen string) { - tlsConfig := utils.GetTLSConfig() - tlsConfig.GetConfigForClient = ws.GetCertificate() - - ln, err := net.Listen("tcp", listen) - if err != nil { - ws.log.WithField("listen", listen).WithError(err).Warning("failed to listen (TLS)") - return - } - proxyListener := &proxyproto.Listener{ - Listener: web.TCPKeepAliveListener{ - TCPListener: ln.(*net.TCPListener), - }, - ConnPolicy: utils.GetProxyConnectionPolicy(), - } - defer func() { - err := proxyListener.Close() - if err != nil { - ws.log.WithError(err).Warning("failed to close proxy listener") - } - }() - - tlsListener := tls.NewListener(proxyListener, tlsConfig) - ws.log.WithField("listen", listen).Info("Starting HTTPS server") - ws.serve(tlsListener) - ws.log.WithField("listen", listen).Info("Stopping HTTPS server") -} diff --git a/website/docs/developer-docs/setup/debugging.md b/website/docs/developer-docs/setup/debugging.md index 7af3eaf0eee1..a321a2124d84 100644 --- a/website/docs/developer-docs/setup/debugging.md +++ b/website/docs/developer-docs/setup/debugging.md @@ -58,6 +58,6 @@ After re-creating the containers with `AUTHENTIK_DEBUGGER` set to `true` and the If the authentik instance is running on a remote server, the `.vscode/launch.json` file needs to be adjusted to point to the IP of the remote server. Alternatively, you can forward the debug port via an SSH tunnel, using `-L 9901:127.0.0.1:9901`. -## authentik Server / Outposts (Golang) +## authentik Outposts (Golang) -Outposts, as well as some auxiliary code of the authentik server, are written in Go. These components can be debugged using standard Golang tooling, such as [Delve](https://github.com/go-delve/delve). +Outposts, except the proxy outpost, are written in Go. These components can be debugged using standard Golang tooling, such as [Delve](https://github.com/go-delve/delve). From a94eb4addbdde2b6b15e955c1d51d3a0b6eb1701 Mon Sep 17 00:00:00 2001 From: Ben Richeson <36977340+Benricheson101@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:18:38 +0200 Subject: [PATCH 08/14] web/elements/table: show scrollbar instead of overflowing off the page (#25232) * web/elements/table: show scrollbar instead of overflowing off the page * web/elements/table: hide overflow-y scrollbar * Tweak alignment. Positioning. --------- Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> --- .../admin/admin-overview/cards/RecentEventsCard.css | 4 ---- web/src/elements/Tabs.css | 4 ---- web/src/elements/table/Table.css | 5 +++++ web/src/styles/authentik/components/Table/table.css | 12 ++++++++++++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/web/src/admin/admin-overview/cards/RecentEventsCard.css b/web/src/admin/admin-overview/cards/RecentEventsCard.css index bbbf3fb7c0e1..877ae6439504 100644 --- a/web/src/admin/admin-overview/cards/RecentEventsCard.css +++ b/web/src/admin/admin-overview/cards/RecentEventsCard.css @@ -3,7 +3,3 @@ --pf-c-card__title--FontSize: var(--pf-global--FontSize--md); --pf-c-card__title--FontWeight: var(--pf-global--FontWeight--bold); } - -[part="table-container"] { - overflow-x: auto; -} diff --git a/web/src/elements/Tabs.css b/web/src/elements/Tabs.css index d84838f37591..05077574b28a 100644 --- a/web/src/elements/Tabs.css +++ b/web/src/elements/Tabs.css @@ -55,10 +55,6 @@ ak-tabs[vertical] { [role="tabpanel"] { padding-inline-start: 0 !important; } - - .pf-c-card__body > *::part(table-container) { - overflow-x: auto; - } } .pf-c-tabs__link { diff --git a/web/src/elements/table/Table.css b/web/src/elements/table/Table.css index 7ea28ab25670..136b43ff48e8 100644 --- a/web/src/elements/table/Table.css +++ b/web/src/elements/table/Table.css @@ -196,6 +196,11 @@ time { display: contents; } +[part="table-container"] { + overflow-x: auto; + overflow-y: hidden; +} + .pf-c-table { @container (width > 1200px) { --pf-c-table--cell--MinWidth: 9em; diff --git a/web/src/styles/authentik/components/Table/table.css b/web/src/styles/authentik/components/Table/table.css index 64775eb10313..8a4ca80a9757 100644 --- a/web/src/styles/authentik/components/Table/table.css +++ b/web/src/styles/authentik/components/Table/table.css @@ -28,6 +28,18 @@ } } +.pf-c-table .pf-c-table__check { + position: sticky; + left: -1px; + background: var(--pf-c-table--BackgroundColor); + z-index: 1; + + @media (width < 768px) { + box-shadow: -0.5px 0px inset var(--pf-global--BorderColor--300); + --pf-c-table--m-compact--cell--first-last-child--PaddingLeft: var(--pf-global--spacer--sm); + } +} + .pf-c-table thead .pf-c-table__check { text-align: center; min-width: 3rem; From 0aeab0f1b7cfa20ac82af169712f73c6a389c2b7 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 17:51:57 +0200 Subject: [PATCH 09/14] tenants/flags: don't mark deprecated flags as required in API (#25389) * tenants/flags: mark deprecated flags as nullable in API Signed-off-by: Marc 'risson' Schmitt * also remove from required Signed-off-by: Marc 'risson' Schmitt * remove nullable Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- authentik/brands/api.py | 2 +- authentik/tenants/api/settings.py | 6 ++++-- packages/client-ts/src/models/CurrentBrandFlags.ts | 12 +++--------- .../src/models/PatchedSettingsRequestFlags.ts | 12 +++--------- schema.yml | 4 ---- 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/authentik/brands/api.py b/authentik/brands/api.py index 7fe246e4d999..74a3fceb3988 100644 --- a/authentik/brands/api.py +++ b/authentik/brands/api.py @@ -118,7 +118,7 @@ def map_serializer_field(self, auto_schema, direction): props[_flag.key]["description"] = _flag.description if _flag.deprecated: props[_flag.key]["deprecated"] = _flag.deprecated - if visibility == "public": + if visibility == "public" and not _flag.deprecated: required.append(_flag.key) return build_object_type(props, required=required) diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index ea4e918e20ff..e67e234a0183 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -19,7 +19,6 @@ class FlagJSONField(JSONDictField): - def to_internal_value(self, data: str): flags = super().to_internal_value(data) for flag in Flag.available(visibility="system", exclude_system=False): @@ -61,6 +60,7 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension): def map_serializer_field(self, auto_schema, direction): props = {} + required = [] for flag in Flag.available(): _flag = flag() props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0]) @@ -68,7 +68,9 @@ def map_serializer_field(self, auto_schema, direction): props[_flag.key]["description"] = _flag.description if _flag.deprecated: props[_flag.key]["deprecated"] = _flag.deprecated - return build_object_type(props, required=props.keys()) + if not _flag.deprecated: + required.append(_flag.key) + return build_object_type(props, required=required) class SettingsSerializer(ModelSerializer): diff --git a/packages/client-ts/src/models/CurrentBrandFlags.ts b/packages/client-ts/src/models/CurrentBrandFlags.ts index 46e50598099f..a276212c0cb2 100644 --- a/packages/client-ts/src/models/CurrentBrandFlags.ts +++ b/packages/client-ts/src/models/CurrentBrandFlags.ts @@ -30,7 +30,7 @@ export interface CurrentBrandFlags { * @memberof CurrentBrandFlags * @deprecated */ - flowsRefreshOthers: boolean; + flowsRefreshOthers?: boolean; } /** @@ -44,13 +44,6 @@ export function instanceOfCurrentBrandFlags(value: object): value is CurrentBran (value as Record)["flows_continuous_login"] === undefined) ) return false; - if ( - (!("flowsRefreshOthers" in (value as Record)) && - !("flows_refresh_others" in (value as Record))) || - ((value as Record)["flowsRefreshOthers"] === undefined && - (value as Record)["flows_refresh_others"] === undefined) - ) - return false; return true; } @@ -67,7 +60,8 @@ export function CurrentBrandFlagsFromJSONTyped( } return { flowsContinuousLogin: json["flows_continuous_login"], - flowsRefreshOthers: json["flows_refresh_others"], + flowsRefreshOthers: + json["flows_refresh_others"] == null ? undefined : json["flows_refresh_others"], }; } diff --git a/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts b/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts index 5c14e03b7f3a..9adfe131cec9 100644 --- a/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts +++ b/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts @@ -42,7 +42,7 @@ export interface PatchedSettingsRequestFlags { * @memberof PatchedSettingsRequestFlags * @deprecated */ - flowsRefreshOthers: boolean; + flowsRefreshOthers?: boolean; } /** @@ -72,13 +72,6 @@ export function instanceOfPatchedSettingsRequestFlags( (value as Record)["flows_continuous_login"] === undefined) ) return false; - if ( - (!("flowsRefreshOthers" in (value as Record)) && - !("flows_refresh_others" in (value as Record))) || - ((value as Record)["flowsRefreshOthers"] === undefined && - (value as Record)["flows_refresh_others"] === undefined) - ) - return false; return true; } @@ -97,7 +90,8 @@ export function PatchedSettingsRequestFlagsFromJSONTyped( coreDefaultAppAccess: json["core_default_app_access"], enterpriseAuditIncludeExpandedDiff: json["enterprise_audit_include_expanded_diff"], flowsContinuousLogin: json["flows_continuous_login"], - flowsRefreshOthers: json["flows_refresh_others"], + flowsRefreshOthers: + json["flows_refresh_others"] == null ? undefined : json["flows_refresh_others"], }; } diff --git a/schema.yml b/schema.yml index 57d04f7ef2a4..606e709148f6 100644 --- a/schema.yml +++ b/schema.yml @@ -39128,7 +39128,6 @@ components: deprecated: true required: - flows_continuous_login - - flows_refresh_others readOnly: true required: - branding_custom_css @@ -53805,7 +53804,6 @@ components: - core_default_app_access - enterprise_audit_include_expanded_diff - flows_continuous_login - - flows_refresh_others PatchedSourceStageRequest: type: object description: SourceStage Serializer @@ -58900,7 +58898,6 @@ components: - core_default_app_access - enterprise_audit_include_expanded_diff - flows_continuous_login - - flows_refresh_others required: - flags SettingsRequest: @@ -58995,7 +58992,6 @@ components: - core_default_app_access - enterprise_audit_include_expanded_diff - flows_continuous_login - - flows_refresh_others required: - flags SeverityEnum: From 3de1bab05840d26e278a208728148d43ef4cfe6e Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 19:17:58 +0200 Subject: [PATCH 10/14] flows: remove RefreshOtherFlowsAfterAuthentication flag (#24998) * wip Signed-off-by: Marc 'risson' Schmitt * more removal Signed-off-by: Marc 'risson' Schmitt * remove device group from websocket Signed-off-by: Marc 'risson' Schmitt * fixup Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- authentik/core/signals.py | 13 ---- authentik/flows/apps.py | 8 --- authentik/root/tests/test_ws_client.py | 63 ------------------- authentik/root/ws/consumer.py | 26 -------- .../client-ts/src/models/CurrentBrandFlags.ts | 10 --- .../src/models/PatchedSettingsRequestFlags.ts | 10 --- schema.yml | 16 ----- tests/e2e/compose.yml | 3 +- .../admin/admin-settings/AdminSettingsForm.ts | 12 ---- web/src/common/ui/config.ts | 1 - web/src/common/ws/events.ts | 22 +------ web/src/flow/FlowExecutor.ts | 12 ---- 12 files changed, 3 insertions(+), 193 deletions(-) diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 020aff059666..228d4d44e3bd 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -3,7 +3,6 @@ from contextlib import contextmanager from contextvars import ContextVar -from channels.layers import get_channel_layer from django.contrib.auth.signals import user_logged_in from django.core.cache import cache from django.db.models import Model @@ -20,9 +19,7 @@ User, default_token_duration, ) -from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication from authentik.lib.models import ExpiringModel -from authentik.root.ws.consumer import build_device_group password_changed = Signal() """Arguments: user: User, password: str""" @@ -97,16 +94,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_): AuthenticatedSession.create_from_request(request, user) - if not RefreshOtherFlowsAfterAuthentication.get(): - return - layer = get_channel_layer() - device_cookie = request.COOKIES.get("authentik_device") - if device_cookie: - layer.group_send_blocking( - build_device_group(device_cookie), - {"type": "event.session.authenticated"}, - ) - @receiver(post_save, sender=User) def user_deactivated_delete_sessions(sender: type[Model], instance: User, **_): diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py index 4339fa792d1a..577fc30d5de1 100644 --- a/authentik/flows/apps.py +++ b/authentik/flows/apps.py @@ -24,14 +24,6 @@ ) -class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"): - - default = False - visibility = "public" - description = _("Refresh other tabs after successful authentication.") - deprecated = True - - class ContinuousLogin(Flag[bool], key="flows_continuous_login"): default = False diff --git a/authentik/root/tests/test_ws_client.py b/authentik/root/tests/test_ws_client.py index 04b49e46b104..6d9344f49670 100644 --- a/authentik/root/tests/test_ws_client.py +++ b/authentik/root/tests/test_ws_client.py @@ -1,9 +1,6 @@ -from unittest.mock import patch - from asgiref.sync import sync_to_async from channels.routing import URLRouter from channels.testing import WebsocketCommunicator -from django.http import HttpRequest from django.test import TransactionTestCase from authentik.core.tests.utils import create_test_user @@ -14,74 +11,14 @@ NotificationTransport, TransportMode, ) -from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication from authentik.lib.generators import generate_id from authentik.root import websocket -from authentik.stages.password import BACKEND_INBUILT -from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE -from authentik.tenants.utils import get_current_tenant class TestClientWS(TransactionTestCase): - def setUp(self): - tenant = get_current_tenant() - tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True - tenant.save() self.user = create_test_user() - async def _alogin_cookie(self, user, **kwargs): - """Similar to `client.aforce_login` but allow setting of cookies""" - from django.contrib.auth import alogin - - # Create a fake request to store login details. - request = HttpRequest() - session = await self.client.asession() - request.session = session - request.COOKIES.update(kwargs) - - await alogin(request, user, BACKEND_INBUILT) - # Save the session values. - await request.session.asave() - self.client._set_login_cookies(request) - - async def test_auth_blank(self): - dev_id = generate_id() - communicator = WebsocketCommunicator( - URLRouter(websocket.websocket_urlpatterns), - "/ws/client/", - headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())], - ) - connected, _ = await communicator.connect() - self.assertTrue(connected) - - await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id}) - - await communicator.receive_nothing() - await communicator.receive_json_from() - await communicator.disconnect() - - async def test_tab_refresh(self): - dev_id = generate_id() - communicator = WebsocketCommunicator( - URLRouter(websocket.websocket_urlpatterns), - "/ws/client/", - headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())], - ) - connected, _ = await communicator.connect() - self.assertTrue(connected) - - with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag: - flag.return_value = True - await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id}) - - evt = await communicator.receive_json_from() - self.assertEqual( - evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"} - ) - - await communicator.disconnect() - async def test_notification(self): communicator = WebsocketCommunicator( URLRouter(websocket.websocket_urlpatterns), "/ws/client/" diff --git a/authentik/root/ws/consumer.py b/authentik/root/ws/consumer.py index afb525c15a58..38e755b21d96 100644 --- a/authentik/root/ws/consumer.py +++ b/authentik/root/ws/consumer.py @@ -11,18 +11,6 @@ from authentik.root.ws.storage import CACHE_PREFIX -def build_session_group(session_key: str): - return sha256( - f"{connection.schema_name}/group_client_session_{str(session_key)}".encode() - ).hexdigest() - - -def build_device_group(device_id: str): - return sha256( - f"{connection.schema_name}/group_client_device_{str(device_id)}".encode() - ).hexdigest() - - def build_user_group(user: User): return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest() @@ -32,7 +20,6 @@ class MessageConsumer(JsonWebsocketConsumer): channel_name is saved into cache with user_id, and when a add_message is called""" session_key: str - device_cookie: str | None = None user: User | None = None def connect(self): @@ -45,19 +32,10 @@ def connect(self): async_to_sync(self.channel_layer.group_add)( build_user_group(user), self.channel_name ) - if device_cookie := self.scope["cookies"].get("authentik_device", None): - self.device_cookie = device_cookie - async_to_sync(self.channel_layer.group_add)( - build_device_group(self.device_cookie), self.channel_name - ) def disconnect(self, code): if self.session_key: cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}") - if self.device_cookie: - async_to_sync(self.channel_layer.group_discard)( - build_device_group(self.device_cookie), self.channel_name - ) if self.user: async_to_sync(self.channel_layer.group_discard)( build_user_group(self.user), self.channel_name @@ -67,10 +45,6 @@ def event_message(self, event: dict): """Event handler which is called by Messages Storage backend""" self.send_json(event) - def event_session_authenticated(self, event: dict): - """Event handler post user authentication""" - self.send_json({"message_type": "session.authenticated", **event}) - def event_notification(self, event: dict): """Event handler for new notifications""" self.send_json({"message_type": "notification.new", **event}) diff --git a/packages/client-ts/src/models/CurrentBrandFlags.ts b/packages/client-ts/src/models/CurrentBrandFlags.ts index a276212c0cb2..ac552a6c0e93 100644 --- a/packages/client-ts/src/models/CurrentBrandFlags.ts +++ b/packages/client-ts/src/models/CurrentBrandFlags.ts @@ -24,13 +24,6 @@ export interface CurrentBrandFlags { * @memberof CurrentBrandFlags */ flowsContinuousLogin: boolean; - /** - * Refresh other tabs after successful authentication. - * @type {boolean} - * @memberof CurrentBrandFlags - * @deprecated - */ - flowsRefreshOthers?: boolean; } /** @@ -60,8 +53,6 @@ export function CurrentBrandFlagsFromJSONTyped( } return { flowsContinuousLogin: json["flows_continuous_login"], - flowsRefreshOthers: - json["flows_refresh_others"] == null ? undefined : json["flows_refresh_others"], }; } @@ -79,6 +70,5 @@ export function CurrentBrandFlagsToJSONTyped( return { flows_continuous_login: value["flowsContinuousLogin"], - flows_refresh_others: value["flowsRefreshOthers"], }; } diff --git a/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts b/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts index 9adfe131cec9..288cb3b3ef11 100644 --- a/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts +++ b/packages/client-ts/src/models/PatchedSettingsRequestFlags.ts @@ -36,13 +36,6 @@ export interface PatchedSettingsRequestFlags { * @memberof PatchedSettingsRequestFlags */ flowsContinuousLogin: boolean; - /** - * Refresh other tabs after successful authentication. - * @type {boolean} - * @memberof PatchedSettingsRequestFlags - * @deprecated - */ - flowsRefreshOthers?: boolean; } /** @@ -90,8 +83,6 @@ export function PatchedSettingsRequestFlagsFromJSONTyped( coreDefaultAppAccess: json["core_default_app_access"], enterpriseAuditIncludeExpandedDiff: json["enterprise_audit_include_expanded_diff"], flowsContinuousLogin: json["flows_continuous_login"], - flowsRefreshOthers: - json["flows_refresh_others"] == null ? undefined : json["flows_refresh_others"], }; } @@ -111,6 +102,5 @@ export function PatchedSettingsRequestFlagsToJSONTyped( core_default_app_access: value["coreDefaultAppAccess"], enterprise_audit_include_expanded_diff: value["enterpriseAuditIncludeExpandedDiff"], flows_continuous_login: value["flowsContinuousLogin"], - flows_refresh_others: value["flowsRefreshOthers"], }; } diff --git a/schema.yml b/schema.yml index 606e709148f6..28f78449821e 100644 --- a/schema.yml +++ b/schema.yml @@ -39122,10 +39122,6 @@ components: type: boolean description: Upon successful authentication, re-start authentication in other open tabs. - flows_refresh_others: - type: boolean - description: Refresh other tabs after successful authentication. - deprecated: true required: - flows_continuous_login readOnly: true @@ -53796,10 +53792,6 @@ components: type: boolean description: Upon successful authentication, re-start authentication in other open tabs. - flows_refresh_others: - type: boolean - description: Refresh other tabs after successful authentication. - deprecated: true required: - core_default_app_access - enterprise_audit_include_expanded_diff @@ -58890,10 +58882,6 @@ components: type: boolean description: Upon successful authentication, re-start authentication in other open tabs. - flows_refresh_others: - type: boolean - description: Refresh other tabs after successful authentication. - deprecated: true required: - core_default_app_access - enterprise_audit_include_expanded_diff @@ -58984,10 +58972,6 @@ components: type: boolean description: Upon successful authentication, re-start authentication in other open tabs. - flows_refresh_others: - type: boolean - description: Refresh other tabs after successful authentication. - deprecated: true required: - core_default_app_access - enterprise_audit_include_expanded_diff diff --git a/tests/e2e/compose.yml b/tests/e2e/compose.yml index c956d6b12f0f..090902694357 100644 --- a/tests/e2e/compose.yml +++ b/tests/e2e/compose.yml @@ -1,6 +1,7 @@ services: chromium: - image: ghcr.io/goauthentik/selenium:150.0-ak-0.60.1 + # Needed for API changes. Bump to a real tag when platform > 0.60.1 + image: ghcr.io/goauthentik/selenium:150.0-ak-0.60.1@sha256:e2c1b84379b5a0ec21141ea565f823a437c761db6338a03a127c931ee62b241a shm_size: 2g network_mode: host restart: always diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts index 2e90b2d21105..cb8adc144aa7 100644 --- a/web/src/admin/admin-settings/AdminSettingsForm.ts +++ b/web/src/admin/admin-settings/AdminSettingsForm.ts @@ -292,18 +292,6 @@ export class AdminSettingsForm extends Form { )} >
- - ${msg("This flag is deprecated.")} - `} - > - { - if (!document.hidden) { - return; - } - - console.debug("authentik/ws: Reloading after session authenticated event"); - window.location.reload(); - }; - private setFlowErrorChallenge(error: APIError) { this.challenge = { component: "ak-stage-flow-error", From 463315bee7caa0cec4797c2c1eda5824614d11a4 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 19:17:59 +0200 Subject: [PATCH 11/14] flows: send messages with the responses (#24999) * wip Signed-off-by: Marc 'risson' Schmitt * make it more generic Signed-off-by: Marc 'risson' Schmitt * Apply suggestion from @BeryJu Signed-off-by: Jens L. * fixup Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt Signed-off-by: Jens L. Co-authored-by: Jens L. --- authentik/flows/challenge.py | 13 +- authentik/flows/stage.py | 8 +- authentik/flows/tests/test_executor.py | 1 + authentik/flows/tests/test_inspector.py | 1 + authentik/flows/tests/test_messages.py | 116 +++++++++++ .../oauth2/tests/test_device_init.py | 1 + authentik/root/settings.py | 1 + authentik/root/ws/storage.py | 1 - .../client-go/model_contextual_flow_info.go | 37 ++++ packages/client-go/model_flow_message.go | 196 ++++++++++++++++++ .../model_flow_message_level_enum.go | 117 +++++++++++ .../src/models/contextual_flow_info.rs | 3 + .../client-rust/src/models/flow_message.rs | 27 +++ .../src/models/flow_message_level_enum.rs | 44 ++++ packages/client-rust/src/models/mod.rs | 4 + .../src/models/ContextualFlowInfo.ts | 16 ++ packages/client-ts/src/models/FlowMessage.ts | 77 +++++++ .../src/models/FlowMessageLevelEnum.ts | 60 ++++++ packages/client-ts/src/models/index.ts | 2 + schema.yml | 23 ++ 20 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 authentik/flows/tests/test_messages.py create mode 100644 packages/client-go/model_flow_message.go create mode 100644 packages/client-go/model_flow_message_level_enum.go create mode 100644 packages/client-rust/src/models/flow_message.rs create mode 100644 packages/client-rust/src/models/flow_message_level_enum.rs create mode 100644 packages/client-ts/src/models/FlowMessage.ts create mode 100644 packages/client-ts/src/models/FlowMessageLevelEnum.ts diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index 8b80bcbf9f17..1c4be6ff0bb0 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, TypedDict from uuid import UUID +from django.contrib.messages import DEFAULT_TAGS from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.http import JsonResponse @@ -42,6 +43,16 @@ class ErrorDetailSerializer(PassiveSerializer): code = CharField() +FLOW_MESSAGE_LEVELS = list(DEFAULT_TAGS.values()) + + +class FlowMessageSerializer(PassiveSerializer): + """Serializer for a django.contrib.messages message""" + + level = ChoiceField(choices=FLOW_MESSAGE_LEVELS, source="level_tag") + message = CharField() + + class ContextualFlowInfo(PassiveSerializer): """Contextual flow information for a challenge""" @@ -50,6 +61,7 @@ class ContextualFlowInfo(PassiveSerializer): background_themed_urls = ThemedUrlsSerializer(required=False, allow_null=True) cancel_url = CharField() layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout]) + messages = FlowMessageSerializer(many=True, required=False) class Challenge(PassiveSerializer): @@ -179,7 +191,6 @@ class FrameChallenge(Challenge): class FrameChallengeResponse(ChallengeResponse): - component = CharField(default="xak-flow-frame") diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py index be48733ae161..d805eb10e897 100644 --- a/authentik/flows/stage.py +++ b/authentik/flows/stage.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth.models import AnonymousUser +from django.contrib.messages import get_messages from django.http import HttpRequest from django.http.request import QueryDict from django.http.response import HttpResponse @@ -22,6 +23,7 @@ Challenge, ChallengeResponse, ContextualFlowInfo, + FlowMessageSerializer, HttpChallengeResponse, RedirectChallenge, SessionEndChallenge, @@ -194,6 +196,9 @@ def _get_challenge(self, *args, **kwargs) -> Challenge: if not hasattr(challenge, "initial_data"): challenge.initial_data = {} if "flow_info" not in challenge.initial_data: + messages = [] + if self.request is not None and not isinstance(challenge, RedirectChallenge): + messages = get_messages(self.request) # Flow payloads can outlive the previous signed media JWT, so # refreshes must mint fresh URLs instead of reusing cached ones. flow_info = ContextualFlowInfo( @@ -205,7 +210,8 @@ def _get_challenge(self, *args, **kwargs) -> Challenge: ), "cancel_url": self.cancel_url, "layout": self.executor.flow.layout, - } + "messages": FlowMessageSerializer(messages, many=True).data, + }, ) flow_info.is_valid() challenge.initial_data["flow_info"] = flow_info.data diff --git a/authentik/flows/tests/test_executor.py b/authentik/flows/tests/test_executor.py index 4f15ff70e7a4..cd448c0ea2fb 100644 --- a/authentik/flows/tests/test_executor.py +++ b/authentik/flows/tests/test_executor.py @@ -822,6 +822,7 @@ def test_cancel_next(self): "cancel_url": "/flows/-/cancel/?next=%2Ffoo", "layout": "stacked", "title": flow.title, + "messages": [], }, ) diff --git a/authentik/flows/tests/test_inspector.py b/authentik/flows/tests/test_inspector.py index 2bf3dc2b6e30..be7c8fd1ce9c 100644 --- a/authentik/flows/tests/test_inspector.py +++ b/authentik/flows/tests/test_inspector.py @@ -55,6 +55,7 @@ def test(self): "cancel_url": reverse("authentik_flows:cancel"), "title": flow.title, "layout": "stacked", + "messages": [], }, "flow_designation": "authentication", "passkey_challenge": None, diff --git a/authentik/flows/tests/test_messages.py b/authentik/flows/tests/test_messages.py new file mode 100644 index 000000000000..ced923b25a21 --- /dev/null +++ b/authentik/flows/tests/test_messages.py @@ -0,0 +1,116 @@ +"""Tests for messages attached to challenges""" + +from django.contrib.messages import add_message +from django.contrib.messages.constants import SUCCESS, WARNING +from django.contrib.messages.storage.session import SessionStorage +from django.http import HttpRequest, HttpResponse +from django.urls import reverse + +from authentik.core.tests.utils import create_test_flow +from authentik.flows.challenge import Challenge, ChallengeResponse +from authentik.flows.models import FlowStageBinding, in_memory_stage +from authentik.flows.planner import FlowPlan +from authentik.flows.stage import ChallengeStageView, StageView +from authentik.flows.tests import FlowTestCase +from authentik.stages.dummy.models import DummyStage + + +class MessageStageView(StageView): + """Stage which queues a message and continues to the next stage""" + + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + add_message(request, SUCCESS, "stage message") + return self.executor.stage_ok() + + +class MessageChallengeStageView(ChallengeStageView): + """Stage which queues a message while rendering its challenge""" + + def get_challenge(self, *args, **kwargs) -> Challenge: + add_message(self.request, WARNING, "challenge message") + return Challenge(data={"component": "ak-stage-dummy"}) + + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + return self.executor.stage_ok() + + +class TestFlowMessages(FlowTestCase): + """Test messages attached to challenges""" + + def setUp(self): + self.flow = create_test_flow() + self.url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + + def test_challenge(self): + """Test message queued while the challenge is rendered""" + plan = FlowPlan(flow_pk=self.flow.pk.hex) + plan.append_stage(in_memory_stage(MessageChallengeStageView)) + self.set_flow_plan(plan) + + response = self.client.get(self.url) + raw_response = self.assertStageResponse(response, self.flow) + self.assertEqual( + raw_response["flow_info"]["messages"], + [{"level": "warning", "message": "challenge message"}], + ) + + def test_challenge_not_repeated(self): + """Test that a message is only ever attached to a single challenge""" + plan = FlowPlan(flow_pk=self.flow.pk.hex) + plan.append_stage(in_memory_stage(MessageChallengeStageView)) + self.set_flow_plan(plan) + + self.client.get(self.url) + # The stage queues a new message on every render, so the second challenge only + # contains the message queued for it + response = self.client.get(self.url) + raw_response = self.assertStageResponse(response, self.flow) + self.assertEqual( + raw_response["flow_info"]["messages"], + [{"level": "warning", "message": "challenge message"}], + ) + + def test_previous_stage(self): + """Test message queued by a stage which doesn't render a challenge itself, it is + attached to the challenge of the next stage""" + FlowStageBinding.objects.create( + target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0 + ) + plan = FlowPlan(flow_pk=self.flow.pk.hex) + plan.append_stage(in_memory_stage(MessageStageView)) + plan.append(FlowStageBinding.objects.filter(target=self.flow).first()) + self.set_flow_plan(plan) + + response = self.client.get(self.url, follow=True) + raw_response = self.assertStageResponse( + response, + self.flow, + component="ak-stage-dummy", + ) + self.assertEqual( + raw_response["flow_info"]["messages"], + [{"level": "success", "message": "stage message"}], + ) + + def test_redirect_challenge(self): + """Test message queued by the last stage of a flow. The client navigates away as soon + as it gets the redirect challenge the flow finishes with, so the message is left + queued for the page we redirect to""" + plan = FlowPlan(flow_pk=self.flow.pk.hex) + plan.append_stage(in_memory_stage(MessageStageView)) + self.set_flow_plan(plan) + + response = self.client.get(self.url) + raw_response = self.assertStageResponse(response, component="xak-flow-redirect") + self.assertNotIn("messages", raw_response.get("flow_info", {})) + self.assertIn("stage message", self.client.session[SessionStorage.session_key]) + + def test_no_messages(self): + """Test that challenges without messages have an empty list""" + FlowStageBinding.objects.create( + target=self.flow, stage=DummyStage.objects.create(name="dummy"), order=0 + ) + + response = self.client.get(self.url) + raw_response = self.assertStageResponse(response) + self.assertEqual(raw_response["flow_info"]["messages"], []) diff --git a/authentik/providers/oauth2/tests/test_device_init.py b/authentik/providers/oauth2/tests/test_device_init.py index 067a324fd190..1b62359a510b 100644 --- a/authentik/providers/oauth2/tests/test_device_init.py +++ b/authentik/providers/oauth2/tests/test_device_init.py @@ -93,6 +93,7 @@ def test_device_init_post(self): "cancel_url": "/flows/-/cancel/", "layout": "stacked", "title": self.device_flow.title, + "messages": [], }, }, ) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index 7141564b6abe..c3b9360c1dc1 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -193,6 +193,7 @@ "EventActions": "authentik.events.models.EventAction", "FlowDesignationEnum": "authentik.flows.models.FlowDesignation", "FlowLayoutEnum": "authentik.flows.models.FlowLayout", + "FlowMessageLevelEnum": "authentik.flows.challenge.FLOW_MESSAGE_LEVELS", "LDAPAPIAccessMode": "authentik.providers.ldap.models.APIAccessMode", "ModelEnum": "authentik.lib.api.Models", "OffboardingActionEnum": ( diff --git a/authentik/root/ws/storage.py b/authentik/root/ws/storage.py index 6ac974fe99bf..33d3c63f0551 100644 --- a/authentik/root/ws/storage.py +++ b/authentik/root/ws/storage.py @@ -7,7 +7,6 @@ from django.core.cache import cache from django.http.request import HttpRequest -SESSION_KEY = "_messages" CACHE_PREFIX = "goauthentik.io/root/messages_" diff --git a/packages/client-go/model_contextual_flow_info.go b/packages/client-go/model_contextual_flow_info.go index 31cb8754cf26..021ff11c4f00 100644 --- a/packages/client-go/model_contextual_flow_info.go +++ b/packages/client-go/model_contextual_flow_info.go @@ -26,6 +26,7 @@ type ContextualFlowInfo struct { BackgroundThemedUrls NullableThemedUrls `json:"background_themed_urls,omitempty"` CancelUrl string `json:"cancel_url"` Layout ContextualFlowInfoLayoutEnum `json:"layout"` + Messages []FlowMessage `json:"messages,omitempty"` AdditionalProperties map[string]interface{} } @@ -205,6 +206,38 @@ func (o *ContextualFlowInfo) SetLayout(v ContextualFlowInfoLayoutEnum) { o.Layout = v } +// GetMessages returns the Messages field value if set, zero value otherwise. +func (o *ContextualFlowInfo) GetMessages() []FlowMessage { + if o == nil || IsNil(o.Messages) { + var ret []FlowMessage + return ret + } + return o.Messages +} + +// GetMessagesOk returns a tuple with the Messages field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ContextualFlowInfo) GetMessagesOk() ([]FlowMessage, bool) { + if o == nil || IsNil(o.Messages) { + return nil, false + } + return o.Messages, true +} + +// HasMessages returns a boolean if a field has been set. +func (o *ContextualFlowInfo) HasMessages() bool { + if o != nil && !IsNil(o.Messages) { + return true + } + + return false +} + +// SetMessages gets a reference to the given []FlowMessage and assigns it to the Messages field. +func (o *ContextualFlowInfo) SetMessages(v []FlowMessage) { + o.Messages = v +} + func (o ContextualFlowInfo) MarshalJSON() ([]byte, error) { toSerialize, err := o.ToMap() if err != nil { @@ -226,6 +259,9 @@ func (o ContextualFlowInfo) ToMap() (map[string]interface{}, error) { } toSerialize["cancel_url"] = o.CancelUrl toSerialize["layout"] = o.Layout + if !IsNil(o.Messages) { + toSerialize["messages"] = o.Messages + } for key, value := range o.AdditionalProperties { toSerialize[key] = value @@ -275,6 +311,7 @@ func (o *ContextualFlowInfo) UnmarshalJSON(data []byte) (err error) { delete(additionalProperties, "background_themed_urls") delete(additionalProperties, "cancel_url") delete(additionalProperties, "layout") + delete(additionalProperties, "messages") o.AdditionalProperties = additionalProperties } diff --git a/packages/client-go/model_flow_message.go b/packages/client-go/model_flow_message.go new file mode 100644 index 000000000000..f655e143ee96 --- /dev/null +++ b/packages/client-go/model_flow_message.go @@ -0,0 +1,196 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.11.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// checks if the FlowMessage type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &FlowMessage{} + +// FlowMessage Serializer for a django.contrib.messages message +type FlowMessage struct { + Level FlowMessageLevelEnum `json:"level"` + Message string `json:"message"` + AdditionalProperties map[string]interface{} +} + +type _FlowMessage FlowMessage + +// NewFlowMessage instantiates a new FlowMessage object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewFlowMessage(level FlowMessageLevelEnum, message string) *FlowMessage { + this := FlowMessage{} + this.Level = level + this.Message = message + return &this +} + +// NewFlowMessageWithDefaults instantiates a new FlowMessage object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewFlowMessageWithDefaults() *FlowMessage { + this := FlowMessage{} + return &this +} + +// GetLevel returns the Level field value +func (o *FlowMessage) GetLevel() FlowMessageLevelEnum { + if o == nil { + var ret FlowMessageLevelEnum + return ret + } + + return o.Level +} + +// GetLevelOk returns a tuple with the Level field value +// and a boolean to check if the value has been set. +func (o *FlowMessage) GetLevelOk() (*FlowMessageLevelEnum, bool) { + if o == nil { + return nil, false + } + return &o.Level, true +} + +// SetLevel sets field value +func (o *FlowMessage) SetLevel(v FlowMessageLevelEnum) { + o.Level = v +} + +// GetMessage returns the Message field value +func (o *FlowMessage) GetMessage() string { + if o == nil { + var ret string + return ret + } + + return o.Message +} + +// GetMessageOk returns a tuple with the Message field value +// and a boolean to check if the value has been set. +func (o *FlowMessage) GetMessageOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Message, true +} + +// SetMessage sets field value +func (o *FlowMessage) SetMessage(v string) { + o.Message = v +} + +func (o FlowMessage) MarshalJSON() ([]byte, error) { + toSerialize, err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o FlowMessage) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["level"] = o.Level + toSerialize["message"] = o.Message + + for key, value := range o.AdditionalProperties { + toSerialize[key] = value + } + + return toSerialize, nil +} + +func (o *FlowMessage) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "level", + "message", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err + } + + for _, requiredProperty := range requiredProperties { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varFlowMessage := _FlowMessage{} + + err = json.Unmarshal(data, &varFlowMessage) + + if err != nil { + return err + } + + *o = FlowMessage(varFlowMessage) + + additionalProperties := make(map[string]interface{}) + + if err = json.Unmarshal(data, &additionalProperties); err == nil { + delete(additionalProperties, "level") + delete(additionalProperties, "message") + o.AdditionalProperties = additionalProperties + } + + return err +} + +type NullableFlowMessage struct { + value *FlowMessage + isSet bool +} + +func (v NullableFlowMessage) Get() *FlowMessage { + return v.value +} + +func (v *NullableFlowMessage) Set(val *FlowMessage) { + v.value = val + v.isSet = true +} + +func (v NullableFlowMessage) IsSet() bool { + return v.isSet +} + +func (v *NullableFlowMessage) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableFlowMessage(val *FlowMessage) *NullableFlowMessage { + return &NullableFlowMessage{value: val, isSet: true} +} + +func (v NullableFlowMessage) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableFlowMessage) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-go/model_flow_message_level_enum.go b/packages/client-go/model_flow_message_level_enum.go new file mode 100644 index 000000000000..b0dfedb31529 --- /dev/null +++ b/packages/client-go/model_flow_message_level_enum.go @@ -0,0 +1,117 @@ +/* +authentik + +Making authentication simple. + +API version: 2026.11.0-rc1 +Contact: hello@goauthentik.io +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package api + +import ( + "encoding/json" + "fmt" +) + +// FlowMessageLevelEnum the model 'FlowMessageLevelEnum' +type FlowMessageLevelEnum string + +// List of FlowMessageLevelEnum +const ( + FLOWMESSAGELEVELENUM_DEBUG FlowMessageLevelEnum = "debug" + FLOWMESSAGELEVELENUM_INFO FlowMessageLevelEnum = "info" + FLOWMESSAGELEVELENUM_SUCCESS FlowMessageLevelEnum = "success" + FLOWMESSAGELEVELENUM_WARNING FlowMessageLevelEnum = "warning" + FLOWMESSAGELEVELENUM_ERROR FlowMessageLevelEnum = "error" +) + +// All allowed values of FlowMessageLevelEnum enum +var AllowedFlowMessageLevelEnumEnumValues = []FlowMessageLevelEnum{ + "debug", + "info", + "success", + "warning", + "error", +} + +func (v *FlowMessageLevelEnum) UnmarshalJSON(src []byte) error { + var value string + err := json.Unmarshal(src, &value) + if err != nil { + return err + } + enumTypeValue := FlowMessageLevelEnum(value) + for _, existing := range AllowedFlowMessageLevelEnumEnumValues { + if existing == enumTypeValue { + *v = enumTypeValue + return nil + } + } + + return fmt.Errorf("%+v is not a valid FlowMessageLevelEnum", value) +} + +// NewFlowMessageLevelEnumFromValue returns a pointer to a valid FlowMessageLevelEnum +// for the value passed as argument, or an error if the value passed is not allowed by the enum +func NewFlowMessageLevelEnumFromValue(v string) (*FlowMessageLevelEnum, error) { + ev := FlowMessageLevelEnum(v) + if ev.IsValid() { + return &ev, nil + } else { + return nil, fmt.Errorf("invalid value '%v' for FlowMessageLevelEnum: valid values are %v", v, AllowedFlowMessageLevelEnumEnumValues) + } +} + +// IsValid return true if the value is valid for the enum, false otherwise +func (v FlowMessageLevelEnum) IsValid() bool { + for _, existing := range AllowedFlowMessageLevelEnumEnumValues { + if existing == v { + return true + } + } + return false +} + +// Ptr returns reference to FlowMessageLevelEnum value +func (v FlowMessageLevelEnum) Ptr() *FlowMessageLevelEnum { + return &v +} + +type NullableFlowMessageLevelEnum struct { + value *FlowMessageLevelEnum + isSet bool +} + +func (v NullableFlowMessageLevelEnum) Get() *FlowMessageLevelEnum { + return v.value +} + +func (v *NullableFlowMessageLevelEnum) Set(val *FlowMessageLevelEnum) { + v.value = val + v.isSet = true +} + +func (v NullableFlowMessageLevelEnum) IsSet() bool { + return v.isSet +} + +func (v *NullableFlowMessageLevelEnum) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableFlowMessageLevelEnum(val *FlowMessageLevelEnum) *NullableFlowMessageLevelEnum { + return &NullableFlowMessageLevelEnum{value: val, isSet: true} +} + +func (v NullableFlowMessageLevelEnum) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableFlowMessageLevelEnum) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} diff --git a/packages/client-rust/src/models/contextual_flow_info.rs b/packages/client-rust/src/models/contextual_flow_info.rs index a5d2748ec20f..ed4bf7cb42cf 100644 --- a/packages/client-rust/src/models/contextual_flow_info.rs +++ b/packages/client-rust/src/models/contextual_flow_info.rs @@ -28,6 +28,8 @@ pub struct ContextualFlowInfo { pub cancel_url: String, #[serde(rename = "layout")] pub layout: models::ContextualFlowInfoLayoutEnum, + #[serde(rename = "messages", skip_serializing_if = "Option::is_none")] + pub messages: Option>, } impl ContextualFlowInfo { @@ -42,6 +44,7 @@ impl ContextualFlowInfo { background_themed_urls: None, cancel_url, layout, + messages: None, } } } diff --git a/packages/client-rust/src/models/flow_message.rs b/packages/client-rust/src/models/flow_message.rs new file mode 100644 index 000000000000..980034bc5819 --- /dev/null +++ b/packages/client-rust/src/models/flow_message.rs @@ -0,0 +1,27 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.11.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// FlowMessage : Serializer for a django.contrib.messages message +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct FlowMessage { + #[serde(rename = "level")] + pub level: models::FlowMessageLevelEnum, + #[serde(rename = "message")] + pub message: String, +} + +impl FlowMessage { + /// Serializer for a django.contrib.messages message + pub fn new(level: models::FlowMessageLevelEnum, message: String) -> FlowMessage { + FlowMessage { level, message } + } +} diff --git a/packages/client-rust/src/models/flow_message_level_enum.rs b/packages/client-rust/src/models/flow_message_level_enum.rs new file mode 100644 index 000000000000..907691267940 --- /dev/null +++ b/packages/client-rust/src/models/flow_message_level_enum.rs @@ -0,0 +1,44 @@ +// authentik +// +// Making authentication simple. +// +// The version of the OpenAPI document: 2026.11.0-rc1 +// Contact: hello@goauthentik.io +// Generated by: https://openapi-generator.tech + +use serde::{Deserialize, Serialize}; + +use crate::models; + +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum FlowMessageLevelEnum { + #[serde(rename = "debug")] + Debug, + #[serde(rename = "info")] + Info, + #[serde(rename = "success")] + Success, + #[serde(rename = "warning")] + Warning, + #[serde(rename = "error")] + Error, +} + +impl std::fmt::Display for FlowMessageLevelEnum { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Debug => write!(f, "debug"), + Self::Info => write!(f, "info"), + Self::Success => write!(f, "success"), + Self::Warning => write!(f, "warning"), + Self::Error => write!(f, "error"), + } + } +} + +impl Default for FlowMessageLevelEnum { + fn default() -> FlowMessageLevelEnum { + Self::Debug + } +} diff --git a/packages/client-rust/src/models/mod.rs b/packages/client-rust/src/models/mod.rs index 04c024b7d4f5..354e70ea1439 100644 --- a/packages/client-rust/src/models/mod.rs +++ b/packages/client-rust/src/models/mod.rs @@ -98,6 +98,10 @@ pub mod flow_designation_enum; pub use self::flow_designation_enum::FlowDesignationEnum; pub mod flow_error_challenge; pub use self::flow_error_challenge::FlowErrorChallenge; +pub mod flow_message; +pub use self::flow_message::FlowMessage; +pub mod flow_message_level_enum; +pub use self::flow_message_level_enum::FlowMessageLevelEnum; pub mod frame_challenge; pub use self::frame_challenge::FrameChallenge; pub mod frame_challenge_response_request; diff --git a/packages/client-ts/src/models/ContextualFlowInfo.ts b/packages/client-ts/src/models/ContextualFlowInfo.ts index a7c8fdcda78a..22e045f8c0c4 100644 --- a/packages/client-ts/src/models/ContextualFlowInfo.ts +++ b/packages/client-ts/src/models/ContextualFlowInfo.ts @@ -17,6 +17,8 @@ import { ContextualFlowInfoLayoutEnumFromJSON, ContextualFlowInfoLayoutEnumToJSON, } from "./ContextualFlowInfoLayoutEnum"; +import type { FlowMessage } from "./FlowMessage"; +import { FlowMessageFromJSON, FlowMessageToJSON } from "./FlowMessage"; import type { ThemedUrls } from "./ThemedUrls"; import { ThemedUrlsFromJSON, ThemedUrlsToJSON } from "./ThemedUrls"; @@ -56,6 +58,12 @@ export interface ContextualFlowInfo { * @memberof ContextualFlowInfo */ layout: ContextualFlowInfoLayoutEnum; + /** + * + * @type {Array} + * @memberof ContextualFlowInfo + */ + messages?: Array; } /** @@ -95,6 +103,10 @@ export function ContextualFlowInfoFromJSONTyped( : ThemedUrlsFromJSON(json["background_themed_urls"]), cancelUrl: json["cancel_url"], layout: ContextualFlowInfoLayoutEnumFromJSON(json["layout"]), + messages: + json["messages"] == null + ? undefined + : (json["messages"] as Array).map(FlowMessageFromJSON), }; } @@ -116,5 +128,9 @@ export function ContextualFlowInfoToJSONTyped( background_themed_urls: ThemedUrlsToJSON(value["backgroundThemedUrls"]), cancel_url: value["cancelUrl"], layout: ContextualFlowInfoLayoutEnumToJSON(value["layout"]), + messages: + value["messages"] == null + ? undefined + : (value["messages"] as Array).map(FlowMessageToJSON), }; } diff --git a/packages/client-ts/src/models/FlowMessage.ts b/packages/client-ts/src/models/FlowMessage.ts new file mode 100644 index 000000000000..56a92cb31c37 --- /dev/null +++ b/packages/client-ts/src/models/FlowMessage.ts @@ -0,0 +1,77 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.11.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { FlowMessageLevelEnum } from "./FlowMessageLevelEnum"; +import { FlowMessageLevelEnumFromJSON, FlowMessageLevelEnumToJSON } from "./FlowMessageLevelEnum"; + +/** + * Serializer for a django.contrib.messages message + * @export + * @interface FlowMessage + */ +export interface FlowMessage { + /** + * + * @type {FlowMessageLevelEnum} + * @memberof FlowMessage + */ + level: FlowMessageLevelEnum; + /** + * + * @type {string} + * @memberof FlowMessage + */ + message: string; +} + +/** + * Check if a given object implements the FlowMessage interface. + */ +export function instanceOfFlowMessage(value: object): value is FlowMessage { + if (!("level" in value) || value["level"] === undefined) return false; + if (!("message" in value) || value["message"] === undefined) return false; + return true; +} + +export function FlowMessageFromJSON(json: any): FlowMessage { + return FlowMessageFromJSONTyped(json, false); +} + +export function FlowMessageFromJSONTyped(json: any, ignoreDiscriminator: boolean): FlowMessage { + if (json == null) { + return json; + } + return { + level: FlowMessageLevelEnumFromJSON(json["level"]), + message: json["message"], + }; +} + +export function FlowMessageToJSON(json: any): FlowMessage { + return FlowMessageToJSONTyped(json, false); +} + +export function FlowMessageToJSONTyped( + value?: FlowMessage | null, + ignoreDiscriminator: boolean = false, +): any { + if (value == null) { + return value; + } + + return { + level: FlowMessageLevelEnumToJSON(value["level"]), + message: value["message"], + }; +} diff --git a/packages/client-ts/src/models/FlowMessageLevelEnum.ts b/packages/client-ts/src/models/FlowMessageLevelEnum.ts new file mode 100644 index 000000000000..d1d1e352b827 --- /dev/null +++ b/packages/client-ts/src/models/FlowMessageLevelEnum.ts @@ -0,0 +1,60 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * authentik + * Making authentication simple. + * + * The version of the OpenAPI document: 2026.11.0-rc1 + * Contact: hello@goauthentik.io + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + */ +export const FlowMessageLevelEnum = { + Debug: "debug", + Info: "info", + Success: "success", + Warning: "warning", + Error: "error", + UnknownDefaultOpenApi: "11184809", +} as const; +export type FlowMessageLevelEnum = (typeof FlowMessageLevelEnum)[keyof typeof FlowMessageLevelEnum]; + +export function instanceOfFlowMessageLevelEnum(value: any): boolean { + for (const key in FlowMessageLevelEnum) { + if (Object.prototype.hasOwnProperty.call(FlowMessageLevelEnum, key)) { + if (FlowMessageLevelEnum[key as keyof typeof FlowMessageLevelEnum] === value) { + return true; + } + } + } + return false; +} + +export function FlowMessageLevelEnumFromJSON(json: any): FlowMessageLevelEnum { + return FlowMessageLevelEnumFromJSONTyped(json, false); +} + +export function FlowMessageLevelEnumFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): FlowMessageLevelEnum { + return json as FlowMessageLevelEnum; +} + +export function FlowMessageLevelEnumToJSON(value?: FlowMessageLevelEnum | null): any { + return value as any; +} + +export function FlowMessageLevelEnumToJSONTyped( + value: any, + ignoreDiscriminator: boolean, +): FlowMessageLevelEnum { + return value as FlowMessageLevelEnum; +} diff --git a/packages/client-ts/src/models/index.ts b/packages/client-ts/src/models/index.ts index 425bc2da9cd4..62996e882898 100644 --- a/packages/client-ts/src/models/index.ts +++ b/packages/client-ts/src/models/index.ts @@ -202,6 +202,8 @@ export * from "./FlowErrorChallenge"; export * from "./FlowInspection"; export * from "./FlowInspectorPlan"; export * from "./FlowLayoutEnum"; +export * from "./FlowMessage"; +export * from "./FlowMessageLevelEnum"; export * from "./FlowRequest"; export * from "./FlowSet"; export * from "./FlowStageBinding"; diff --git a/schema.yml b/schema.yml index 28f78449821e..21f1c7e953ba 100644 --- a/schema.yml +++ b/schema.yml @@ -38793,6 +38793,10 @@ components: type: string layout: $ref: '#/components/schemas/ContextualFlowInfoLayoutEnum' + messages: + type: array + items: + $ref: '#/components/schemas/FlowMessage' required: - cancel_url - layout @@ -41493,6 +41497,25 @@ components: - sidebar_left_frame_background - sidebar_right_frame_background type: string + FlowMessage: + type: object + description: Serializer for a django.contrib.messages message + properties: + level: + $ref: '#/components/schemas/FlowMessageLevelEnum' + message: + type: string + required: + - level + - message + FlowMessageLevelEnum: + enum: + - debug + - info + - success + - warning + - error + type: string FlowRequest: type: object description: Flow Serializer From 29202f7eae52e8019b5264610426c1a70dfe6c7b Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 19:17:59 +0200 Subject: [PATCH 12/14] web/flow: display API-sent messages (#25036) * web/flow: display API-sent messages Signed-off-by: Marc 'risson' Schmitt * fixup Signed-off-by: Marc 'risson' Schmitt * fixup Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- web/src/flow/FlowExecutor.ts | 7 ++- web/src/flow/messages.ts | 45 ++++++++++++++++ .../details/UserSettingsFlowExecutor.ts | 7 +++ web/test/unit/flow-messages.test.ts | 51 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 web/src/flow/messages.ts create mode 100644 web/test/unit/flow-messages.test.ts diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index cad0bf55f756..12df96d8836f 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -17,7 +17,7 @@ import { configureSentry } from "#common/sentry/index"; import { applyBackgroundImageProperty } from "#common/theme"; import { Interface } from "#elements/Interface"; -import { showAPIErrorMessage } from "#elements/messages/MessageContainer"; +import { showAPIErrorMessage, showMessage } from "#elements/messages/MessageContainer"; import { WithBrandConfig } from "#elements/mixins/branding"; import { LitPropertyRecord, SlottedTemplateResult } from "#elements/types"; import { exportParts } from "#elements/utils/attributes"; @@ -29,6 +29,7 @@ import { AKFlowUpdateChallengeRequest, } from "#flow/events"; import { StageMapping } from "#flow/FlowExecutorStageFactory"; +import { flowMessages } from "#flow/messages"; import { BaseStage } from "#flow/stages/base"; import type { FlowChallengeResponseRequestBody, StageHost, SubmitOptions } from "#flow/types"; @@ -188,6 +189,10 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos : this.ownerDocument.body; applyBackgroundImageProperty(background, { target }); + + for (const message of flowMessages(this.challenge?.flowInfo?.messages)) { + showMessage(message); + } } //#region Listeners diff --git a/web/src/flow/messages.ts b/web/src/flow/messages.ts new file mode 100644 index 000000000000..f4d79971d186 --- /dev/null +++ b/web/src/flow/messages.ts @@ -0,0 +1,45 @@ +/** + * @file Messages sent to the client as part of a flow challenge. + */ + +import { APIMessage, MessageLevel } from "#common/messages"; + +import { FlowMessage, FlowMessageLevelEnum } from "@goauthentik/api"; + +/** + * Map the level of a message sent as flow data to the level used by the interface. + * + * @remarks + * `debug` has no counterpart in the interface, and is displayed as info. The server only ever + * sends it when the message level is lowered from its default. + */ +export function flowMessageLevel(level: FlowMessageLevelEnum): MessageLevel { + switch (level) { + case FlowMessageLevelEnum.Error: + return MessageLevel.error; + case FlowMessageLevelEnum.Warning: + return MessageLevel.warning; + case FlowMessageLevelEnum.Success: + return MessageLevel.success; + default: + return MessageLevel.info; + } +} + +/** + * Convert the messages attached to a challenge into messages ready to be displayed. + * + * @remarks + * The server attaches a message to a single challenge and considers it delivered from then on, so + * callers must display these as soon as a challenge is received. Redirect challenges never carry + * messages, since the client navigates away before they could be read; those are delivered by the + * page navigated to instead. + */ +export function flowMessages(messages: FlowMessage[] | null | undefined): APIMessage[] { + if (!messages) return []; + + return messages.map(({ level, message }) => ({ + level: flowMessageLevel(level), + message, + })); +} diff --git a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts index bb4df3a0f248..f75d2c5d2509 100644 --- a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts +++ b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts @@ -11,6 +11,7 @@ import { WithBrandConfig } from "#elements/mixins/branding"; import { WithSession } from "#elements/mixins/session"; import { SlottedTemplateResult } from "#elements/types"; +import { flowMessages } from "#flow/messages"; import type { StageHost } from "#flow/types"; import { @@ -48,6 +49,12 @@ export class UserSettingsFlowExecutor this.#challenge = value; + // Messages ride along with the challenge they were queued during, and the server + // considers them delivered once sent, so each challenge is shown exactly once. + for (const message of flowMessages(value?.flowInfo?.messages)) { + showMessage(message); + } + this.requestUpdate("challenge", previousValue); } diff --git a/web/test/unit/flow-messages.test.ts b/web/test/unit/flow-messages.test.ts new file mode 100644 index 000000000000..2d1f9b041938 --- /dev/null +++ b/web/test/unit/flow-messages.test.ts @@ -0,0 +1,51 @@ +import { MessageLevel } from "#common/messages"; + +import { flowMessageLevel, flowMessages } from "#flow/messages"; + +import { FlowMessage, FlowMessageLevelEnum } from "@goauthentik/api"; + +import { describe, expect, it } from "vitest"; + +const makeFlowMessage = (level: FlowMessageLevelEnum, message: string): FlowMessage => ({ + level, + message, +}); + +describe("flowMessageLevel", () => { + it("maps each level the server can send to its interface counterpart", () => { + expect(flowMessageLevel(FlowMessageLevelEnum.Error)).toBe(MessageLevel.error); + expect(flowMessageLevel(FlowMessageLevelEnum.Warning)).toBe(MessageLevel.warning); + expect(flowMessageLevel(FlowMessageLevelEnum.Success)).toBe(MessageLevel.success); + expect(flowMessageLevel(FlowMessageLevelEnum.Info)).toBe(MessageLevel.info); + }); + + it("displays debug as info, as the interface has no debug level", () => { + expect(flowMessageLevel(FlowMessageLevelEnum.Debug)).toBe(MessageLevel.info); + }); + + it("displays a level added by a newer server as info", () => { + expect(flowMessageLevel(FlowMessageLevelEnum.UnknownDefaultOpenApi)).toBe( + MessageLevel.info, + ); + }); +}); + +describe("flowMessages", () => { + it("converts every message attached to a challenge", () => { + const messages = [ + makeFlowMessage(FlowMessageLevelEnum.Success, "Email successfully sent."), + makeFlowMessage(FlowMessageLevelEnum.Error, "Failed to authenticate."), + ]; + + expect(flowMessages(messages)).toEqual([ + { level: MessageLevel.success, message: "Email successfully sent." }, + { level: MessageLevel.error, message: "Failed to authenticate." }, + ]); + }); + + it("returns nothing for a challenge without messages", () => { + expect(flowMessages(undefined)).toEqual([]); + expect(flowMessages(null)).toEqual([]); + expect(flowMessages([])).toEqual([]); + }); +}); From 8f35ac400381c807f4dd75b62e31e9c26529d573 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 19:17:59 +0200 Subject: [PATCH 13/14] root: remove django messages from websockets (#25061) * wip Signed-off-by: Marc 'risson' Schmitt * remove from web Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- authentik/root/settings.py | 2 +- authentik/root/ws/consumer.py | 10 ----- authentik/root/ws/storage.py | 40 ------------------- web/src/common/ws/events.ts | 10 +---- web/src/flow/FlowExecutor.ts | 5 --- .../FlowWebsocketClientController.ts | 32 --------------- 6 files changed, 2 insertions(+), 97 deletions(-) delete mode 100644 authentik/root/ws/storage.py delete mode 100644 web/src/flow/controllers/FlowWebsocketClientController.ts diff --git a/authentik/root/settings.py b/authentik/root/settings.py index c3b9360c1dc1..6556e0b66e91 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -291,7 +291,7 @@ ).total_seconds() SESSION_EXPIRE_AT_BROWSER_CLOSE = True -MESSAGE_STORAGE = "authentik.root.ws.storage.ChannelsStorage" +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" MIDDLEWARE_FIRST = [ "django_prometheus.middleware.PrometheusBeforeMiddleware", diff --git a/authentik/root/ws/consumer.py b/authentik/root/ws/consumer.py index 38e755b21d96..804bd4cd5b68 100644 --- a/authentik/root/ws/consumer.py +++ b/authentik/root/ws/consumer.py @@ -4,11 +4,9 @@ from asgiref.sync import async_to_sync from channels.generic.websocket import JsonWebsocketConsumer -from django.core.cache import cache from django.db import connection from authentik.core.models import User -from authentik.root.ws.storage import CACHE_PREFIX def build_user_group(user: User): @@ -25,8 +23,6 @@ class MessageConsumer(JsonWebsocketConsumer): def connect(self): self.accept() self.session_key = self.scope["session"].session_key - if self.session_key: - cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None) if user := self.scope.get("user"): if user.is_authenticated: async_to_sync(self.channel_layer.group_add)( @@ -34,17 +30,11 @@ def connect(self): ) def disconnect(self, code): - if self.session_key: - cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}") if self.user: async_to_sync(self.channel_layer.group_discard)( build_user_group(self.user), self.channel_name ) - def event_message(self, event: dict): - """Event handler which is called by Messages Storage backend""" - self.send_json(event) - def event_notification(self, event: dict): """Event handler for new notifications""" self.send_json({"message_type": "notification.new", **event}) diff --git a/authentik/root/ws/storage.py b/authentik/root/ws/storage.py deleted file mode 100644 index 33d3c63f0551..000000000000 --- a/authentik/root/ws/storage.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Channels Messages storage""" - -from asgiref.sync import async_to_sync -from channels.layers import get_channel_layer -from django.contrib.messages.storage.base import Message -from django.contrib.messages.storage.session import SessionStorage -from django.core.cache import cache -from django.http.request import HttpRequest - -CACHE_PREFIX = "goauthentik.io/root/messages_" - - -class ChannelsStorage(SessionStorage): - """Send contrib.messages over websocket""" - - def __init__(self, request: HttpRequest) -> None: - super().__init__(request) - self.channel = get_channel_layer() - - def _store(self, messages: list[Message], response, *args, **kwargs): - prefix = f"{CACHE_PREFIX}{self.request.session.session_key}_messages_" - keys = cache.keys(f"{prefix}*") - # if no active connections are open, fallback to storing messages in the - # session, so they can always be retrieved - if len(keys) < 1: - return super()._store(messages, response, *args, **kwargs) - for key in keys: - uid = key.replace(prefix, "") - for message in messages: - async_to_sync(self.channel.send)( - uid, - { - "type": "event.message", - "message_type": "message", - "level": message.level_tag, - "tags": message.tags, - "message": message.message, - }, - ) - return [] diff --git a/web/src/common/ws/events.ts b/web/src/common/ws/events.ts index c67b3c2216ed..c5f31e49a968 100644 --- a/web/src/common/ws/events.ts +++ b/web/src/common/ws/events.ts @@ -3,22 +3,16 @@ */ import { EVENT_REFRESH } from "#common/constants"; -import { AKMessageEvent, APIMessage } from "#common/messages"; import { Notification, NotificationFromJSON } from "@goauthentik/api"; //#region WebSocket Messages export enum WSMessageType { - Message = "message", NotificationNew = "notification.new", Refresh = "refresh", } -export interface WSMessageMessage extends APIMessage { - message_type: WSMessageType.Message; -} - export interface WSMessageNotification { id: string; data: Notification; @@ -29,7 +23,7 @@ export interface WSMessageRefresh { message_type: WSMessageType.Refresh; } -export type WSMessage = WSMessageMessage | WSMessageNotification | WSMessageRefresh; +export type WSMessage = WSMessageNotification | WSMessageRefresh; //#endregion @@ -58,8 +52,6 @@ export class AKNotificationEvent extends Event { */ export function createEventFromWSMessage(message: WSMessage): Event { switch (message.message_type) { - case WSMessageType.Message: - return new AKMessageEvent(message); case WSMessageType.NotificationNew: return new AKNotificationEvent(message.data); case WSMessageType.Refresh: diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 12df96d8836f..3f3fb393cd33 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -7,7 +7,6 @@ import "#flow/tabs/broadcast"; import { FlowIframeMessageController } from "./controllers/FlowIframeMessageController"; import { FlowMultitabController } from "./controllers/FlowMultitabController"; -import { FlowWebsocketClientController } from "./controllers/FlowWebsocketClientController"; import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; import { aki } from "#common/api/client"; @@ -128,9 +127,6 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos // Listen for authentik state-change events from other tabs #flowMultitabController = new FlowMultitabController(this); - // Listen for server-side events and forward them to the notification handler - #flowWebsocketClientController = new FlowWebsocketClientController(this); - //#endregion //#region Accessors @@ -161,7 +157,6 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos this.#api = aki(FlowsApi); this.addController(this.#flowIframeMessageController); this.addController(this.#flowMultitabController); - this.addController(this.#flowWebsocketClientController); this.addEventListener(AKFlowUpdateChallengeRequest.eventName, this.handleChallengeRequest); this.addEventListener(AKFlowSubmitRequest.eventName, this.handleSubordinateSubmit); } diff --git a/web/src/flow/controllers/FlowWebsocketClientController.ts b/web/src/flow/controllers/FlowWebsocketClientController.ts deleted file mode 100644 index a3adf7ec334f..000000000000 --- a/web/src/flow/controllers/FlowWebsocketClientController.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { WebsocketClient } from "#common/ws/WebSocketClient"; - -import type { Interface } from "#elements/Interface"; - -import { ReactiveController, ReactiveControllerHost } from "lit"; - -type WebsocketClientControllerHost = ReactiveControllerHost & Interface; - -/** - * Set up the web socket to listen for messages from the authentik server - * - * @remarks - * - * The authentik server may send notifications to the user's session. This controller handles the - * lifecycle of our simple websocket listener, which filters and re-issues events into the DOM. - * Users of this controller are expected to implement a listener and display the events to the user. - * The current implementation uses `ak-message-notifications`, but that's just a detail. - * - */ -export class FlowWebsocketClientController implements ReactiveController { - constructor(private host: WebsocketClientControllerHost) { - /* no op */ - } - - hostConnected() { - WebsocketClient.connect(); - } - - hostDisconnected() { - WebsocketClient.close(); - } -} From 795440c77dce304cd8847afdfb43255db55a29a4 Mon Sep 17 00:00:00 2001 From: Marc 'risson' Schmitt Date: Fri, 21 Aug 2026 19:18:00 +0200 Subject: [PATCH 14/14] core: make client ws connection authenticated (#25065) * core: make client ws connection authenticated Signed-off-by: Marc 'risson' Schmitt * fixup Signed-off-by: Marc 'risson' Schmitt * move to events Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Marc 'risson' Schmitt --- authentik/core/urls.py | 4 ++-- authentik/{root/ws => events}/consumer.py | 15 +++++++-------- authentik/events/models.py | 2 +- .../{root => events}/tests/test_ws_client.py | 7 +++++++ 4 files changed, 17 insertions(+), 11 deletions(-) rename authentik/{root/ws => events}/consumer.py (73%) rename authentik/{root => events}/tests/test_ws_client.py (87%) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 24e67aff31ce..766407c2fce2 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -27,10 +27,10 @@ InterfaceView, RootRedirectView, ) +from authentik.events.consumer import ClientConsumer from authentik.flows.views.interface import FlowInterfaceView from authentik.root.asgi_middleware import AuthMiddlewareStack from authentik.root.middleware import ChannelsLoggingMiddleware -from authentik.root.ws.consumer import MessageConsumer from authentik.tenants.channels import TenantsAwareMiddleware urlpatterns = [ @@ -116,7 +116,7 @@ path( "ws/client/", ChannelsLoggingMiddleware( - TenantsAwareMiddleware(AuthMiddlewareStack(MessageConsumer.as_asgi())) + TenantsAwareMiddleware(AuthMiddlewareStack(ClientConsumer.as_asgi())) ), ), ] diff --git a/authentik/root/ws/consumer.py b/authentik/events/consumer.py similarity index 73% rename from authentik/root/ws/consumer.py rename to authentik/events/consumer.py index 804bd4cd5b68..5affd44a65da 100644 --- a/authentik/root/ws/consumer.py +++ b/authentik/events/consumer.py @@ -3,6 +3,7 @@ from hashlib import sha256 from asgiref.sync import async_to_sync +from channels.exceptions import DenyConnection from channels.generic.websocket import JsonWebsocketConsumer from django.db import connection @@ -13,21 +14,19 @@ def build_user_group(user: User): return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest() -class MessageConsumer(JsonWebsocketConsumer): +class ClientConsumer(JsonWebsocketConsumer): """Consumer which sends django.contrib.messages Messages over WS. channel_name is saved into cache with user_id, and when a add_message is called""" - session_key: str user: User | None = None def connect(self): + user = self.scope.get("user") + if user is None or not user.is_authenticated: + raise DenyConnection() + self.user = user self.accept() - self.session_key = self.scope["session"].session_key - if user := self.scope.get("user"): - if user.is_authenticated: - async_to_sync(self.channel_layer.group_add)( - build_user_group(user), self.channel_name - ) + async_to_sync(self.channel_layer.group_add)(build_user_group(self.user), self.channel_name) def disconnect(self, code): if self.user: diff --git a/authentik/events/models.py b/authentik/events/models.py index 7b3df96d4316..0be7b8705356 100644 --- a/authentik/events/models.py +++ b/authentik/events/models.py @@ -29,6 +29,7 @@ ) from authentik.core.models import Group, PropertyMapping, User from authentik.crypto.models import CertificateKeyPair +from authentik.events.consumer import build_user_group from authentik.events.context_processors.base import get_context_processors from authentik.events.utils import ( cleanse_dict, @@ -50,7 +51,6 @@ from authentik.outposts.docker_tls import DockerInlineTLS from authentik.policies.models import PolicyBindingModel from authentik.root.middleware import ClientIPMiddleware -from authentik.root.ws.consumer import build_user_group from authentik.stages.email.models import EmailTemplates from authentik.stages.email.utils import TemplateEmailMessage from authentik.tasks.models import TasksModel diff --git a/authentik/root/tests/test_ws_client.py b/authentik/events/tests/test_ws_client.py similarity index 87% rename from authentik/root/tests/test_ws_client.py rename to authentik/events/tests/test_ws_client.py index 6d9344f49670..bf273292c20a 100644 --- a/authentik/root/tests/test_ws_client.py +++ b/authentik/events/tests/test_ws_client.py @@ -19,6 +19,13 @@ class TestClientWS(TransactionTestCase): def setUp(self): self.user = create_test_user() + async def test_unauthenticated(self): + communicator = WebsocketCommunicator( + URLRouter(websocket.websocket_urlpatterns), "/ws/client/" + ) + connected, _ = await communicator.connect() + self.assertFalse(connected) + async def test_notification(self): communicator = WebsocketCommunicator( URLRouter(websocket.websocket_urlpatterns), "/ws/client/"