4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
214 lines
8.0 KiB
Python
214 lines
8.0 KiB
Python
"""GitHub OAuth device-flow authorization for the GitHub MCP integration.
|
|
|
|
Browser-based "authorize the app" login that yields a GitHub user access token
|
|
usable as the GitHub MCP ``Authorization: Bearer`` credential.
|
|
|
|
Device flow needs only a *public* OAuth App ``client_id`` (no client secret),
|
|
which is why it is safe to ship in a distributed CLI. Register an OAuth App with
|
|
"Enable Device Flow" checked, then expose its client id via
|
|
``OPENSRE_GITHUB_OAUTH_CLIENT_ID`` (or bake it into ``DEFAULT_GITHUB_OAUTH_CLIENT_ID``).
|
|
|
|
GitHub does not advertise dynamic client registration, so a pre-registered app is
|
|
required regardless of the flow; device flow keeps the implementation minimal
|
|
(no localhost redirect server, no PKCE plumbing, no embedded secret).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
import webbrowser
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
|
GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"
|
|
GITHUB_DEVICE_VERIFICATION_URL = "https://github.com/login/device"
|
|
_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
|
|
|
|
# Read-oriented repository investigation scopes. All are listed in the GitHub MCP
|
|
# server's advertised ``scopes_supported``.
|
|
DEFAULT_GITHUB_OAUTH_SCOPES: tuple[str, ...] = ("repo", "read:org", "read:user")
|
|
|
|
# Public OAuth App client id shipped with OpenSRE (device flow enabled). This is
|
|
# NOT a secret — device flow has no client secret. Override at runtime with
|
|
# ``OPENSRE_GITHUB_OAUTH_CLIENT_ID`` to point at a different OAuth App.
|
|
DEFAULT_GITHUB_OAUTH_CLIENT_ID = "Ov23li3MyquuARMTSibo"
|
|
|
|
_MIN_POLL_INTERVAL = 1.0
|
|
_SLOW_DOWN_BACKOFF = 5.0
|
|
|
|
|
|
class GitHubDeviceFlowError(RuntimeError):
|
|
"""Raised when device-flow authorization cannot complete."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GitHubDeviceCode:
|
|
"""Device/user codes returned by the device-authorization request."""
|
|
|
|
device_code: str
|
|
user_code: str
|
|
verification_uri: str
|
|
expires_in: int
|
|
interval: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GitHubDeviceToken:
|
|
"""A GitHub user access token obtained via device flow."""
|
|
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
scope: str = ""
|
|
refresh_token: str = ""
|
|
expires_in: int | None = None
|
|
|
|
|
|
def resolve_github_oauth_client_id(explicit: str = "") -> str:
|
|
"""Resolve the OAuth App client id from arg, env, then shipped default."""
|
|
return (
|
|
(explicit or "").strip()
|
|
or os.getenv("OPENSRE_GITHUB_OAUTH_CLIENT_ID", "").strip()
|
|
or DEFAULT_GITHUB_OAUTH_CLIENT_ID.strip()
|
|
)
|
|
|
|
|
|
def _device_error_message(payload: dict[str, object]) -> str:
|
|
error = str(payload.get("error") or "").strip()
|
|
if error == "device_flow_disabled":
|
|
return (
|
|
"Device flow is not enabled on this GitHub OAuth App. Open the app's "
|
|
"settings and check 'Enable Device Flow'."
|
|
)
|
|
description = str(payload.get("error_description") or error or "unknown error").strip()
|
|
return f"GitHub device authorization failed: {description}"
|
|
|
|
|
|
def request_github_device_code(
|
|
*,
|
|
client_id: str,
|
|
scopes: Sequence[str] = DEFAULT_GITHUB_OAUTH_SCOPES,
|
|
timeout: float = 15.0,
|
|
) -> GitHubDeviceCode:
|
|
"""Start device authorization: ask GitHub for a device + user code."""
|
|
response = httpx.post(
|
|
GITHUB_DEVICE_CODE_URL,
|
|
data={"client_id": client_id, "scope": " ".join(scopes)},
|
|
headers={"Accept": "application/json"},
|
|
timeout=timeout,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not isinstance(payload, dict) or "device_code" not in payload:
|
|
raise GitHubDeviceFlowError(
|
|
_device_error_message(payload if isinstance(payload, dict) else {})
|
|
)
|
|
return GitHubDeviceCode(
|
|
device_code=str(payload["device_code"]),
|
|
user_code=str(payload.get("user_code", "")),
|
|
verification_uri=str(payload.get("verification_uri") or GITHUB_DEVICE_VERIFICATION_URL),
|
|
expires_in=int(payload.get("expires_in", 900)),
|
|
interval=int(payload.get("interval", 5)),
|
|
)
|
|
|
|
|
|
def poll_github_device_token(
|
|
*,
|
|
client_id: str,
|
|
device_code: GitHubDeviceCode,
|
|
timeout: float = 15.0,
|
|
sleep: Callable[[float], None] = time.sleep,
|
|
monotonic: Callable[[], float] = time.monotonic,
|
|
) -> GitHubDeviceToken:
|
|
"""Poll the token endpoint until the user approves (or the code expires)."""
|
|
interval = max(float(device_code.interval), _MIN_POLL_INTERVAL)
|
|
deadline = monotonic() + float(device_code.expires_in)
|
|
while True:
|
|
if monotonic() >= deadline:
|
|
raise GitHubDeviceFlowError(
|
|
"Device authorization expired before it was approved. Re-run setup."
|
|
)
|
|
sleep(interval)
|
|
response = httpx.post(
|
|
GITHUB_ACCESS_TOKEN_URL,
|
|
data={
|
|
"client_id": client_id,
|
|
"device_code": device_code.device_code,
|
|
"grant_type": _DEVICE_GRANT_TYPE,
|
|
},
|
|
headers={"Accept": "application/json"},
|
|
timeout=timeout,
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if not isinstance(payload, dict):
|
|
raise GitHubDeviceFlowError("GitHub returned an unexpected token response.")
|
|
|
|
error = str(payload.get("error") or "").strip()
|
|
if not error:
|
|
access_token = str(payload.get("access_token") or "")
|
|
if not access_token:
|
|
raise GitHubDeviceFlowError("GitHub returned no access token.")
|
|
expires_raw = payload.get("expires_in")
|
|
return GitHubDeviceToken(
|
|
access_token=access_token,
|
|
token_type=str(payload.get("token_type") or "bearer"),
|
|
scope=str(payload.get("scope") or ""),
|
|
refresh_token=str(payload.get("refresh_token") or ""),
|
|
expires_in=int(expires_raw) if expires_raw is not None else None,
|
|
)
|
|
if error == "authorization_pending":
|
|
continue
|
|
if error == "slow_down":
|
|
interval = max(interval, float(payload.get("interval", interval + _SLOW_DOWN_BACKOFF)))
|
|
continue
|
|
if error in {"expired_token", "access_denied"}:
|
|
msg = (
|
|
"Authorization was denied in the browser."
|
|
if error == "access_denied"
|
|
else "Device code expired before approval. Re-run setup."
|
|
)
|
|
raise GitHubDeviceFlowError(msg)
|
|
raise GitHubDeviceFlowError(_device_error_message(payload))
|
|
|
|
|
|
def authorize_github_via_device_flow(
|
|
*,
|
|
client_id: str = "",
|
|
scopes: Sequence[str] = DEFAULT_GITHUB_OAUTH_SCOPES,
|
|
open_browser: bool = True,
|
|
on_prompt: Callable[[GitHubDeviceCode], None] | None = None,
|
|
poll_sleep: Callable[[float], None] | None = None,
|
|
) -> GitHubDeviceToken:
|
|
"""Run the full browser device flow and return a user access token.
|
|
|
|
``on_prompt`` is invoked with the device/user codes so the caller can show
|
|
the user code and verification URL before (optionally) opening the browser.
|
|
"""
|
|
resolved_client_id = resolve_github_oauth_client_id(client_id)
|
|
if not resolved_client_id:
|
|
raise GitHubDeviceFlowError(
|
|
"No GitHub OAuth client id is configured. Register an OAuth App with "
|
|
"Device Flow enabled and set OPENSRE_GITHUB_OAUTH_CLIENT_ID."
|
|
)
|
|
|
|
device_code = request_github_device_code(client_id=resolved_client_id, scopes=scopes)
|
|
if on_prompt is not None:
|
|
on_prompt(device_code)
|
|
if open_browser:
|
|
try:
|
|
webbrowser.open(device_code.verification_uri)
|
|
except Exception: # pragma: no cover - headless/no-browser environments
|
|
logger.debug("Could not open a browser for GitHub device authorization", exc_info=True)
|
|
return poll_github_device_token(
|
|
client_id=resolved_client_id,
|
|
device_code=device_code,
|
|
sleep=poll_sleep or time.sleep,
|
|
)
|