Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
2a78f0e0ce 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.
2026-04-29 16:50:40 -10:00
3 changed files with 291 additions and 83 deletions

166
BINARYBEACHIO.md Normal file
View file

@ -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-<name>-fork (where we push)
github mirror → github.com/binarybeachllc/bb-<name>-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/<v> — 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=<zitadel-app-client-id>
GITHUB_CLIENT_SECRET=<zitadel-app-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.<n>` |
| `plane-frontend` (aka web) | YES | `apps/web/Dockerfile.web` | `git.binarybeach.io/binarybeach/plane-frontend:v1.3.0-mine.<n>` |
| `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: `<upstream-version>-mine.<n>`. 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.<n>` 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`.

View file

@ -1,14 +1,41 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors # Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details. # 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 # Python imports
import os import os
from datetime import datetime from datetime import timedelta
from urllib.parse import urlencode from urllib.parse import urlencode
import pytz from django.utils import timezone
import requests
from plane.authentication.adapter.error import ( from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES, AUTHENTICATION_ERROR_CODES,
@ -20,15 +47,30 @@ from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value 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): class GitHubOAuthProvider(OauthAdapter):
token_url = "https://github.com/login/oauth/access_token" # Endpoint URLs — env-driven. Defaults derived from $ZITADEL_DOMAIN if set,
userinfo_url = "https://api.github.com/user" # falling back to GitHub.com to preserve upstream behavior when unset.
org_membership_url = "https://api.github.com/orgs" 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" 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): def __init__(self, request, code=None, state=None, callback=None):
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([ 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_id = GITHUB_CLIENT_ID
client_secret = GITHUB_CLIENT_SECRET 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 self.organization_id = GITHUB_ORGANIZATION_ID
if self.organization_id: # Build redirect_uri — must match what's registered with the IdP.
self.scope += f" {self.organization_scope}" # 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/""" redirect_uri = f"""{"https" if request.is_secure() else "http"}://{request.get_host()}/auth/github/callback/"""
url_params = { url_params = {
"client_id": client_id, "client_id": client_id,
@ -66,7 +110,10 @@ class GitHubOAuthProvider(OauthAdapter):
"scope": self.scope, "scope": self.scope,
"state": state, "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__( super().__init__(
request, request,
self.provider, self.provider,
@ -83,93 +130,84 @@ class GitHubOAuthProvider(OauthAdapter):
def set_token_data(self): def set_token_data(self):
data = { data = {
"grant_type": "authorization_code",
"client_id": self.client_id, "client_id": self.client_id,
"client_secret": self.client_secret, "client_secret": self.client_secret,
"code": self.code, "code": self.code,
"redirect_uri": self.redirect_uri, "redirect_uri": self.redirect_uri,
} }
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) 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({ super().set_token_data({
"access_token": token_response.get("access_token"), "access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None), "refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": ( "access_token_expired_at": access_token_expired_at,
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) "refresh_token_expired_at": refresh_token_expired_at,
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
),
"id_token": token_response.get("id_token", ""), "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): def set_user_data(self):
user_info_response = self.get_user_response() 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 = { headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}", "Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json", "Accept": "application/json",
} }
emails_response = requests.get("https://api.github.com/user/emails", headers=headers).json()
if self.organization_id: if not isinstance(emails_response, list):
if not self.is_user_in_organization(user_info_response.get("login")): raise AuthenticationException(
self.logger.warning( error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
"User is not in organization", error_message="GITHUB_OAUTH_PROVIDER_ERROR",
extra={ )
"organization_id": self.organization_id, email = next((e["email"] for e in emails_response if e["primary"]), None)
"user_login": user_info_response.get("login"), if not email:
}, raise AuthenticationException(
) error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
raise AuthenticationException( error_message="GITHUB_OAUTH_PROVIDER_ERROR",
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,
},
)
super().set_user_data({ super().set_user_data({
"email": email, "email": email,
"user": { "user": {

View file

@ -46,14 +46,18 @@ export const useCoreOAuthConfig = (oauthActionText: string): TOAuthConfigs => {
enabled: config?.is_google_enabled, 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", id: "github",
text: `${oauthActionText} with GitHub`, text: `${oauthActionText} with binarybeach.io`,
icon: ( icon: (
<img <img
src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo} src={resolvedTheme === "dark" ? GithubDarkLogo : GithubLightLogo}
height={18} height={18}
width={18} width={18}
alt="GitHub Logo" alt="binarybeach.io SSO"
/> />
), ),
onClick: () => { onClick: () => {