Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions .buzzvil/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
17 changes: 12 additions & 5 deletions .github/workflows/build-patched-ecr.yaml
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
name: build-patched-ghcr

# authentik 패치 이미지 빌드 + GitHub Release 를 함께 생성.
# 오버레이 패치 목록·경로는 .buzzvil/Dockerfile 참고 (SAML RelayState + OAuth2 CORS regex).
#
# 트리거: '<authentik version>-relaystate' 형태의 태그 push (예: 2026.2.1-relaystate).
# 태그명 = 이미지 태그 = release 태그 → 빌드와 release 가 항상 한 쌍으로 생성된다.
# (git 태그명이 'relaystate' 인 것은 최초 SAML 패치 시절의 관례일 뿐, 이미지에는
# Dockerfile 이 COPY 하는 모든 포크 패치가 포함된다.)
#
# 새 authentik 버전 릴리스 절차:
# 1) 새 브랜치(buzzvil/workspaces-relaystate-<ver>)에서 .buzzvil/Dockerfile 의 FROM 을
# 그 버전으로 바꾸고 SAML 2 파일 패치를 재적용.
# 1) 새 브랜치(buzzvil/*-<ver>)에서 .buzzvil/Dockerfile 의 FROM 을 그 버전으로 바꾸고
# 포크 패치(SAML 2파일 + oauth2 CORS 4파일)를 재적용, sparse-checkout 목록도 맞춘다.
# 2) 그 커밋에 태그 '<ver>-relaystate' 를 push → 이 워크플로우가 이미지 빌드 + release 생성.
# 3) ops(buzz-k8s-resources) 의 global.image tag 를 갱신하고 WorkSpaces 로그인 재검증.
# 3) ops(buzz-k8s-resources) 의 global.image tag 를 갱신하고 로그인/CORS 재검증.
on:
push:
tags:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 참고." \
Comment on lines +78 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

태그명을 셸 명령에 직접 보간하지 마세요.

Line 78-79의 ${{ github.ref_name }}는 셸 실행 전에 확장됩니다. $() 등을 포함한 태그가 생성 가능하면 명령 치환으로 실행될 수 있습니다. 기존 Line 76도 함께 env 변수로 옮긴 뒤 인용된 변수 확장만 사용하세요.

수정 예시
         env:
           GH_TOKEN: ${{ github.token }}
+          RELEASE_TAG: ${{ github.ref_name }}
         run: |
-          gh release create "${{ github.ref_name }}" \
+          gh release create "$RELEASE_TAG" \
             --repo "${{ github.repository }}" \
-            --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 참고." \
+            --title "$RELEASE_TAG — Buzzvil patched authentik (SAML RelayState + OAuth2 CORS regex)" \
+            --notes "패치 이미지: \`ghcr.io/buzzvil/authentik:${RELEASE_TAG}\` (multi-arch amd64/arm64, public). 오버레이 패치 목록·빌드·제거 조건은 .buzzvil/Dockerfile 및 저장소 README 참고." \
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
--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 참고." \
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
- --title "${{ github.ref_name }} — Buzzvil patched authentik (SAML RelayState + OAuth2 CORS regex)" \
--title "$RELEASE_TAG — Buzzvil patched authentik (SAML RelayState + OAuth2 CORS regex)" \
--notes "패치 이미지: \`ghcr.io/buzzvil/authentik:${RELEASE_TAG}\` (multi-arch amd64/arm64, public). 오버레이 패치 목록·빌드·제거 조건은 .buzzvil/Dockerfile 및 저장소 README 참고." \
🧰 Tools
🪛 zizmor (1.26.1)

[error] 78-78: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 79-79: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/build-patched-ecr.yaml around lines 78 - 79, Update the
workflow step containing the gh release --title and --notes arguments to pass
github.ref_name through the step’s env configuration, alongside the existing
Line 76 usage. Replace every direct ${{ github.ref_name }} interpolation in the
shell command with a quoted shell variable expansion, preserving the release
title and notes content.

Source: Linters/SAST tools

--verify-tag \
|| echo "release for ${{ github.ref_name }} already exists — skipping"
151 changes: 151 additions & 0 deletions authentik/providers/oauth2/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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")
55 changes: 44 additions & 11 deletions authentik/providers/oauth2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion authentik/providers/oauth2/views/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion authentik/providers/oauth2/views/token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion authentik/providers/oauth2/views/userinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading