mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 22:13:08 +00:00
One GitHub connection feeds both a Knowledge source and an agent tool. Users connect with a personal access token, checked against GitHub and named after the account, or, when an admin registers a GitHub App (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_APP_SLUG), with Sign in with GitHub. App tokens expire after eight hours and are refreshed before each sync or tool call; tokens with no expiry are never treated as expired. The catalog gains an optional second sign-in method (oauth_settings, exposed as sign_in_methods) without changing any other connector. Sync lists the repositories the connection can read (the token's own, or the App installations') and ingests one with the connection's token. The tool is GitHub's read-only MCP server (api.githubcopilot.com/mcp/readonly): setup discovers its actions and creates it bound to the connection, and the executor sends the connection's token only to that server.
149 lines
6.3 KiB
Python
149 lines
6.3 KiB
Python
"""Sign in with GitHub through a GitHub App (user access tokens).
|
|
|
|
A GitHub App's user token can read what both the user and the app's
|
|
installations can see: the repositories are chosen when the user installs
|
|
the app, not with OAuth scopes. Tokens last eight hours and come with a
|
|
six-month refresh token, unless the app turned token expiry off, in which
|
|
case they carry no expiry and never need refreshing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
from typing import Any, Dict, Optional
|
|
from urllib.parse import urlencode
|
|
|
|
import requests
|
|
|
|
from docsgpt.core.settings import settings
|
|
from docsgpt.parser.connectors.base import BaseConnectorAuth
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
API_URL = "https://api.github.com"
|
|
|
|
|
|
class GitHubAuth(BaseConnectorAuth):
|
|
"""OAuth web flow of the GitHub App set in ``GITHUB_CLIENT_ID`` and friends."""
|
|
|
|
AUTH_URL = "https://github.com/login/oauth/authorize"
|
|
TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
# Refresh this long before the stated expiry, so a token never lapses mid-request.
|
|
EXPIRY_MARGIN = datetime.timedelta(minutes=5)
|
|
|
|
def __init__(self):
|
|
self.client_id = settings.GITHUB_CLIENT_ID
|
|
self.client_secret = settings.GITHUB_CLIENT_SECRET
|
|
self.app_slug = settings.GITHUB_APP_SLUG
|
|
self.redirect_uri = settings.CONNECTOR_REDIRECT_BASE_URI
|
|
if not self.client_id or not self.client_secret:
|
|
raise ValueError(
|
|
"GitHub App credentials not configured. "
|
|
"Please set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET in settings."
|
|
)
|
|
|
|
def get_authorization_url(self, state: Optional[str] = None) -> str:
|
|
"""The GitHub page that asks the user to authorize the app."""
|
|
params = {"client_id": self.client_id, "redirect_uri": self.redirect_uri, "state": state}
|
|
return f"{self.AUTH_URL}?{urlencode({k: v for k, v in params.items() if v})}"
|
|
|
|
def get_installation_url(self, state: Optional[str] = None) -> str:
|
|
"""The GitHub page where the user installs the app and picks repositories.
|
|
|
|
With "Request user authorization (OAuth) during installation" on, GitHub
|
|
sends the user back to the callback with a code and this ``state``.
|
|
"""
|
|
base = f"https://github.com/apps/{self.app_slug}/installations/new"
|
|
return f"{base}?{urlencode({'state': state})}" if state else base
|
|
|
|
def _token_request(self, data: Dict[str, str]) -> Dict[str, Any]:
|
|
"""POST to the token endpoint; GitHub reports failures as 200 with ``error``."""
|
|
response = requests.post(
|
|
self.TOKEN_URL,
|
|
data={"client_id": self.client_id, "client_secret": self.client_secret, **data},
|
|
headers={"Accept": "application/json"},
|
|
timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not isinstance(payload, dict) or payload.get("error") or not payload.get("access_token"):
|
|
error = payload.get("error") if isinstance(payload, dict) else None
|
|
raise ValueError(f"GitHub refused the sign-in: {error or 'no access token returned'}")
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _tokens(payload: Dict[str, Any], refresh_token: Optional[str] = None) -> Dict[str, Any]:
|
|
"""Token info from a token response; ``expiry`` is None for non-expiring tokens."""
|
|
expires_in = payload.get("expires_in")
|
|
expiry = None
|
|
if expires_in:
|
|
expiry = (
|
|
datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=int(expires_in))
|
|
).isoformat()
|
|
return {
|
|
"access_token": payload["access_token"],
|
|
"refresh_token": payload.get("refresh_token") or refresh_token,
|
|
"token_uri": GitHubAuth.TOKEN_URL,
|
|
"expiry": expiry,
|
|
}
|
|
|
|
def exchange_code_for_tokens(self, authorization_code: str) -> Dict[str, Any]:
|
|
"""Trade the callback's code for tokens, plus the account's login.
|
|
|
|
Raises:
|
|
ValueError: No code, or GitHub refused it.
|
|
"""
|
|
if not authorization_code:
|
|
raise ValueError("Authorization code is required")
|
|
payload = self._token_request({"code": authorization_code, "redirect_uri": self.redirect_uri})
|
|
token_info = self._tokens(payload)
|
|
token_info["user_info"] = self._fetch_user(token_info["access_token"])
|
|
return token_info
|
|
|
|
def refresh_access_token(self, refresh_token: str) -> Dict[str, Any]:
|
|
"""A new access token (and a new refresh token: GitHub rotates them).
|
|
|
|
Raises:
|
|
ValueError: The refresh token was refused (expired or revoked).
|
|
"""
|
|
if not refresh_token:
|
|
raise ValueError("Refresh token is required")
|
|
payload = self._token_request({"grant_type": "refresh_token", "refresh_token": refresh_token})
|
|
return self._tokens(payload, refresh_token)
|
|
|
|
def is_token_expired(self, token_info: Dict[str, Any]) -> bool:
|
|
"""Whether the access token is (about to be) expired.
|
|
|
|
A token with no expiry never expires: the app has token expiry
|
|
turned off.
|
|
"""
|
|
if not token_info or not token_info.get("access_token"):
|
|
return True
|
|
expiry = token_info.get("expiry")
|
|
if not expiry:
|
|
return False
|
|
try:
|
|
expiry_dt = datetime.datetime.fromisoformat(expiry)
|
|
except (TypeError, ValueError):
|
|
return True
|
|
if expiry_dt.tzinfo is None:
|
|
expiry_dt = expiry_dt.replace(tzinfo=datetime.timezone.utc)
|
|
return datetime.datetime.now(datetime.timezone.utc) >= expiry_dt - self.EXPIRY_MARGIN
|
|
|
|
@staticmethod
|
|
def _fetch_user(access_token: str) -> Dict[str, Any]:
|
|
"""``{login, name}`` of the signed-in account; empty when GitHub does not say."""
|
|
try:
|
|
response = requests.get(
|
|
f"{API_URL}/user",
|
|
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github+json"},
|
|
timeout=30,
|
|
)
|
|
response.raise_for_status()
|
|
user = response.json()
|
|
except Exception as exc: # the sign-in still works; only its label is missing
|
|
logger.warning("Could not read the GitHub account: %s", type(exc).__name__)
|
|
return {}
|
|
return {"login": user.get("login", ""), "name": user.get("name") or ""}
|