Files
Claude 8b77d1bfb6 feat(v4.6.0): one-click OAuth — PKCE + cloud broker, drop secret-paste UX
Replaces the painful "register your own OAuth app, paste client_id +
client_secret" flow with click-Authorize-and-done. Two completion
paths:

PKCE (no broker, no secrets)
- Used for providers that support OAuth 2.1 PKCE: Linear today;
  Atlassian / Microsoft / Google / Twitter / Discord drop in once each
  is registered.
- Local hub generates code_verifier, sends sha256 challenge to the
  provider, exchanges code + verifier on the callback. No secret in
  the binary.

Broker-mediated (autoMate Cloud or self-hosted)
- For providers that still require a confidential client at the token
  endpoint: GitHub, Notion, Slack, 飞书, 钉钉.
- Hosted broker (cloud/oauth_broker/) holds client_secret per
  provider; local hub never sees it. Flow:
    local → broker /start → provider → broker /callback (exchanges
    code for token, stashes by flow_id) → user-browser bounce back to
    local with flow_id → local fetches token via broker /result
    (single-use, deleted on read).
- Default broker URL is https://broker.automate.cloud; override with
  AUTOMATE_OAUTH_BROKER_URL for self-hosted.
- Tokens live in the broker's memory only during the ~30s in-flight
  window; flow_id is the only secret in transit (HTTPS).

Tools tab UI
- Each card has ONE "Connect" button.
- OAuth modal: a single big "Authorize with X" button. Click opens a
  popup at the provider's consent page; on success the callback page
  posts a message to the opener and self-closes; the modal updates to
  ✓ Connected without a refresh.
- API-key path: a single big deep-link button to the EXACT token
  page (with scopes pre-selected for GitHub etc.) + one paste field.
- Removed: client_id + client_secret inputs, "Use OAuth instead
  (advanced)" toggle, the /api/integrations/<id>/oauth-app endpoint.

Standalone broker (cloud/oauth_broker/)
- Tiny FastAPI app, deployable as a Docker image (Dockerfile +
  requirements.txt included) or `pip install -r requirements.txt &
  uvicorn main:app`.
- Reads OAUTH_<PROVIDER>_CLIENT_ID/SECRET from env, plus
  PUBLIC_BASE_URL.
- Exposes /oauth/<p>/start, /oauth/<p>/callback, /oauth/result, /health.
- Per-provider OAuth scopes are hardcoded in main.py:SPECS.

Docs
- docs/oauth-broker.md walks maintainers/self-hosters through the
  deployment + per-provider OAuth app registration.

When the broker is unreachable the OAuth modal degrades gracefully:
clear error message + "Or paste an API key instead" expandable that
deep-links to the provider's PAT page. OAuth and API-key flows produce
identical end-state in the connections table.

https://claude.ai/code/session_019uRjfdjRsVwG9iNbc5gJmN
2026-04-28 07:55:55 +00:00

257 lines
9.2 KiB
Python

"""autoMate OAuth broker.
A tiny FastAPI service that holds the OAuth ``client_secret`` for each
upstream provider and brokers the dance for self-hosted autoMate hubs
that can't ship secrets in their binaries.
Architecture
------------
Local hub Broker (this app) Provider (GitHub, …)
│ │ │
│ GET /oauth/<p>/start │ │
│ ──────────────────────────────▶ │ │
│ ◀── { authorize_url } │ │
│ │ │
│ (user's browser opens authorize_url) │
│ │ ◀── ?code= callback ───── │
│ │ POST /token ────────────▶ │
│ │ ◀── token payload ─────────│
│ │ │
│ ◀── 302 to local_redirect?flow_id=... (broker stashes payload by flow_id) │
│ │
│ GET /oauth/result?flow_id=... │
│ ──────────────────────────────────────────────────────────▶ │
│ ◀── { token: ..., ... } (single-use, deleted on read) │
Deployment
----------
Set provider creds via env vars:
OAUTH_GITHUB_CLIENT_ID=...
OAUTH_GITHUB_CLIENT_SECRET=...
OAUTH_NOTION_CLIENT_ID=...
OAUTH_NOTION_CLIENT_SECRET=...
OAUTH_SLACK_CLIENT_ID=...
OAUTH_SLACK_CLIENT_SECRET=...
OAUTH_FEISHU_CLIENT_ID=...
OAUTH_FEISHU_CLIENT_SECRET=...
OAUTH_DINGTALK_CLIENT_ID=...
OAUTH_DINGTALK_CLIENT_SECRET=...
Then::
pip install fastapi uvicorn httpx
PUBLIC_BASE_URL=https://broker.example.com uvicorn cloud.oauth_broker.main:app --host 0.0.0.0 --port 8080
Each provider needs an OAuth app registered with redirect URI
``{PUBLIC_BASE_URL}/oauth/<provider>/callback``. See
``docs/oauth-broker.md`` for per-provider registration links.
"""
from __future__ import annotations
import os
import time
import urllib.parse
from dataclasses import dataclass
from threading import Lock
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import JSONResponse, RedirectResponse
# ---------------- provider specs ----------------
@dataclass(frozen=True)
class Spec:
id: str
authorize_url: str
token_url: str
default_scopes: tuple[str, ...]
extra_auth_params: dict
# Some providers (Slack, GitHub) accept secret in body; some (Notion)
# require Basic auth. Set "header" to use HTTP Basic for the token call.
auth_method: str = "body" # "body" or "header"
SPECS: dict[str, Spec] = {
"github": Spec("github",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
("repo", "read:user", "read:org"), {}),
"notion": Spec("notion",
"https://api.notion.com/v1/oauth/authorize",
"https://api.notion.com/v1/oauth/token",
(), {"owner": "user"}, auth_method="header"),
"slack": Spec("slack",
"https://slack.com/oauth/v2/authorize",
"https://slack.com/api/oauth.v2.access",
("chat:write", "channels:read", "users:read"), {}),
"feishu": Spec("feishu",
"https://open.feishu.cn/open-apis/authen/v1/index",
"https://open.feishu.cn/open-apis/authen/v2/oauth/token",
(), {}),
"dingtalk": Spec("dingtalk",
"https://login.dingtalk.com/oauth2/auth",
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
("openid",), {"prompt": "consent"}),
}
def _creds(provider: str) -> tuple[str, str]:
cid = os.environ.get(f"OAUTH_{provider.upper()}_CLIENT_ID") or ""
csec = os.environ.get(f"OAUTH_{provider.upper()}_CLIENT_SECRET") or ""
if not cid or not csec:
raise HTTPException(503, f"broker not configured for {provider}: "
f"set OAUTH_{provider.upper()}_CLIENT_ID/SECRET env vars")
return cid, csec
def _public_base_url() -> str:
base = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/")
if not base:
raise HTTPException(500, "PUBLIC_BASE_URL env var is required")
return base
# ---------------- in-memory state ----------------
@dataclass
class Pending:
provider: str
local_redirect: str
created_at: float
state_token: str # the broker's own state, sent to the provider
@dataclass
class Result:
payload: dict
created_at: float
_lock = Lock()
_pending: dict[str, Pending] = {} # flow_id → Pending
_results: dict[str, Result] = {} # flow_id → Result
def _gc() -> None:
now = time.time()
with _lock:
for d in (_pending, _results):
stale = [k for k, v in d.items() if now - v.created_at > 600]
for k in stale:
d.pop(k, None)
# ---------------- app ----------------
app = FastAPI(title="autoMate OAuth broker")
@app.get("/health")
def health():
return {"ok": True, "providers": list(SPECS.keys())}
@app.get("/oauth/{provider}/start")
def start(provider: str, flow_id: str = Query(...), local_redirect: str = Query(...),
scopes: str = Query("")):
"""Give the local hub an authorize URL it can send the user's browser to."""
spec = SPECS.get(provider)
if not spec:
raise HTTPException(404, f"unknown provider {provider}")
cid, _ = _creds(provider)
base = _public_base_url()
redirect_uri = f"{base}/oauth/{provider}/callback"
state_token = f"{provider}:{flow_id}"
_gc()
with _lock:
_pending[flow_id] = Pending(
provider=provider, local_redirect=local_redirect,
created_at=time.time(), state_token=state_token,
)
scope_list = tuple(s for s in scopes.split() if s) or spec.default_scopes
params = {
"client_id": cid,
"redirect_uri": redirect_uri,
"response_type": "code",
"state": state_token,
}
if scope_list:
params["scope"] = " ".join(scope_list)
params.update(spec.extra_auth_params)
return {"authorize_url": f"{spec.authorize_url}?{urllib.parse.urlencode(params)}"}
@app.get("/oauth/{provider}/callback")
def callback(provider: str, request: Request,
code: str = Query(None), state: str = Query(None),
error: str = Query(None)):
"""Provider callback. Exchange code → token, stash by flow_id, bounce back to local."""
spec = SPECS.get(provider)
if not spec:
raise HTTPException(404, f"unknown provider {provider}")
if error:
raise HTTPException(400, f"{provider} returned error: {error}")
if not code or not state or ":" not in state:
raise HTTPException(400, "missing code or malformed state")
state_provider, flow_id = state.split(":", 1)
if state_provider != provider:
raise HTTPException(400, "state/provider mismatch")
with _lock:
pending = _pending.pop(flow_id, None)
if not pending:
raise HTTPException(400, "no pending flow for this state — expired or unknown")
cid, csec = _creds(provider)
base = _public_base_url()
redirect_uri = f"{base}/oauth/{provider}/callback"
body = {
"code": code,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
}
headers = {"Accept": "application/json"}
if spec.auth_method == "body":
body["client_id"] = cid
body["client_secret"] = csec
auth = None
else:
auth = (cid, csec)
try:
with httpx.Client(timeout=30.0) as client:
r = client.post(spec.token_url, data=body, auth=auth, headers=headers)
r.raise_for_status()
except httpx.HTTPError as e: # noqa: BLE001
raise HTTPException(502, f"{provider} token exchange failed: {e}")
try:
payload = r.json()
except ValueError:
# GitHub may return form-encoded
payload = dict(urllib.parse.parse_qsl(r.text))
with _lock:
_results[flow_id] = Result(payload=payload, created_at=time.time())
# Bounce the user's browser back to the local hub. Include flow_id —
# the local hub will fetch the actual token via /oauth/result.
sep = "&" if "?" in pending.local_redirect else "?"
return RedirectResponse(f"{pending.local_redirect}{sep}flow_id={urllib.parse.quote(flow_id)}", status_code=302)
@app.get("/oauth/result")
def result(flow_id: str = Query(...)):
"""Local hub polls this to retrieve the token. Single-use; deleted on read."""
_gc()
with _lock:
rec = _results.pop(flow_id, None)
if not rec:
raise HTTPException(404, "flow_id not found (expired, already consumed, or never completed)")
return JSONResponse(rec.payload)