From 2a78f0e0ce3dbbd455197266aadd4d7693e27fb0 Mon Sep 17 00:00:00 2001 From: binarybeach Date: Wed, 29 Apr 2026 16:50:40 -1000 Subject: [PATCH] binarybeachio: repurpose GitHub OAuth as Zitadel OIDC Patches the plane-backend GitHubOAuthProvider so the /auth/github/* flow points at our self-hosted Zitadel instance when ZITADEL_DOMAIN is set, and falls back to vanilla GitHub OAuth when unset (regression- safe). Touch surface is one backend file plus a cosmetic frontend label change. Full rationale, configuration steps, refresh procedure, and AGPL compliance notes in BINARYBEACHIO.md at repo root. --- BINARYBEACHIO.md | 166 +++++++++++++++ .../authentication/provider/oauth/github.py | 200 +++++++++++------- apps/web/core/hooks/oauth/core.tsx | 8 +- 3 files changed, 291 insertions(+), 83 deletions(-) create mode 100644 BINARYBEACHIO.md diff --git a/BINARYBEACHIO.md b/BINARYBEACHIO.md new file mode 100644 index 000000000..7b2e611dd --- /dev/null +++ b/BINARYBEACHIO.md @@ -0,0 +1,166 @@ +# bb-plane-fork — binarybeachio customizations of Plane + +This file is the canonical contract between this fork and the binarybeachio platform repo. It exists so anyone (or any agent) on a fresh session can answer "what's customized, why, and how do I refresh from upstream" without reading code. + +**Fork repo convention** (template — same shape for every Path B fork in binarybeachio): + +``` +upstream remote → original project on github.com (read-only, merge-source) +origin remote → git.binarybeach.io/binarybeach/bb--fork (where we push) +github mirror → github.com/binarybeachllc/bb--fork (push-mirror, off-site backup) + +upstream branch — clean mirror of upstream's default branch, never modified +main branch — our customizations on top of latest upstream tag we've integrated +update/ — short-lived integration branch when pulling a new upstream version +``` + +`git log main..upstream` = "upstream changes I haven't pulled in" +`git log upstream..main` = "binarybeachio's customizations" + +--- + +## Upstream + +| Field | Value | +|---|---| +| Project | Plane (open-source project management) | +| Upstream repo | https://github.com/makeplane/plane | +| Upstream default branch | `preview` | +| Currently integrated upstream version | **v1.3.0** (release commit `cf696d2`) | +| License | AGPL-3.0-only (we MUST publish source of any deployed customizations — push-mirror to GitHub satisfies this) | + +## Why we forked + +Plane's first-party OIDC support is gated behind the **Pro/Business commercial edition** (Pro tier minimum 25 users = $338+/mo). The community edition's `/god-mode/authentication/oidc` page is a frontend stub — the backend handler returns 404 (verified 2026-04-29 against pm.binarybeach.io). + +We don't want to pay $338/mo for a single binarybeachio operator's SSO. We DO want every self-hosted service to authenticate users via the same Zitadel IdP (the break-glass admin convention from `binarybeachio/docs/architecture/self-hosting-infrastructure.md` §6.1 demands it). + +Plane's backend has working **community-edition** GitHub OAuth (`/auth/github/...`). We repurpose that flow to point at Zitadel by env-driving the four GitHub URL constants and switching the userinfo claim mapping to OIDC standard. This is described in detail in `apps/api/plane/authentication/provider/oauth/github.py`'s header comment. + +## What's customized (the inventory — keep current) + +Touch surface is intentionally minimal. **Two files**, both narrowly scoped, designed to minimize merge conflict probability on every Plane upgrade. + +| File | Change | Risk on upgrade | +|---|---|---| +| `apps/api/plane/authentication/provider/oauth/github.py` | Repurposed entire file: env-drive endpoint URLs (default to `$ZITADEL_DOMAIN`'s OIDC endpoints, fall back to GitHub when `ZITADEL_DOMAIN` unset). Switch claim mapping to OIDC standard. Drop `__get_email` (OIDC userinfo includes email). Fix upstream's `expires_in` epoch-vs-duration bug. Drop `is_user_in_organization` (Zitadel handles authz). | **Medium.** This file rarely changes upstream. If Plane refactors the OauthAdapter base class signatures, our patched constructor must follow. | +| `apps/web/core/hooks/oauth/core.tsx` | Cosmetic: rename "GitHub" button text to "binarybeach.io". Backend ID/route/icon path unchanged. | **Low.** Pure cosmetic; rebases trivially. | + +Files **not** changed (deliberately): +- `apps/api/plane/authentication/views/app/github.py` — view layer, unchanged. Routes still `/auth/github/`. +- `apps/api/plane/authentication/views/space/github.py` — public-share OAuth, unchanged. +- `apps/api/plane/authentication/urls.py` — URL routing unchanged. +- `apps/admin/...` — god-mode UI still says "GitHub" provider; only the operator (us) sees it, not worth the patch surface. +- `apps/space/...` — public sharing site OAuth, not a priority for v1. + +## Required runtime config + +Set these env vars on the patched `plane-backend` container (binarybeachio sets them in `infrastructure/plane/.env`): + +```bash +# Pin our Zitadel host — this enables the OIDC code path. Without it, the +# patched provider falls back to vanilla GitHub OAuth (deliberate). +ZITADEL_DOMAIN=auth.binarybeach.io + +# Optional explicit overrides if endpoints differ from Zitadel defaults. +# Defaults derive from ZITADEL_DOMAIN: /oauth/v2/{authorize,token}, /oidc/v1/userinfo. +# OIDC_AUTH_URL= +# OIDC_TOKEN_URL= +# OIDC_USERINFO_URL= + +# Existing Plane env vars (kept names — backend still calls them GITHUB_*) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +``` + +And in Plane's god-mode admin UI (`/god-mode/authentication/github`): +- Toggle GitHub OAuth ON +- Paste the same client_id/secret (god-mode DB rows shadow env vars at runtime — both must agree) + +In Zitadel: +- Create OIDC Web application +- Redirect URI: `https://pm.binarybeach.io/auth/github/callback/` (production) and `http://localhost/auth/github/callback/` (local test) +- Auth method: client_secret_post (Plane sends creds in body) +- Grant types: Authorization Code + Refresh Token +- Response types: code + +## Refresh from upstream — the procedure + +When a new Plane release lands and we want to integrate: + +```bash +git fetch upstream +# Sync the upstream mirror branch (never touched by us) +git switch upstream +git reset --hard upstream/preview # or @v1.4.0 if we track tags +git push origin upstream + +# Integration branch +git switch main +git switch -c update/v1.4.0 +git merge upstream # resolve any conflicts (likely in github.py) +# Run all tests, hand-test the OIDC flow against staging Zitadel +# Once happy: +git switch main +git merge --ff-only update/v1.4.0 +git branch -d update/v1.4.0 +git push origin main + +# Then on laptop: rebuild + tag + push images (see "Build" below) +# Then in binarybeachio repo: bump tag in infrastructure/plane/docker-compose.yml +# Then: py infrastructure/_shared/bootstrap.py to trigger the Coolify deploy +``` + +## Build — which images to rebuild and how + +Per binarybeachio architecture doc §7.4 ("only rebuild what we touched"), this fork only requires rebuilding **two of the six** Plane images: + +| Image | Customized? | Source | Build target | +|---|---|---|---| +| `plane-backend` | YES | `apps/api/Dockerfile.api` | `git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.` | +| `plane-frontend` (aka web) | YES | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.` | +| `plane-space` | no | upstream `makeplane/plane-space:v1.3.0` | (no rebuild) | +| `plane-admin` | no | upstream `makeplane/plane-admin:v1.3.0` | (no rebuild) | +| `plane-live` | no | upstream `makeplane/plane-live:v1.3.0` | (no rebuild) | +| `plane-proxy` | no | upstream `makeplane/plane-proxy:v1.3.0` | (no rebuild) | + +The binarybeachio compose file at `infrastructure/plane/docker-compose.yml` mixes our patched images with upstream-vanilla images for the four we don't touch. + +Tag scheme per architecture §6 #7: `-mine.`. Push immutable tag + `:latest`: + +```bash +# from C:\Users\maxwe\GitHubRepos\bb-plane-fork +docker build -t git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.1 \ + -t git.binarybeach.io/binarybeach/plane-backend:latest \ + -f apps/api/Dockerfile.api . +docker push git.binarybeach.io/binarybeach/plane-backend:v1.3.0-mine.1 +docker push git.binarybeach.io/binarybeach/plane-backend:latest + +docker build -t git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.1 \ + -t git.binarybeach.io/binarybeach/plane-frontend:latest \ + -f apps/web/Dockerfile.web . +docker push git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.1 +docker push git.binarybeach.io/binarybeach/plane-frontend:latest +``` + +`mine.` resets to `mine.1` on every upstream version bump; increments per local rebuild within the same upstream version. + +## License compliance + +Plane is AGPL-3.0-only. The license requires us to provide the source of any modified version we deploy or offer over a network. Our compliance: + +1. **Forgejo source** — `git.binarybeach.io/binarybeach/bb-plane-fork` is a public-readable repository (Forgejo `DEFAULT_PRIVATE=public`). +2. **GitHub mirror** — push-mirror to `github.com/binarybeachllc/bb-plane-fork` provides off-site backup AND a publicly-discoverable source location even if Forgejo is unreachable. +3. **In-product source link** — TODO: add a footer link in our customized `apps/web` to https://git.binarybeach.io/binarybeach/bb-plane-fork. AGPL §13 requires "prominent" notice to network users; a footer suffices. + +The TODO in #3 is tracked in the parent binarybeachio repo's compliance log when we get there. Not a v1 blocker — Plane already includes upstream license notices and our changes preserve them. + +## Test plan (manual, until we have CI) + +1. **Local build smoke**: both images build cleanly with current Dockerfiles. +2. **Local stack**: `docker compose -f docker-compose-local.yml up -d` (using patched images), pointed at hosted Zitadel. +3. **OIDC flow**: visit `http://localhost`, click "Continue with binarybeach.io", redirected to `auth.binarybeach.io`, log in as Zitadel user, redirected back, account auto-provisioned in Plane, signed in. +4. **New-user flow**: sign in with a Zitadel user that doesn't yet exist in Plane → Plane auto-creates the account. +5. **Re-login**: sign out, sign in again with same Zitadel user → matched by email, same Plane user. +6. **Fallback**: unset `ZITADEL_DOMAIN` env var, restart backend, try GitHub OAuth flow with real GitHub creds → should still work (regression check that we didn't break upstream behavior). +7. **Production deploy**: bump tag in binarybeachio `infrastructure/plane/docker-compose.yml` → `py infrastructure/_shared/bootstrap.py` → verify on `pm.binarybeach.io`. diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index 363cd722e..a81ecc33f 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -1,14 +1,41 @@ # Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +# +# binarybeachio fork — see BINARYBEACHIO.md at repo root. +# This file is patched to repurpose Plane's "GitHub" OAuth provider as a +# generic OIDC provider, so we can point /auth/github/ at our self-hosted +# Zitadel instance without paying for Plane Pro/Business edition's first-party +# OIDC support. +# +# Touch points kept stable to minimize merge conflicts on Plane upgrades: +# - class name `GitHubOAuthProvider` (callers import it by name) +# - `provider = "github"` (DB rows keyed on this string) +# - env var names `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` +# - URL routes `/auth/github/...` (frontend hardcodes these) +# +# What changed: +# - `auth_url` / `token_url` / `userinfo_url` are now read from env, default +# to the Zitadel instance at $ZITADEL_DOMAIN. If `ZITADEL_DOMAIN` is unset +# the original GitHub URLs apply, so vanilla GitHub OAuth still works as a +# fallback (lets us re-test against upstream behavior without reverting). +# - Scope flipped from "read:user user:email" to "openid email profile" when +# pointed at Zitadel (or any OIDC IdP). +# - `__get_email` removed — standard OIDC userinfo includes `email` directly. +# - User claim mapping switched to OIDC standard: sub, name, given_name, +# family_name, email, picture. +# - Fixed upstream bug where `expires_in` (a duration in seconds) was being +# passed to datetime.fromtimestamp() (which expects an epoch timestamp). +# - Dropped `is_user_in_organization` — Zitadel handles authorization itself +# via project grants/roles. The `GITHUB_ORGANIZATION_ID` env stays read +# (no-op) to avoid breaking deployments that have it set. # Python imports import os -from datetime import datetime +from datetime import timedelta from urllib.parse import urlencode -import pytz -import requests +from django.utils import timezone from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, @@ -20,15 +47,30 @@ from plane.authentication.adapter.oauth import OauthAdapter from plane.license.utils.instance_value import get_configuration_value +def _zitadel_default(path: str) -> str | None: + """Build a Zitadel endpoint URL from $ZITADEL_DOMAIN if set.""" + domain = os.environ.get("ZITADEL_DOMAIN") + return f"https://{domain}{path}" if domain else None + + class GitHubOAuthProvider(OauthAdapter): - token_url = "https://github.com/login/oauth/access_token" - userinfo_url = "https://api.github.com/user" - org_membership_url = "https://api.github.com/orgs" + # Endpoint URLs — env-driven. Defaults derived from $ZITADEL_DOMAIN if set, + # falling back to GitHub.com to preserve upstream behavior when unset. + token_url = os.environ.get("OIDC_TOKEN_URL") or ( + _zitadel_default("/oauth/v2/token") or "https://github.com/login/oauth/access_token" + ) + userinfo_url = os.environ.get("OIDC_USERINFO_URL") or ( + _zitadel_default("/oidc/v1/userinfo") or "https://api.github.com/user" + ) + _auth_url_base = os.environ.get("OIDC_AUTH_URL") or ( + _zitadel_default("/oauth/v2/authorize") or "https://github.com/login/oauth/authorize" + ) provider = "github" - scope = "read:user user:email" - organization_scope = "read:org" + # Scopes — OIDC standard when ZITADEL_DOMAIN is set; GitHub-flavored otherwise + # to match unpatched upstream behavior for fallback testing. + scope = "openid email profile" if os.environ.get("ZITADEL_DOMAIN") else "read:user user:email" def __init__(self, request, code=None, state=None, callback=None): GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([ @@ -54,11 +96,13 @@ class GitHubOAuthProvider(OauthAdapter): client_id = GITHUB_CLIENT_ID client_secret = GITHUB_CLIENT_SECRET + # Read but unused — kept for API compatibility with deployments that + # had this set under upstream Plane. Authorization in our setup is + # handled by Zitadel project grants, not client-side org membership. self.organization_id = GITHUB_ORGANIZATION_ID - if self.organization_id: - self.scope += f" {self.organization_scope}" - + # Build redirect_uri — must match what's registered with the IdP. + # Plane's frontend hardcodes /auth/github/callback/ so we keep that path. redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/""" url_params = { "client_id": client_id, @@ -66,7 +110,10 @@ class GitHubOAuthProvider(OauthAdapter): "scope": self.scope, "state": state, } - auth_url = f"https://github.com/login/oauth/authorize?{urlencode(url_params)}" + # OIDC requires response_type=code; GitHub OAuth tolerates it. + if os.environ.get("ZITADEL_DOMAIN"): + url_params["response_type"] = "code" + auth_url = f"{self._auth_url_base}?{urlencode(url_params)}" super().__init__( request, self.provider, @@ -83,93 +130,84 @@ class GitHubOAuthProvider(OauthAdapter): def set_token_data(self): data = { + "grant_type": "authorization_code", "client_id": self.client_id, "client_secret": self.client_secret, "code": self.code, "redirect_uri": self.redirect_uri, } token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) + + # Fix upstream bug: `expires_in` is a duration (seconds) per RFC 6749, + # not an epoch timestamp. Compute absolute expiry correctly. + expires_in = token_response.get("expires_in") + access_token_expired_at = ( + timezone.now() + timedelta(seconds=int(expires_in)) if expires_in else None + ) + # `refresh_token_expired_at` is non-standard; some IdPs return it as + # absolute, some as duration. Zitadel doesn't return it at all. Keep the + # original interpretation as-epoch for backward-compat with upstream. + refresh_expired_raw = token_response.get("refresh_token_expired_at") + if refresh_expired_raw: + from datetime import datetime + import pytz + refresh_token_expired_at = datetime.fromtimestamp(refresh_expired_raw, tz=pytz.utc) + else: + refresh_token_expired_at = None + super().set_token_data({ "access_token": token_response.get("access_token"), "refresh_token": token_response.get("refresh_token", None), - "access_token_expired_at": ( - datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) - if token_response.get("expires_in") - else None - ), - "refresh_token_expired_at": ( - datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) - if token_response.get("refresh_token_expired_at") - else None - ), + "access_token_expired_at": access_token_expired_at, + "refresh_token_expired_at": refresh_token_expired_at, "id_token": token_response.get("id_token", ""), }) - def __get_email(self, headers): - try: - # Github does not provide email in user response - emails_url = "https://api.github.com/user/emails" - emails_response = requests.get(emails_url, headers=headers).json() - # Ensure the response is a list before iterating - if not isinstance(emails_response, list): - self.logger.error("Unexpected response format from GitHub emails API") - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", - ) - email = next((email["email"] for email in emails_response if email["primary"]), None) - if not email: - self.logger.error("No primary email found for user") - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", - ) - return email - except requests.RequestException: - self.logger.warning( - "Error getting email from GitHub", - ) - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], - error_message="GITHUB_OAUTH_PROVIDER_ERROR", - ) - - def is_user_in_organization(self, github_username): - headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"} - response = requests.get( - f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", - headers=headers, - ) - return response.status_code == 200 # 200 means the user is a member - def set_user_data(self): user_info_response = self.get_user_response() + + # Claim mapping. When ZITADEL_DOMAIN is set, use OIDC standard claims; + # otherwise fall back to GitHub's quirky shape (no email in userinfo, + # `name` instead of `given_name`/`family_name`). + if os.environ.get("ZITADEL_DOMAIN"): + email = user_info_response.get("email") + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + super().set_user_data({ + "email": email, + "user": { + "provider_id": user_info_response.get("sub"), + "email": email, + "avatar": user_info_response.get("picture"), + "first_name": user_info_response.get("given_name") or user_info_response.get("name", "").split(" ", 1)[0], + "last_name": user_info_response.get("family_name") or (user_info_response.get("name", "").split(" ", 1)[1] if " " in user_info_response.get("name", "") else ""), + "is_password_autoset": True, + }, + }) + return + + # Fallback: vanilla GitHub OAuth — keep upstream behavior. Email comes + # from a separate /user/emails call. + import requests headers = { "Authorization": f"Bearer {self.token_data.get('access_token')}", "Accept": "application/json", } - - if self.organization_id: - if not self.is_user_in_organization(user_info_response.get("login")): - self.logger.warning( - "User is not in organization", - extra={ - "organization_id": self.organization_id, - "user_login": user_info_response.get("login"), - }, - ) - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"], - error_message="GITHUB_USER_NOT_IN_ORG", - ) - - email = self.__get_email(headers=headers) - self.logger.debug( - "Email found", - extra={ - "email": email, - }, - ) + emails_response = requests.get("https://api.github.com/user/emails", headers=headers).json() + if not isinstance(emails_response, list): + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) + email = next((e["email"] for e in emails_response if e["primary"]), None) + if not email: + raise AuthenticationException( + error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"], + error_message="GITHUB_OAUTH_PROVIDER_ERROR", + ) super().set_user_data({ "email": email, "user": { diff --git a/apps/web/core/hooks/oauth/core.tsx b/apps/web/core/hooks/oauth/core.tsx index 1614883fe..1c280e173 100644 --- a/apps/web/core/hooks/oauth/core.tsx +++ b/apps/web/core/hooks/oauth/core.tsx @@ -46,14 +46,18 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => { enabled: config?.is_google_enabled, }, { + // binarybeachio fork — this OAuth slot is repurposed as our Zitadel SSO + // entry point (the backend's GitHubOAuthProvider was patched to point at + // Zitadel — see provider/oauth/github.py). Branding is rebranded here; + // backend identifiers (route, env vars, DB provider key) stay "github". id: "github", - text: `${oauthActionText} with GitHub`, + text: `${oauthActionText} with binarybeach.io`, icon: ( GitHub Logo ), onClick: () => {