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.
221 lines
9.7 KiB
Python
221 lines
9.7 KiB
Python
# 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 timedelta
|
|
from urllib.parse import urlencode
|
|
|
|
from django.utils import timezone
|
|
|
|
from plane.authentication.adapter.error import (
|
|
AUTHENTICATION_ERROR_CODES,
|
|
AuthenticationException,
|
|
)
|
|
|
|
# Module imports
|
|
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):
|
|
# 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"
|
|
|
|
# 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([
|
|
{
|
|
"key": "GITHUB_CLIENT_ID",
|
|
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
|
},
|
|
{
|
|
"key": "GITHUB_CLIENT_SECRET",
|
|
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
|
},
|
|
{
|
|
"key": "GITHUB_ORGANIZATION_ID",
|
|
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
|
},
|
|
])
|
|
|
|
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
|
raise AuthenticationException(
|
|
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_NOT_CONFIGURED"],
|
|
error_message="GITHUB_NOT_CONFIGURED",
|
|
)
|
|
|
|
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
|
|
|
|
# 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,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": self.scope,
|
|
"state": state,
|
|
}
|
|
# 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,
|
|
client_id,
|
|
self.scope,
|
|
redirect_uri,
|
|
auth_url,
|
|
self.token_url,
|
|
self.userinfo_url,
|
|
client_secret,
|
|
code,
|
|
callback=callback,
|
|
)
|
|
|
|
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": access_token_expired_at,
|
|
"refresh_token_expired_at": refresh_token_expired_at,
|
|
"id_token": token_response.get("id_token", ""),
|
|
})
|
|
|
|
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",
|
|
}
|
|
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": {
|
|
"provider_id": user_info_response.get("id"),
|
|
"email": email,
|
|
"avatar": user_info_response.get("avatar_url"),
|
|
"first_name": user_info_response.get("name"),
|
|
"last_name": user_info_response.get("family_name"),
|
|
"is_password_autoset": True,
|
|
},
|
|
})
|