open-swe/agent/dashboard/oauth.py
Johannes du Plessis 88856a04fa
feat: open-swe dashboard for per-user profile config (#1302)
* feat: dashboard backend — GitHub OAuth, profile CRUD, admin endpoints

Adds agent/dashboard/ FastAPI router mounted at /dashboard/api covering:
- GitHub App OAuth login → JWT cookie session (cross-domain ready)
- profile CRUD against LangGraph Store with model+effort validation
- admin gate via CONFIGURED_ADMINS
- /repos via /user/installations using the user's encrypted OAuth token

CORS allowlist on webapp.py is opt-in via DASHBOARD_ALLOWED_ORIGINS so the
Vercel-hosted frontend can call the LangSmith deployment with credentials.

* feat: apply dashboard profile model/effort overrides in get_agent

Look up the triggering user's GitHub login from config (direct field or
GITHUB_USER_EMAIL_MAP reverse lookup), read their profile from the Store,
and apply default_model + reasoning_effort to make_model when both are
valid. Effort 'max' is captured on the profile but not yet wired through —
the OpenAI Reasoning Literal doesn't accept it.

* feat: ui/ TanStack Start dashboard for profile config

Scaffolded with the shadcn b7CScJIjA preset (TanStack Start template,
base-ui primitives, Tailwind v4). Three routes:

- /login   — Sign in with GitHub (links to /dashboard/api/auth/login)
- /profile — Edit default model, reasoning effort, default repo
- /admin   — Admin-only: list users and edit other profiles

API client (src/lib/api.ts) uses credentials: include so the osw_session
cookie set by the OAuth callback rides cross-origin. VITE_DASHBOARD_API_BASE_URL
points at the LangSmith deployment.

Effort options re-render when the model changes; 'max' on Opus 4.7 is
captured on the profile but ignored downstream until anthropic reasoning
is wired through make_model.

* feat: searchable Combobox for default repo picker

Replaces the Select with a base-ui Combobox so users can filter by typing,
the popup is wider than the trigger so full owner/repo names are readable,
and the list caps at max-h-80 to stay on screen.

* fix: address review comments + wire default_repo and Anthropic thinking

Security/correctness fixes from PR review:

* Open redirect: validate `redirect_to` in `/auth/login` against
  `DASHBOARD_BASE_URL` + `DASHBOARD_ALLOWED_ORIGINS` before signing it
  into the state JWT. Anything off-allowlist falls back to the dashboard
  base URL. (PR #1302 r3250054386)

* Login CSRF: bind the OAuth `state` to the requesting browser. At
  `/auth/login` we generate a fresh nonce, set it as a short-lived
  HttpOnly SameSite=Lax cookie scoped to `/dashboard/api/auth`, and
  embed `hash_state_nonce(nonce)` in the state JWT. At `/auth/callback`
  we require the cookie nonce to hash-match the state JWT's nonce_hash
  (constant-time compare). (PR #1302 r3250054395)

* RMW race in profile vs token writes: split storage into two
  namespaces — `["profiles"]` for user-editable settings and
  `["oauth_tokens"]` for the encrypted GitHub token. Each upsert now
  only writes its own namespace so an in-flight profile save can no
  longer clobber a fresh token from a concurrent re-login (and vice
  versa). (PR #1302 r3250054393)

* /repos pagination: follow `Link: rel="next"` for both
  `/user/installations` and per-installation `/repositories` with
  per_page=100, capped at 1000 items. (PR #1302 r3250054401)

Feature wires:

* default_repo: applied as a fallback in `get_slack_repo_config` (after
  explicit-repo / thread metadata, before the env defaults) and in the
  Linear webhook (after comment-body extraction, before team mapping).
  Both paths resolve the triggering user's GitHub login via
  GITHUB_USER_EMAIL_MAP and read the profile's default_repo.

* Anthropic "thinking" effort: `make_model` now accepts a `thinking`
  kwarg; `get_agent` maps profile effort {low,medium,high,xhigh,max}
  to budget_tokens {1k,4k,12k,32k,60k} when the chosen model is
  anthropic. OpenAI path still ignores "max" since the Literal doesn't
  accept it.
2026-05-15 11:23:53 -07:00

182 lines
5.8 KiB
Python

"""GitHub App OAuth code-exchange and signed-JWT session cookie."""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from typing import Any
from urllib.parse import urlparse
import httpx
import jwt
from fastapi import HTTPException, Request
logger = logging.getLogger(__name__)
COOKIE_NAME = "osw_session"
STATE_COOKIE_NAME = "osw_oauth_state"
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
STATE_TTL_SECONDS = 600
JWT_ALG = "HS256"
GITHUB_APP_CLIENT_ID = os.environ.get("GITHUB_APP_CLIENT_ID", "")
GITHUB_APP_CLIENT_SECRET = os.environ.get("GITHUB_APP_CLIENT_SECRET", "")
def _secret() -> str:
s = os.environ.get("DASHBOARD_JWT_SECRET", "")
if not s:
raise HTTPException(500, "DASHBOARD_JWT_SECRET not configured")
return s
def _allowed_redirect_origins() -> set[str]:
"""Origins permitted for the post-login redirect.
Built from DASHBOARD_BASE_URL plus any DASHBOARD_ALLOWED_ORIGINS entries
so the dashboard itself and its preview deploys can all be redirect
targets — but nothing else.
"""
origins: set[str] = set()
base = os.environ.get("DASHBOARD_BASE_URL", "").strip()
if base:
origins.add(_origin_of(base))
for entry in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(","):
entry = entry.strip()
if entry:
origins.add(_origin_of(entry))
origins.discard("")
return origins
def _origin_of(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}"
def sanitize_redirect_to(redirect_to: str | None) -> str:
"""Return a safe post-login redirect URL.
Falls back to DASHBOARD_BASE_URL when the supplied URL's origin isn't
explicitly allowed. This blocks the open-redirect / phishing primitive
where an attacker drops their own URL into `?redirect_to=`.
"""
fallback = os.environ.get("DASHBOARD_BASE_URL", "").strip()
if not redirect_to:
return fallback
candidate_origin = _origin_of(redirect_to)
if not candidate_origin:
return fallback
if candidate_origin in _allowed_redirect_origins():
return redirect_to
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
return fallback
def issue_session(*, login: str, email: str | None, avatar_url: str | None) -> str:
now = int(time.time())
payload = {
"sub": login,
"email": email,
"avatar_url": avatar_url,
"iat": now,
"exp": now + SESSION_TTL_SECONDS,
}
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
def decode_session(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, _secret(), algorithms=[JWT_ALG])
except jwt.PyJWTError as e:
raise HTTPException(401, f"invalid session: {e}") from e
def new_state_nonce() -> str:
"""A fresh random nonce used to bind state JWT ↔ browser cookie."""
return secrets.token_urlsafe(32)
def hash_state_nonce(nonce: str) -> str:
"""HMAC the nonce so the value stored on the wire isn't reversible.
We compare ``hash_state_nonce(cookie_nonce) == state.nonce_hash`` at
callback time. Using HMAC over a constant-time digest also gives us
timing-attack resistance via :func:`hmac.compare_digest` at the call
site.
"""
return hmac.new(_secret().encode(), nonce.encode(), hashlib.sha256).hexdigest()
def issue_state(*, redirect_to: str, nonce_hash: str) -> str:
now = int(time.time())
payload = {
"nonce_hash": nonce_hash,
"redirect_to": redirect_to,
"iat": now,
"exp": now + STATE_TTL_SECONDS,
}
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
def decode_state(state: str) -> dict[str, Any]:
try:
return jwt.decode(state, _secret(), algorithms=[JWT_ALG])
except jwt.PyJWTError as e:
raise HTTPException(400, f"invalid state: {e}") from e
def require_session(request: Request) -> dict[str, Any]:
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(401, "not authenticated")
return decode_session(token)
async def exchange_code(code: str) -> str:
"""Exchange an OAuth authorization code for a user-to-server access token."""
if not GITHUB_APP_CLIENT_ID or not GITHUB_APP_CLIENT_SECRET:
raise HTTPException(500, "GitHub App OAuth not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://github.com/login/oauth/access_token",
headers={"Accept": "application/json"},
data={
"client_id": GITHUB_APP_CLIENT_ID,
"client_secret": GITHUB_APP_CLIENT_SECRET,
"code": code,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(400, f"oauth exchange failed: {data}")
return token
async def fetch_github_user(access_token: str) -> tuple[dict[str, Any], str | None]:
"""Return ``(user, primary_email)`` for the authenticated user."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient() as client:
u = await client.get("https://api.github.com/user", headers=headers)
u.raise_for_status()
user = u.json()
email = user.get("email")
if not email:
e = await client.get("https://api.github.com/user/emails", headers=headers)
if e.status_code == 200:
primary = next((x for x in e.json() if x.get("primary")), None)
if primary:
email = primary.get("email")
return user, email