"""GitHub OAuth and LangSmith authentication utilities.""" from __future__ import annotations import logging import os from datetime import UTC, datetime, timedelta from typing import Any, Literal import httpx import jwt from langgraph.config import get_config from langgraph.graph.state import RunnableConfig from langgraph_sdk import get_client from .github_app import get_github_app_installation_token_with_expiry from .github_token import cache_github_token_for_thread, get_github_token_from_thread from .http import DEFAULT_HTTP_TIMEOUT from .linear import comment_on_linear_issue from .slack import post_slack_thread_reply logger = logging.getLogger(__name__) client = get_client() class GitHubUserAuthRequired(RuntimeError): """Raised when a mapped user has no valid GitHub OAuth token. Signals that the run cannot proceed on the user's behalf and that the user must (re-)authenticate. The Slack webhook blocks before creating a run, so this is a defense-in-depth signal at execution time. """ def __init__(self, source: str, github_login: str | None) -> None: self.source = source self.github_login = github_login super().__init__(f"GitHub authentication required for {source} user '{github_login}'") LANGSMITH_API_KEY = os.environ.get("LANGSMITH_API_KEY_PROD", "") LANGSMITH_API_URL = os.environ.get("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com") LANGSMITH_HOST_API_URL = os.environ.get("LANGSMITH_HOST_API_URL", "https://api.host.langchain.com") GITHUB_OAUTH_PROVIDER_ID = os.environ.get("GITHUB_OAUTH_PROVIDER_ID", "") X_SERVICE_AUTH_JWT_SECRET = os.environ.get("X_SERVICE_AUTH_JWT_SECRET", "") USER_ID_API_KEY_MAP = os.environ.get("USER_ID_API_KEY_MAP", "") logger.debug( "Auth env snapshot: LANGSMITH_API_KEY_PROD=%s LANGSMITH_ENDPOINT=%s " "LANGSMITH_HOST_API_URL=%s GITHUB_OAUTH_PROVIDER_ID=%s", "set" if LANGSMITH_API_KEY else "missing", "set" if LANGSMITH_API_URL else "missing", "set" if LANGSMITH_HOST_API_URL else "missing", "set" if GITHUB_OAUTH_PROVIDER_ID else "missing", ) def is_bot_token_only_mode() -> bool: """Check if we're in bot-token-only mode. This is the case when LANGSMITH_API_KEY_PROD is set (deployed) but neither X_SERVICE_AUTH_JWT_SECRET nor USER_ID_API_KEY_MAP is configured, meaning we can't resolve per-user GitHub OAuth tokens. In this mode the GitHub App installation token is used for all git operations instead. """ return bool(LANGSMITH_API_KEY and not X_SERVICE_AUTH_JWT_SECRET and not USER_ID_API_KEY_MAP) def _retry_instruction(source: str) -> str: if source == "slack": return "Once authenticated, mention me again in this Slack thread to retry." return "Once authenticated, reply to this issue mentioning @openswe to retry." def _source_account_label(source: str) -> str: if source == "slack": return "Slack" return "Linear" def _auth_link_text(source: str, auth_url: str) -> str: if source == "slack": return auth_url return f"[Authenticate with GitHub]({auth_url})" def _work_item_label(source: str) -> str: if source == "slack": return "thread" return "issue" def get_secret_key_for_user( user_id: str, tenant_id: str, expiration_seconds: int = 300 ) -> tuple[str, Literal["service", "api_key"]]: """Create a short-lived service JWT for authenticating as a specific user.""" if not X_SERVICE_AUTH_JWT_SECRET: msg = "X_SERVICE_AUTH_JWT_SECRET is not configured. Cannot generate service keys." raise ValueError(msg) payload = { "sub": "unspecified", "exp": datetime.now(UTC) + timedelta(seconds=expiration_seconds), "user_id": user_id, "tenant_id": tenant_id, } return jwt.encode(payload, X_SERVICE_AUTH_JWT_SECRET, algorithm="HS256"), "service" async def get_ls_user_id_from_email(email: str) -> dict[str, str | None]: """Get the LangSmith user ID and tenant ID from a user's email.""" if not LANGSMITH_API_KEY: logger.warning("LangSmith API key not configured; cannot resolve LS user for %s", email) return {"ls_user_id": None, "tenant_id": None} url = f"{LANGSMITH_API_URL}/api/v1/workspaces/current/members/active" async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client: try: response = await client.get( url, headers={"X-API-Key": LANGSMITH_API_KEY}, params={"emails": [email]}, ) response.raise_for_status() members = response.json() if members and len(members) > 0: member = members[0] return { "ls_user_id": member.get("ls_user_id"), "tenant_id": member.get("tenant_id"), } except Exception as e: logger.exception("Error getting LangSmith user info for email: %s", e) return {"ls_user_id": None, "tenant_id": None} def _extract_expires_at(response_data: dict[str, Any]) -> str | None: """Pull an expiry from a LangSmith auth response in any of its known shapes.""" expires_at = response_data.get("expires_at") or response_data.get("expiresAt") if isinstance(expires_at, str) and expires_at: return expires_at if isinstance(expires_at, int | float): return datetime.fromtimestamp(float(expires_at), tz=UTC).isoformat() expires_in = response_data.get("expires_in") or response_data.get("expiresIn") if isinstance(expires_in, int | float) and expires_in > 0: return (datetime.now(UTC) + timedelta(seconds=int(expires_in))).isoformat() return None async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str, Any]: """Get GitHub OAuth token for a user via LangSmith agent auth.""" if not GITHUB_OAUTH_PROVIDER_ID: logger.error("GitHub auth failed: GITHUB_OAUTH_PROVIDER_ID is not configured") return {"error": "GITHUB_OAUTH_PROVIDER_ID not configured"} try: headers = { "X-Tenant-Id": tenant_id, "X-User-Id": ls_user_id, } secret_key, secret_type = get_secret_key_for_user(ls_user_id, tenant_id) if secret_type == "api_key": headers["X-API-Key"] = secret_key else: headers["X-Service-Key"] = secret_key payload = { "provider": GITHUB_OAUTH_PROVIDER_ID, "scopes": ["repo"], "user_id": ls_user_id, "ls_user_id": ls_user_id, } async with httpx.AsyncClient(timeout=DEFAULT_HTTP_TIMEOUT) as client: response = await client.post( f"{LANGSMITH_HOST_API_URL}/v2/auth/authenticate", json=payload, headers=headers, ) response.raise_for_status() response_data = response.json() token = response_data.get("token") auth_url = response_data.get("url") if token: result: dict[str, Any] = {"token": token} expires_at = _extract_expires_at(response_data) if expires_at: result["expires_at"] = expires_at return result if auth_url: return {"auth_url": auth_url} # Log the full upstream body server-side only; the returned error becomes a # user-facing Slack/Linear comment, so never echo the raw response body. logger.error( "GitHub auth returned an unexpected result (no token/url): %s", response_data ) return {"error": "GitHub auth returned an unexpected result"} except httpx.HTTPStatusError as e: # Log the full upstream body server-side only; the returned error becomes a # user-facing Slack/Linear comment, so never echo the raw response text. logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text) return {"error": f"GitHub auth failed (status {e.response.status_code})"} except Exception as e: # noqa: BLE001 logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e)) return {"error": str(e)} async def resolve_github_token_from_email(email: str) -> dict[str, Any]: """Resolve a GitHub token for a user identified by email. Chains get_ls_user_id_from_email -> get_github_token_for_user. Returns: Dict with one of: - {"token": str} on success - {"auth_url": str} if user needs to authenticate via OAuth - {"error": str} on failure; error="no_ls_user" if email not in LangSmith """ user_info = await get_ls_user_id_from_email(email) ls_user_id = user_info.get("ls_user_id") tenant_id = user_info.get("tenant_id") if not ls_user_id or not tenant_id: logger.warning( "No LangSmith user found for email %s (ls_user_id=%s, tenant_id=%s)", email, ls_user_id, tenant_id, ) return {"error": "no_ls_user", "email": email} auth_result = await get_github_token_for_user(ls_user_id, tenant_id) return auth_result async def leave_failure_comment( source: str, message: str, ) -> None: """Leave an auth failure comment for the appropriate source.""" config = get_config() configurable = config.get("configurable", {}) if source == "linear": linear_issue = configurable.get("linear_issue", {}) issue_id = linear_issue.get("id") if isinstance(linear_issue, dict) else None if issue_id: logger.info( "Posting auth failure comment to Linear issue %s (source=%s)", issue_id, source, ) await comment_on_linear_issue(issue_id, message) return if source == "slack": slack_thread = configurable.get("slack_thread", {}) channel_id = slack_thread.get("channel_id") if isinstance(slack_thread, dict) else None thread_ts = slack_thread.get("thread_ts") if isinstance(slack_thread, dict) else None if channel_id and thread_ts: # The auth-failure ``message`` can carry a per-user GitHub auth URL, # which must not be posted in a shared thread (anyone could complete # it and bind the wrong account). Post a generic, token-free notice and # let the user finish sign-in from their own authenticated dashboard. from ..dashboard.oauth import build_settings_url settings_url = build_settings_url() link = ( f"<{settings_url}|your Open SWE settings>" if settings_url else "your Open SWE settings" ) logger.info( "Posting generic auth-failure notice to Slack channel %s thread %s", channel_id, thread_ts, ) await post_slack_thread_reply( channel_id, thread_ts, "⚠️ I couldn't resolve your GitHub account for this run. Sign in with GitHub and " f"connect your Slack account in {link}, then tag me again.", ) return if source in ("github", "github_push"): logger.warning( "Auth failure for GitHub-triggered run (no token to post comment): %s", message ) return raise ValueError(f"Unknown source: {source}") def _current_repo() -> Any: """Best-effort read of the run's repo (owner/name) for cache binding.""" try: configurable = get_config().get("configurable", {}) except Exception: return None return configurable.get("repo") if isinstance(configurable, dict) else None def _cache_resolved_github_token( thread_id: str, token: str, expires_at: str | None = None ) -> tuple[str, str | None]: cache_github_token_for_thread(thread_id, token, expires_at=expires_at, repo=_current_repo()) return token, expires_at async def resolve_token_from_email( email: str | None, source: str, ) -> tuple[str, str | None]: """Resolve and cache a GitHub token based on user email.""" config = get_config() configurable = config.get("configurable", {}) thread_id = configurable.get("thread_id") if not thread_id: raise ValueError("GitHub auth failed: missing thread_id") if not email: message = ( "❌ **GitHub Auth Error**\n\n" "Failed to authenticate with GitHub: missing_user_email\n\n" "Please try again or contact support." ) await leave_failure_comment(source, message) raise ValueError("GitHub auth failed: missing user_email") user_info = await get_ls_user_id_from_email(email) ls_user_id = user_info.get("ls_user_id") tenant_id = user_info.get("tenant_id") if not ls_user_id or not tenant_id: account_label = _source_account_label(source) message = ( "🔐 **GitHub Authentication Required**\n\n" f"Could not find a LangSmith account for **{email}**.\n\n" "Please ensure this email is invited to the main LangSmith organization. " f"If your {account_label} account uses a different email than your LangSmith account, " "you may need to update one of them to match.\n\n" "Once your email is added to LangSmith, " f"{_retry_instruction(source)}" ) await leave_failure_comment(source, message) raise ValueError(f"No ls_user_id found from email {email}") auth_result = await get_github_token_for_user(ls_user_id, tenant_id) auth_url = auth_result.get("auth_url") if auth_url: work_item_label = _work_item_label(source) auth_link_text = _auth_link_text(source, auth_url) message = ( "🔐 **GitHub Authentication Required**\n\n" f"To allow the Open SWE agent to work on this {work_item_label}, " "please authenticate with GitHub by clicking the link below:\n\n" f"{auth_link_text}\n\n" f"{_retry_instruction(source)}" ) await leave_failure_comment(source, message) raise ValueError("User not authenticated.") token = auth_result.get("token") if not token: error = auth_result.get("error", "unknown") message = ( "❌ **GitHub Auth Error**\n\n" f"Failed to authenticate with GitHub: {error}\n\n" "Please try again or contact support." ) await leave_failure_comment(source, message) raise ValueError(f"No token found: {error}") expires_at = auth_result.get("expires_at") if isinstance(auth_result, dict) else None return _cache_resolved_github_token( thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None ) async def _resolve_dashboard_user_token( thread_id: str, github_login: str ) -> tuple[str, str | None] | None: """Resolve a per-user GitHub token from the dashboard OAuth store.""" login = github_login.strip() if not login: raise ValueError("missing github_login") from ..dashboard.profiles import OAUTH_TOKENS_NAMESPACE, get_valid_access_token from ..dashboard.profiles import _get_value as get_oauth_record token = await get_valid_access_token(login) if not token: return None record = await get_oauth_record(OAUTH_TOKENS_NAMESPACE, login) expires_at = record.get("token_expires_at") if isinstance(record, dict) else None return _cache_resolved_github_token( thread_id, token, expires_at=expires_at if isinstance(expires_at, str) else None ) async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | None]: """Get a GitHub App installation token and cache it for the thread. AUTHZ-003 (accepted): in bot-token-only mode every run shares one GitHub App installation token, so its blast radius is the whole installation rather than a single user. This is a documented, accepted prod posture for this single-tenant deployment, not a defect. """ bot_token, expires_at = await get_github_app_installation_token_with_expiry() if not bot_token: raise RuntimeError( "Bot-token-only mode is active (LANGSMITH_API_KEY_PROD set without " "X_SERVICE_AUTH_JWT_SECRET) but the GitHub App is not configured. " "Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_INSTALLATION_ID." ) logger.info( "Using GitHub App installation token for thread %s (bot-token-only mode)", thread_id ) return _cache_resolved_github_token(thread_id, bot_token, expires_at=expires_at) async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[str, str | None]: """Resolve a GitHub token from the run config based on the source. Routes to the correct auth method depending on the source. Sources that carry a mapped GitHub login (Slack, Linear, dashboard, schedule) resolve a per-user OAuth token from the dashboard store; GitHub runs are login-based; otherwise resolution falls back to email-based auth. In bot-token-only mode (LANGSMITH_API_KEY_PROD set without X_SERVICE_AUTH_JWT_SECRET), the GitHub App installation token is used for all operations instead of per-user OAuth tokens. Raises: RuntimeError: If source is missing or token resolution fails. """ configurable = config["configurable"] source = configurable.get("source") if not source: logger.error("Missing source for thread %s; cannot route auth failure responses", thread_id) raise RuntimeError(f"GitHub auth failed for thread {thread_id}: missing source") github_login = configurable.get("github_login") # DEFAULT: Slack/Linear/dashboard/schedule runs use the GitHub App installation # token, so all git/gh operations + the PR come in as the app # `seahaven-openswe[bot]` (deterministic; matches GitHub-issue runs; eliminates # the self-review 422). OPT-IN: a profile with `author_prs_as_user: true` # restores the per-user OAuth token so the run is attributed to the triggering # user. if ( source in ("slack", "linear", "dashboard", "schedule") and isinstance(github_login, str) and github_login.strip() ): from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user if profile_author_prs_as_user(await load_profile(github_login.strip())): try: user_token = await _resolve_dashboard_user_token(thread_id, github_login) except ValueError as exc: logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc)) raise RuntimeError(str(exc)) from exc if user_token is not None: return user_token # Opt-in set but no valid user token: in bot-token-only mode fall back # to the bot; otherwise block and require auth. if not is_bot_token_only_mode(): raise GitHubUserAuthRequired(source, github_login) return await _resolve_bot_installation_token(thread_id) if is_bot_token_only_mode(): return await _resolve_bot_installation_token(thread_id) try: if source == "github": cached_token, cached_expires_at = await get_github_token_from_thread( thread_id, expected_repo=configurable.get("repo") ) if cached_token: return cached_token, cached_expires_at from ..dashboard.user_mappings import email_for_login email = await email_for_login(github_login) if not email: raise ValueError(f"No email mapping found for GitHub user '{github_login}'") return await resolve_token_from_email(email, source) return await resolve_token_from_email(configurable.get("user_email"), source) except ValueError as exc: logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc)) raise RuntimeError(str(exc)) from exc