# 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, }, })