From 8109b3c8ac92195878c362888f744b9e3fe76e33 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:50:59 +0530 Subject: [PATCH 1/2] fix(github): store oauth tokens per user and secure the callback --- .../doctype/hive_settings/hive_settings.json | 24 ----- bwh_hive/bwh_hive/github.py | 88 ++++++++++++++++++- bwh_hive/www/github/authorize.py | 30 +++---- .../src/components/settings/GitHubSection.tsx | 88 ++++++++++++++++--- 4 files changed, 174 insertions(+), 56 deletions(-) diff --git a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json index a4e4573..c9bbb2a 100644 --- a/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json +++ b/bwh_hive/bwh_hive/doctype/hive_settings/hive_settings.json @@ -13,9 +13,6 @@ "github_app_client_secret", "github_app_public_link", "github_app_private_key", - "github_access_token", - "github_username", - "github_authorized_at", "agent_section", "agent_orchestration_enabled", "benchspace_api_url", @@ -98,27 +95,6 @@ "label": "GitHub App Private Key", "read_only": 1 }, - { - "fieldname": "github_access_token", - "fieldtype": "Password", - "hidden": 1, - "label": "GitHub Access Token", - "read_only": 1 - }, - { - "fieldname": "github_username", - "fieldtype": "Data", - "hidden": 1, - "label": "GitHub Username", - "read_only": 1 - }, - { - "fieldname": "github_authorized_at", - "fieldtype": "Datetime", - "hidden": 1, - "label": "GitHub Authorized At", - "read_only": 1 - }, { "fieldname": "agent_section", "fieldtype": "Section Break", diff --git a/bwh_hive/bwh_hive/github.py b/bwh_hive/bwh_hive/github.py index 6e32b66..99d880e 100644 --- a/bwh_hive/bwh_hive/github.py +++ b/bwh_hive/bwh_hive/github.py @@ -1,10 +1,13 @@ import time +from urllib.parse import urlencode import jwt import requests import frappe +OAUTH_STATE_TTL_SECONDS = 10 * 60 + def _get_app_jwt(settings) -> str: """Generate a short-lived JWT signed with the GitHub App's private key.""" @@ -58,6 +61,80 @@ def _get_installation_token(settings) -> str: return resp.json()["token"] +def _oauth_state_key(nonce: str) -> str: + return f"github_oauth_state:{nonce}" + + +def _new_oauth_state() -> str: + """Mint a single-use nonce tied to the current user for the OAuth `state` parameter.""" + nonce = frappe.generate_hash(length=32) + frappe.cache.set_value( + _oauth_state_key(nonce), frappe.session.user, expires_in_sec=OAUTH_STATE_TTL_SECONDS + ) + return nonce + + +def consume_oauth_state(nonce: str) -> str | None: + """Return the user who started this OAuth flow, invalidating the nonce.""" + key = _oauth_state_key(nonce) + user = frappe.cache.get_value(key) + frappe.cache.delete_value(key) + return user + + +def _get_user_token() -> str: + """Get the current user's GitHub OAuth token.""" + token = None + if frappe.db.exists("GitHub Token", frappe.session.user): + token = frappe.get_doc("GitHub Token", frappe.session.user).get_password( + "access_token", raise_exception=False + ) + + if not token: + frappe.throw("Connect your GitHub account from Settings first.") + + return token + + +@frappe.whitelist(methods=["POST"]) +def connect_url() -> str: + """Build the GitHub OAuth URL that links the current user's GitHub account.""" + settings = frappe.get_single("Hive Settings") + if not settings.github_app_client_id: + frappe.throw("GitHub App not configured.") + + params = urlencode( + { + "client_id": settings.github_app_client_id, + "redirect_uri": frappe.utils.get_url("/github/authorize"), + "state": _new_oauth_state(), + } + ) + return f"https://github.com/login/oauth/authorize?{params}" + + +@frappe.whitelist(methods=["POST"]) +def install_url() -> str: + """Build the GitHub App installation URL. + + Carries an OAuth state so the post-install redirect also connects the user's account, + since the App manifest sets `request_oauth_on_install`. + """ + settings = frappe.get_single("Hive Settings") + if not settings.github_app_public_link: + frappe.throw("GitHub App not configured.") + + params = urlencode({"state": _new_oauth_state()}) + return f"{settings.github_app_public_link}/installations/new?{params}" + + +@frappe.whitelist(methods=["POST"]) +def disconnect() -> None: + """Unlink the current user's GitHub account.""" + if frappe.db.exists("GitHub Token", frappe.session.user): + frappe.delete_doc("GitHub Token", frappe.session.user, ignore_permissions=True) + + @frappe.whitelist() def status() -> dict: settings = frappe.get_single("Hive Settings") @@ -85,10 +162,16 @@ def status() -> dict: except Exception: pass + github_token = frappe.db.get_value( + "GitHub Token", frappe.session.user, "github_username", as_dict=True + ) + return { "app_configured": app_configured, "connected": connected, "installed_account": installed_account, + "account_connected": bool(github_token), + "github_username": github_token.github_username if github_token else None, } @@ -132,7 +215,7 @@ def get_repos() -> list[dict]: @frappe.whitelist() def create_issue(task_name: str) -> dict: - """Convert a Hive Task into a GitHub issue using an installation token.""" + """Convert a Hive Task into a GitHub issue authored by the current user.""" task = frappe.get_doc("Hive Task", task_name) if task.github_issue_url: @@ -142,8 +225,7 @@ def create_issue(task_name: str) -> dict: if not project.github_repo: frappe.throw("No GitHub repository linked to this project. Set it in the project settings.") - settings = frappe.get_single("Hive Settings") - token = _get_installation_token(settings) + token = _get_user_token() # Build issue body body = "" diff --git a/bwh_hive/www/github/authorize.py b/bwh_hive/www/github/authorize.py index 23dbadc..9a2f389 100644 --- a/bwh_hive/www/github/authorize.py +++ b/bwh_hive/www/github/authorize.py @@ -1,28 +1,21 @@ -import json -from base64 import b64decode - import requests import frappe from frappe.utils import now_datetime +from bwh_hive.bwh_hive.github import consume_oauth_state + def get_context(context): code = frappe.form_dict.get("code") state = frappe.form_dict.get("state") - if not code or not state: + if not code: frappe.flags.redirect_location = "/hive" raise frappe.Redirect - # Validate state - try: - state_data = json.loads(b64decode(state).decode()) - except Exception: - frappe.throw("Invalid state parameter.") - - if state_data.get("user") != frappe.session.user: - frappe.throw("State mismatch. Please try connecting again.") + if not state or consume_oauth_state(state) != frappe.session.user: + frappe.throw("This GitHub authorization link is invalid or has expired. Try connecting again.") # Exchange code for access token settings = frappe.get_single("Hive Settings") @@ -55,10 +48,15 @@ def get_context(context): github_user = user_response.json() github_username = github_user.get("login", "") - # Store on Hive Settings (site-level) - settings.db_set("github_access_token", access_token) - settings.db_set("github_username", github_username) - settings.db_set("github_authorized_at", now_datetime()) + if frappe.db.exists("GitHub Token", frappe.session.user): + token = frappe.get_doc("GitHub Token", frappe.session.user) + else: + token = frappe.new_doc("GitHub Token", user=frappe.session.user) + + token.access_token = access_token + token.github_username = github_username + token.authorized_at = now_datetime() + token.save(ignore_permissions=True) frappe.db.commit() frappe.flags.redirect_location = "/hive" diff --git a/frontend/src/components/settings/GitHubSection.tsx b/frontend/src/components/settings/GitHubSection.tsx index d0a8b02..6ee08c8 100644 --- a/frontend/src/components/settings/GitHubSection.tsx +++ b/frontend/src/components/settings/GitHubSection.tsx @@ -1,4 +1,4 @@ -import { useFrappeGetCall, useFrappeGetDoc } from "frappe-react-sdk" +import { useFrappeGetCall, useFrappeGetDoc, useFrappePostCall } from "frappe-react-sdk" import { useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -16,6 +16,8 @@ interface GitHubStatus { app_configured: boolean connected: boolean installed_account: string | null + account_connected: boolean + github_username: string | null } export function GitHubSection() { @@ -23,8 +25,19 @@ export function GitHubSection() { "Hive Settings", "Hive Settings", ) - const { data: ghStatus, isLoading: statusLoading } = useFrappeGetCall<{ message: GitHubStatus }>( - "bwh_hive.bwh_hive.github.status", + const { + data: ghStatus, + isLoading: statusLoading, + mutate: mutateStatus, + } = useFrappeGetCall<{ message: GitHubStatus }>("bwh_hive.bwh_hive.github.status") + const { call: getConnectUrl } = useFrappePostCall<{ message: string }>( + "bwh_hive.bwh_hive.github.connect_url", + ) + const { call: getInstallUrl } = useFrappePostCall<{ message: string }>( + "bwh_hive.bwh_hive.github.install_url", + ) + const { call: disconnectAccount } = useFrappePostCall<{ message: null }>( + "bwh_hive.bwh_hive.github.disconnect", ) const [submitting, setSubmitting] = useState(false) const [orgName, setOrgName] = useState("") @@ -32,6 +45,34 @@ export function GitHubSection() { const isConfigured = Boolean(settings?.github_app_id) const status = ghStatus?.message + const handleConnect = async () => { + try { + const res = await getConnectUrl({}) + window.location.href = res.message + } catch { + toast.error("Failed to start GitHub authorization") + } + } + + const handleInstall = async () => { + try { + const res = await getInstallUrl({}) + window.location.href = res.message + } catch { + toast.error("Failed to open GitHub App installation") + } + } + + const handleDisconnect = async () => { + try { + await disconnectAccount({}) + await mutateStatus() + toast.success("GitHub account disconnected") + } catch { + toast.error("Failed to disconnect GitHub account") + } + } + const handleCreateApp = async () => { setSubmitting(true) try { @@ -158,20 +199,41 @@ export function GitHubSection() { Not installed yet.

{settings?.github_app_public_link && ( - - - + )} )} + + {/* Per-user account connection */} +
+
+

Your GitHub account

+

+ Connect your account so issues you create are authored by you. +

+
+ + {status?.account_connected ? ( +
+ + Connected + + + as {status.github_username} + + +
+ ) : ( + + )} +
)} From 3c38697e9fba7426a0a74b9a333fabd0426825b0 Mon Sep 17 00:00:00 2001 From: Kaushal Shriwas <64089478+kaulith@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:57:58 +0530 Subject: [PATCH 2/2] fix(github): wrap user-facing messages in translate function --- bwh_hive/bwh_hive/github.py | 6 +++--- bwh_hive/www/github/authorize.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bwh_hive/bwh_hive/github.py b/bwh_hive/bwh_hive/github.py index 99d880e..9cf7ce3 100644 --- a/bwh_hive/bwh_hive/github.py +++ b/bwh_hive/bwh_hive/github.py @@ -91,7 +91,7 @@ def _get_user_token() -> str: ) if not token: - frappe.throw("Connect your GitHub account from Settings first.") + frappe.throw(frappe._("Connect your GitHub account from Settings first.")) return token @@ -101,7 +101,7 @@ def connect_url() -> str: """Build the GitHub OAuth URL that links the current user's GitHub account.""" settings = frappe.get_single("Hive Settings") if not settings.github_app_client_id: - frappe.throw("GitHub App not configured.") + frappe.throw(frappe._("GitHub App not configured.")) params = urlencode( { @@ -122,7 +122,7 @@ def install_url() -> str: """ settings = frappe.get_single("Hive Settings") if not settings.github_app_public_link: - frappe.throw("GitHub App not configured.") + frappe.throw(frappe._("GitHub App not configured.")) params = urlencode({"state": _new_oauth_state()}) return f"{settings.github_app_public_link}/installations/new?{params}" diff --git a/bwh_hive/www/github/authorize.py b/bwh_hive/www/github/authorize.py index 9a2f389..ef9b68c 100644 --- a/bwh_hive/www/github/authorize.py +++ b/bwh_hive/www/github/authorize.py @@ -15,7 +15,9 @@ def get_context(context): raise frappe.Redirect if not state or consume_oauth_state(state) != frappe.session.user: - frappe.throw("This GitHub authorization link is invalid or has expired. Try connecting again.") + frappe.throw( + frappe._("This GitHub authorization link is invalid or has expired. Try connecting again.") + ) # Exchange code for access token settings = frappe.get_single("Hive Settings")