From cd776c51e09eada173dba980e80adf96dfe18180 Mon Sep 17 00:00:00 2001 From: dlddu <39251873+dlddu@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:50:02 +0000 Subject: [PATCH 1/2] fix(oauth2): honour RedirectURI matching_mode in CORS origin check [Buzzvil fork patch] The OAuth2 provider supports STRICT and REGEX matching_mode for redirect URI entries. views/authorize.py honours both when validating the redirect_uri query param, but cors_allow() ignored matching_mode entirely: callers flattened redirect_uris to bare url strings and cors_allow only did scheme/host/port equality via urlparse. Effect: a browser app configured with a REGEX redirect URI completes /authorize/ but its fetch() to /application/o/token/ (and /userinfo/, and the provider-info endpoint) is blocked because the response carries no Access-Control-Allow-Origin header. Worker logs show "CORS: Origin is not an allowed origin" with the regex verbatim in `allowed`, never matched. - cors_allow now accepts RedirectURI | str and branches on matching_mode. Strict entries keep the existing scheme+host+port comparison (regression-safe). Regex entries are matched against the request Origin via re.fullmatch, mirroring views/authorize.py. - Malformed regexes raise re.error, which is caught, logged, and skipped so one bad entry cannot 500 the request. - The three call sites (token.py, userinfo.py, provider.py) pass RedirectURI objects through instead of flattening to url strings. - Bare string entries are still accepted (treated as STRICT) for backwards compatibility. A CORS Origin is scheme+host[:port] with no path, so a regex written for the full redirect URI with a mandatory path will not match a bare Origin; write it as host(/path)? or add a separate origin-only entry. Backport of upstream goauthentik/authentik#22179 (closes #3084) onto the Buzzvil 2026.2.1 fork. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../providers/oauth2/tests/test_utils.py | 151 ++++++++++++++++++ authentik/providers/oauth2/utils.py | 55 +++++-- authentik/providers/oauth2/views/provider.py | 2 +- authentik/providers/oauth2/views/token.py | 2 +- authentik/providers/oauth2/views/userinfo.py | 2 +- 5 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 authentik/providers/oauth2/tests/test_utils.py diff --git a/authentik/providers/oauth2/tests/test_utils.py b/authentik/providers/oauth2/tests/test_utils.py new file mode 100644 index 000000000000..d9906ee8b5e7 --- /dev/null +++ b/authentik/providers/oauth2/tests/test_utils.py @@ -0,0 +1,151 @@ +"""Test OAuth2 utility helpers""" + +from django.http import HttpResponse +from django.test import RequestFactory, TestCase + +from authentik.providers.oauth2.models import RedirectURI, RedirectURIMatchingMode +from authentik.providers.oauth2.utils import cors_allow + + +class TestCorsAllow(TestCase): + """Tests for ``cors_allow``: ensures regex-mode redirect URI entries are honoured + for CORS origin validation, not only for the authorize-step redirect_uri check.""" + + def setUp(self): + self.factory = RequestFactory() + + def _post(self, origin: str): + return self.factory.post("/", HTTP_ORIGIN=origin) + + def test_strict_match(self): + """Strict origin entry matches scheme + host + port (regression).""" + request = self._post("http://local.invalid") + response = cors_allow( + request, + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/callback"), + ) + self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") + self.assertEqual(response["Access-Control-Allow-Credentials"], "true") + + def test_strict_no_match(self): + """Strict entry on a different host does not allow the origin.""" + request = self._post("http://other.invalid") + response = cors_allow( + request, + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/callback"), + ) + self.assertNotIn("Access-Control-Allow-Origin", response) + + def test_regex_match(self): + """Regex entry matching the Origin sets the CORS header.""" + request = self._post("https://app-abc123.example.com") + response = cors_allow( + request, + HttpResponse(), + RedirectURI( + RedirectURIMatchingMode.REGEX, + r"https://app-\w+\.example\.com", + ), + ) + self.assertEqual(response["Access-Control-Allow-Origin"], "https://app-abc123.example.com") + + def test_regex_no_match(self): + """Regex entry that does not match the Origin must not allow the request.""" + request = self._post("https://evil.example.com") + response = cors_allow( + request, + HttpResponse(), + RedirectURI( + RedirectURIMatchingMode.REGEX, + r"https://app-\w+\.example\.com", + ), + ) + self.assertNotIn("Access-Control-Allow-Origin", response) + + def test_regex_with_optional_path_group(self): + """A regex written as ``host(/path)?`` should match the bare Origin (without path) + as well as the full redirect URI used by the authorize step.""" + regex = r"https://app-\w+\.example\.com(/callback)?" + # Origin (no path) + response = cors_allow( + self._post("https://app-foo.example.com"), + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.REGEX, regex), + ) + self.assertEqual(response["Access-Control-Allow-Origin"], "https://app-foo.example.com") + # Origin equal to a wrong host: must not match. + response = cors_allow( + self._post("https://other.example.com"), + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.REGEX, regex), + ) + self.assertNotIn("Access-Control-Allow-Origin", response) + + def test_regex_full_redirect_uri_does_not_match_bare_origin(self): + """A regex written for the full redirect URI (including a mandatory path) will + not match a bare Origin header. This is the documented behaviour: users wanting + regex CORS support should write ``host(/path)?`` or add a separate origin entry.""" + request = self._post("https://app-abc.example.com") + response = cors_allow( + request, + HttpResponse(), + RedirectURI( + RedirectURIMatchingMode.REGEX, + r"https://app-\w+\.example\.com/callback", + ), + ) + self.assertNotIn("Access-Control-Allow-Origin", response) + + def test_malformed_regex_is_skipped(self): + """Malformed regexes are logged and skipped; a later valid entry still matches.""" + request = self._post("https://good.example.com") + response = cors_allow( + request, + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.REGEX, r"["), # invalid regex + RedirectURI(RedirectURIMatchingMode.REGEX, r"https://good\.example\.com"), + ) + self.assertEqual(response["Access-Control-Allow-Origin"], "https://good.example.com") + + def test_malformed_regex_alone_does_not_500(self): + """A malformed regex with no other allowed entries must return the response + unchanged rather than raising.""" + request = self._post("https://anything.example.com") + response = cors_allow( + request, + HttpResponse(), + RedirectURI(RedirectURIMatchingMode.REGEX, r"["), + ) + self.assertNotIn("Access-Control-Allow-Origin", response) + + def test_options_request_allowed_without_match(self): + """Pre-flight OPTIONS requests are allowed through without an entry match + (regression: existing behaviour).""" + request = self.factory.options( + "/", + HTTP_ORIGIN="https://anywhere.example.com", + HTTP_ACCESS_CONTROL_REQUEST_HEADERS="content-type", + ) + response = cors_allow(request, HttpResponse()) + self.assertEqual(response["Access-Control-Allow-Origin"], "https://anywhere.example.com") + + def test_bare_string_treated_as_strict(self): + """Backwards compatibility: bare string entries are treated as STRICT.""" + request = self._post("http://local.invalid") + response = cors_allow(request, HttpResponse(), "http://local.invalid/callback") + self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") + + def test_mixed_strict_and_regex(self): + """A provider with one strict and one regex entry honours both.""" + entries = ( + RedirectURI(RedirectURIMatchingMode.STRICT, "http://local.invalid/callback"), + RedirectURI(RedirectURIMatchingMode.REGEX, r"https://app-\w+\.example\.com"), + ) + # Strict origin matches via the strict entry. + response = cors_allow(self._post("http://local.invalid"), HttpResponse(), *entries) + self.assertEqual(response["Access-Control-Allow-Origin"], "http://local.invalid") + # Regex origin matches via the regex entry. + response = cors_allow(self._post("https://app-xyz.example.com"), HttpResponse(), *entries) + self.assertEqual(response["Access-Control-Allow-Origin"], "https://app-xyz.example.com") diff --git a/authentik/providers/oauth2/utils.py b/authentik/providers/oauth2/utils.py index 1a2ca6ee475c..292ba0c3c5ee 100644 --- a/authentik/providers/oauth2/utils.py +++ b/authentik/providers/oauth2/utils.py @@ -20,7 +20,12 @@ from authentik.lib.utils.time import timedelta_from_string from authentik.providers.oauth2.errors import BearerTokenError from authentik.providers.oauth2.id_token import hash_session_key -from authentik.providers.oauth2.models import AccessToken, OAuth2Provider +from authentik.providers.oauth2.models import ( + AccessToken, + OAuth2Provider, + RedirectURI, + RedirectURIMatchingMode, +) LOGGER = get_logger() @@ -36,9 +41,18 @@ def __init__(self, *args, **kwargs): self["Pragma"] = "no-cache" -def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: str): +def cors_allow( + request: HttpRequest, + response: HttpResponse, + *allowed_origins: RedirectURI | str, +): """Add headers to permit CORS requests from allowed_origins, with or without credentials, - with any headers.""" + with any headers. + + Each entry may be a ``RedirectURI`` (honouring its ``matching_mode``) or a bare string + (treated as ``STRICT`` for backwards compatibility). Regex entries are matched against + the request's Origin header via ``re.fullmatch``, mirroring the semantics used by + ``views/authorize.py``; malformed regexes are logged and skipped rather than raising.""" origin = request.META.get("HTTP_ORIGIN") if not origin: return response @@ -48,14 +62,33 @@ def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: s # so for options requests we allow the calling origin without checking allowed = request.method == "OPTIONS" received_origin = urlparse(origin) - for allowed_origin in allowed_origins: - url = urlparse(allowed_origin) - if ( - received_origin.scheme == url.scheme - and received_origin.hostname == url.hostname - and received_origin.port == url.port - ): - allowed = True + for raw_entry in allowed_origins: + entry = ( + RedirectURI(RedirectURIMatchingMode.STRICT, raw_entry) + if isinstance(raw_entry, str) + else raw_entry + ) + if entry.matching_mode == RedirectURIMatchingMode.STRICT: + url = urlparse(entry.url) + if ( + received_origin.scheme == url.scheme + and received_origin.hostname == url.hostname + and received_origin.port == url.port + ): + allowed = True + break + elif entry.matching_mode == RedirectURIMatchingMode.REGEX: + try: + if re.fullmatch(entry.url, origin): + allowed = True + break + except re.error as exc: + LOGGER.warning( + "CORS: Failed to parse regular expression", + exc=exc, + url=entry.url, + ) + continue if not allowed: LOGGER.warning( "CORS: Origin is not an allowed origin", diff --git a/authentik/providers/oauth2/views/provider.py b/authentik/providers/oauth2/views/provider.py index b16113a60b1a..f7ecf9b5e3ad 100644 --- a/authentik/providers/oauth2/views/provider.py +++ b/authentik/providers/oauth2/views/provider.py @@ -164,5 +164,5 @@ def dispatch( OAuth2Provider, pk=application.provider_id ) response = super().dispatch(request, *args, **kwargs) - cors_allow(request, response, *[x.url for x in self.provider.redirect_uris]) + cors_allow(request, response, *self.provider.redirect_uris) return response diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 8d01bc5ff22d..adf6ca5aa764 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -565,7 +565,7 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespo response = super().dispatch(request, *args, **kwargs) allowed_origins = [] if self.provider: - allowed_origins = [x.url for x in self.provider.redirect_uris] + allowed_origins = self.provider.redirect_uris cors_allow(self.request, response, *allowed_origins) return response diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py index 41b9cbe3fd96..b0a60722c1e4 100644 --- a/authentik/providers/oauth2/views/userinfo.py +++ b/authentik/providers/oauth2/views/userinfo.py @@ -108,7 +108,7 @@ def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpRespo response = super().dispatch(request, *args, **kwargs) allowed_origins = [] if self.token: - allowed_origins = [x.url for x in self.token.provider.redirect_uris] + allowed_origins = self.token.provider.redirect_uris cors_allow(self.request, response, *allowed_origins) return response From 15df72aaa3422f1d26fd1e35d7c14506599d21bb Mon Sep 17 00:00:00 2001 From: dlddu <39251873+dlddu@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:12:28 +0000 Subject: [PATCH 2/2] build(oauth2-cors): overlay the CORS regex patch files into the patched image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patched-image workflow builds an overlay on ghcr.io/goauthentik/server by COPYing only the changed Python files (sparse-checkout + Dockerfile COPY). Until now it carried the two SAML RelayState files only, so a `-relaystate` tag build would NOT include the oauth2 CORS regex fix. - .buzzvil/Dockerfile: COPY the four oauth2 runtime files (utils.py + views/token.py, userinfo.py, provider.py). All four must ship together — the views pass RedirectURI objects into cors_allow's patched signature. The test file is not runtime code and is deliberately excluded. - workflow sparse-checkout: add the same four files so they exist in the build context. - generalise the SAML-only comments and release title now that the image bundles both fork patches. Co-Authored-By: Claude Opus 4.8 (1M context) --- .buzzvil/Dockerfile | 24 +++++++++++++++++------- .github/workflows/build-patched-ecr.yaml | 17 ++++++++++++----- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/.buzzvil/Dockerfile b/.buzzvil/Dockerfile index 46577ef4707f..47515ca13222 100644 --- a/.buzzvil/Dockerfile +++ b/.buzzvil/Dockerfile @@ -1,14 +1,24 @@ # Buzzvil patched authentik image (overlay on the official server image). # -# Replaces two SAML provider files so that IdP-initiated SSO forwards the -# SP-supplied RelayState (AWS WorkSpaces appends a per-session state code via -# ?RelayState= to the init URL) instead of only using the static -# default_relay_state. Without this, the WorkSpaces native client loops back to -# sign-in because euc-sso cannot bind the auth code to the client session. +# Overlays Buzzvil fork patches by replacing Python files in place. Paths below +# match the official image layout (/authentik/...); patch source of truth is this +# repo's authentik/ tree. # -# Patch source of truth: this repo's authentik/ tree (see PR #1). File paths below -# match the official image layout (/authentik/...). +# 1) SAML RelayState (PR #1): IdP-initiated SSO forwards the SP-supplied +# RelayState (AWS WorkSpaces appends a per-session state code via +# ?RelayState= to the init URL) instead of only using the static +# default_relay_state. Without this the WorkSpaces native client loops back +# to sign-in because euc-sso cannot bind the auth code to the client session. +# 2) OAuth2 CORS regex (PR #2): cors_allow honours each redirect URI's +# matching_mode so REGEX-mode redirect URIs are matched against the request +# Origin (backport of goauthentik/authentik#22179). utils.py carries the fix; +# the three view files must be overlaid together because their cors_allow() +# calls now pass RedirectURI objects to the patched signature. FROM ghcr.io/goauthentik/server:2026.2.1 COPY --chown=authentik:authentik authentik/providers/saml/processors/authn_request_parser.py /authentik/providers/saml/processors/authn_request_parser.py COPY --chown=authentik:authentik authentik/providers/saml/views/sso.py /authentik/providers/saml/views/sso.py +COPY --chown=authentik:authentik authentik/providers/oauth2/utils.py /authentik/providers/oauth2/utils.py +COPY --chown=authentik:authentik authentik/providers/oauth2/views/token.py /authentik/providers/oauth2/views/token.py +COPY --chown=authentik:authentik authentik/providers/oauth2/views/userinfo.py /authentik/providers/oauth2/views/userinfo.py +COPY --chown=authentik:authentik authentik/providers/oauth2/views/provider.py /authentik/providers/oauth2/views/provider.py diff --git a/.github/workflows/build-patched-ecr.yaml b/.github/workflows/build-patched-ecr.yaml index b2667b105f44..e255affa5b19 100644 --- a/.github/workflows/build-patched-ecr.yaml +++ b/.github/workflows/build-patched-ecr.yaml @@ -1,15 +1,18 @@ name: build-patched-ghcr # authentik 패치 이미지 빌드 + GitHub Release 를 함께 생성. +# 오버레이 패치 목록·경로는 .buzzvil/Dockerfile 참고 (SAML RelayState + OAuth2 CORS regex). # # 트리거: '-relaystate' 형태의 태그 push (예: 2026.2.1-relaystate). # 태그명 = 이미지 태그 = release 태그 → 빌드와 release 가 항상 한 쌍으로 생성된다. +# (git 태그명이 'relaystate' 인 것은 최초 SAML 패치 시절의 관례일 뿐, 이미지에는 +# Dockerfile 이 COPY 하는 모든 포크 패치가 포함된다.) # # 새 authentik 버전 릴리스 절차: -# 1) 새 브랜치(buzzvil/workspaces-relaystate-)에서 .buzzvil/Dockerfile 의 FROM 을 -# 그 버전으로 바꾸고 SAML 2 파일 패치를 재적용. +# 1) 새 브랜치(buzzvil/*-)에서 .buzzvil/Dockerfile 의 FROM 을 그 버전으로 바꾸고 +# 포크 패치(SAML 2파일 + oauth2 CORS 4파일)를 재적용, sparse-checkout 목록도 맞춘다. # 2) 그 커밋에 태그 '-relaystate' 를 push → 이 워크플로우가 이미지 빌드 + release 생성. -# 3) ops(buzz-k8s-resources) 의 global.image tag 를 갱신하고 WorkSpaces 로그인 재검증. +# 3) ops(buzz-k8s-resources) 의 global.image tag 를 갱신하고 로그인/CORS 재검증. on: push: tags: @@ -37,6 +40,10 @@ jobs: .buzzvil authentik/providers/saml/processors/authn_request_parser.py authentik/providers/saml/views/sso.py + authentik/providers/oauth2/utils.py + authentik/providers/oauth2/views/token.py + authentik/providers/oauth2/views/userinfo.py + authentik/providers/oauth2/views/provider.py sparse-checkout-cone-mode: false - name: Set up QEMU @@ -68,7 +75,7 @@ jobs: run: | gh release create "${{ github.ref_name }}" \ --repo "${{ github.repository }}" \ - --title "${{ github.ref_name }} — WorkSpaces SAML RelayState patch" \ - --notes "패치 이미지: \`ghcr.io/buzzvil/authentik:${{ github.ref_name }}\` (multi-arch amd64/arm64, public). 패치 내용·빌드·제거 조건은 저장소 README 참고." \ + --title "${{ github.ref_name }} — Buzzvil patched authentik (SAML RelayState + OAuth2 CORS regex)" \ + --notes "패치 이미지: \`ghcr.io/buzzvil/authentik:${{ github.ref_name }}\` (multi-arch amd64/arm64, public). 오버레이 패치 목록·빌드·제거 조건은 .buzzvil/Dockerfile 및 저장소 README 참고." \ --verify-tag \ || echo "release for ${{ github.ref_name }} already exists — skipping"