diff --git a/.buzzvil/Dockerfile b/.buzzvil/Dockerfile new file mode 100644 index 000000000000..46577ef4707f --- /dev/null +++ b/.buzzvil/Dockerfile @@ -0,0 +1,14 @@ +# 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. +# +# Patch source of truth: this repo's authentik/ tree (see PR #1). File paths below +# match the official image layout (/authentik/...). +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 diff --git a/.github/workflows/build-patched-ecr.yaml b/.github/workflows/build-patched-ecr.yaml new file mode 100644 index 000000000000..b2667b105f44 --- /dev/null +++ b/.github/workflows/build-patched-ecr.yaml @@ -0,0 +1,74 @@ +name: build-patched-ghcr + +# authentik 패치 이미지 빌드 + GitHub Release 를 함께 생성. +# +# 트리거: '-relaystate' 형태의 태그 push (예: 2026.2.1-relaystate). +# 태그명 = 이미지 태그 = release 태그 → 빌드와 release 가 항상 한 쌍으로 생성된다. +# +# 새 authentik 버전 릴리스 절차: +# 1) 새 브랜치(buzzvil/workspaces-relaystate-)에서 .buzzvil/Dockerfile 의 FROM 을 +# 그 버전으로 바꾸고 SAML 2 파일 패치를 재적용. +# 2) 그 커밋에 태그 '-relaystate' 를 push → 이 워크플로우가 이미지 빌드 + release 생성. +# 3) ops(buzz-k8s-resources) 의 global.image tag 를 갱신하고 WorkSpaces 로그인 재검증. +on: + push: + tags: + - '*-relaystate' + workflow_dispatch: + inputs: + image_tag: + description: "이미지 태그 (예: 2026.2.1-relaystate). 이미지만 rebuild (release 미생성)." + required: true + +permissions: + contents: write # release 생성 + packages: write # GHCR push + +jobs: + build: + runs-on: ubuntu-latest + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag || github.ref_name }} + steps: + - name: Checkout (patch files only) + uses: actions/checkout@v4 + with: + sparse-checkout: | + .buzzvil + authentik/providers/saml/processors/authn_request_parser.py + authentik/providers/saml/views/sso.py + sparse-checkout-cone-mode: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: .buzzvil/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ghcr.io/buzzvil/authentik:${{ env.IMAGE_TAG }} + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + 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 참고." \ + --verify-tag \ + || echo "release for ${{ github.ref_name }} already exists — skipping" diff --git a/README.md b/README.md index 2356706e58c7..210ed033d8d5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,60 @@ +# ⚠️ Buzzvil fork of goauthentik/authentik + +이 저장소는 **upstream [goauthentik/authentik](https://github.com/goauthentik/authentik) 의 포크**이며, +AWS WorkSpaces SAML SSO 를 위한 **2 파일 패치**만 얹은 것이다. 그 외 코드는 upstream 과 동일하다. + +빌드 이미지: **`ghcr.io/buzzvil/authentik:2026.2.1-relaystate`** (multi-arch amd64/arm64, public) +소비처: `Buzzvil/buzz-k8s-resources` → `argo-cd/buzzvil-eks-ops/apps/authentik.yaml` 의 `global.image` override +(ops 클러스터 `ops-k8s-authentik`). base: `ghcr.io/goauthentik/server:2026.2.1`. + +## 무슨 패치인가 + +**IdP-initiated SAML 로그인이 SP 가 넘긴 `RelayState` 를 그대로 forward** 하도록 수정. + +| 파일 | 변경 | +|------|------| +| `authentik/providers/saml/views/sso.py` | `SAMLSSOBindingInitView` 가 `request.GET["RelayState"]` 를 `idp_initiated()` 로 전달 | +| `authentik/providers/saml/processors/authn_request_parser.py` | `idp_initiated(relay_state=None)` — 전달된 relay_state 를 provider 의 정적 `default_relay_state` 보다 우선 사용 | + +### 왜 필요한가 +AWS WorkSpaces native client 로그인은 euc-sso 가 IdP init URL 에 **per-session state code 를 +`?RelayState=` 로 붙여** 보내고, IdP 가 그 값을 SAML Response 에 echo 해야 euc-sso 가 auth code 를 +그 client 세션에 바인딩한다. **upstream Authentik 의 IdP-initiated init 엔드포인트는 요청의 `RelayState` +쿼리를 무시하고 provider 의 정적 `default_relay_state` 만** 쓰기 때문에(SP-initiated redirect/POST +뷰와 달리), state code 가 유실되어 native client 가 sign-in 으로 loop-back 한다. +(upstream 동일 이슈: goauthentik/authentik#17542) + +## 빌드 / 릴리스 + +`.buzzvil/Dockerfile` 이 공식 이미지 위에 위 2 파일만 COPY 하는 overlay 이고, 빌드 워크플로우 +(`build-patched-ghcr`)가 **`-relaystate` 태그 push** 시 GitHub-hosted 러너에서 multi-arch(amd64/arm64) +빌드해 GHCR 로 push 하고 **동일 태그로 GitHub Release 를 함께 생성**한다. +(별도 self-hosted 러너·AWS·org secret 불필요. 이미지만 재빌드하려면 workflow_dispatch.) + +## 언제 내릴 수 있나 (이 포크 제거 조건) + +이 포크는 **임시**이며, 아래 중 하나가 충족되면 제거하고 공식 이미지로 되돌린다. + +1. **(우선) upstream 이 고칠 때** — goauthentik/authentik 가 IdP-initiated init 뷰에서 요청 `RelayState` + 를 forward 하도록 반영한 릴리스가 나오면(이슈 #17542 에 upstream PR 기여 권장), ops 의 `global.image` + override 를 제거해 `ghcr.io/goauthentik/server` 정식 이미지로 복귀하고 이 포크를 archive. +2. **WorkSpaces SAML 경로가 사라질 때** — adfit VDI 폐기 또는 인증 방식 변경 시. + +**제거 전 검증**: 대상 버전으로 올린 뒤 WorkSpaces native client 로그인을 end-to-end(auth code 발급 → +데스크톱 연결)로 확인하고 나서 포크를 내린다. + +## 유지보수 (upstream 이 고치기 전까지) + +authentik 을 새 버전으로 올릴 때마다 이 2 파일 패치를 새 버전 기준으로 다시 적용한다: +1. upstream 태그(`version/`)에서 브랜치 `buzzvil/workspaces-relaystate-` 생성 +2. 위 2 파일 패치 재적용(작고 self-contained), `.buzzvil/Dockerfile` 의 `FROM ...:` 갱신 +3. 그 커밋에 태그 **`-relaystate`** push → 워크플로우가 멀티아치 이미지 빌드 + 동일 태그 Release 생성 +4. ops(buzz-k8s-resources) 의 `global.image` tag 를 `-relaystate` 로 갱신 → WorkSpaces 로그인 재검증 + +--- + +> 아래는 upstream authentik README 원문. +

authentik logo

diff --git a/authentik/providers/saml/processors/authn_request_parser.py b/authentik/providers/saml/processors/authn_request_parser.py index 13dbcade6ade..0aa2baf9867f 100644 --- a/authentik/providers/saml/processors/authn_request_parser.py +++ b/authentik/providers/saml/processors/authn_request_parser.py @@ -174,10 +174,16 @@ def parse_detached( except ParseError as exc: raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc - def idp_initiated(self) -> AuthNRequest: + def idp_initiated(self, relay_state: str | None = None) -> AuthNRequest: """Create IdP Initiated AuthNRequest""" request = AuthNRequest(relay_state=None) - if self.provider.default_relay_state != "": + # Buzzvil patch: prefer a RelayState supplied by the SP over the static + # default_relay_state. AWS WorkSpaces IdP-initiated SSO appends a per-session + # state code via ?RelayState= to the init URL; it must round-trip back to AWS + # or the euc-sso auth code cannot be bound to the client session (loop-back). + if relay_state: + request.relay_state = relay_state + elif self.provider.default_relay_state != "": request.relay_state = self.provider.default_relay_state if self.provider.default_name_id_policy != SAMLNameIDPolicy.UNSPECIFIED: request.name_id_policy = self.provider.default_name_id_policy diff --git a/authentik/providers/saml/views/sso.py b/authentik/providers/saml/views/sso.py index 076fb68706ce..5508a577aabb 100644 --- a/authentik/providers/saml/views/sso.py +++ b/authentik/providers/saml/views/sso.py @@ -155,5 +155,9 @@ class SAMLSSOBindingInitView(SAMLSSOView): def check_saml_request(self) -> HttpRequest | None: """Create SAML Response from scratch""" LOGGER.debug("No SAML Request, using IdP-initiated flow.") - auth_n_request = AuthNRequestParser(self.provider).idp_initiated() + # Buzzvil patch: forward the SP-supplied RelayState (AWS WorkSpaces euc-sso + # per-session state code) into the IdP-initiated AuthNRequest so it round-trips. + auth_n_request = AuthNRequestParser(self.provider).idp_initiated( + relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE), + ) self.plan_context[PLAN_CONTEXT_SAML_AUTH_N_REQUEST] = auth_n_request