Files
codex-app-server/cloud/handler_oauth.py
T
host 935b530f84 split: handler mixins for oauth/identity/project/automation/fullauto (94 methods) on integration base 02835f6
- CloudControlPlaneHandler mixes in OAuthMixin, IdentityMemoryMixin,
  ProjectMixin, AutomationMixin, FullAutoMixin; shared helpers move to
  cloud/handler_support.py with server.py re-exports (old import paths
  keep working, incl. _validate_env_branch for tests).
- Method bodies moved verbatim; routers, base plumbing, task/lifecycle,
  webhooks stay in server.py. No >100-line method body split.
- test_handler_split_conformance.py (6 tests) pins resolution, layer
  bans and method accounting (238 total, 144 + 94, no duplicates).
- test_p1_security_server.py: monkeypatch targets follow the defining
  module (handler_project MAX_PROJECTS_PER_USER, handler_identity
  relay helpers); assertions unchanged.

(cherry picked from commit 92165a2b097126470e4827327d96d592a253adf1)
2026-11-03 02:49:26 +00:00

635 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Handler mixin:OAuthMixin。
由 CloudControlPlaneHandler 拆出,方法体逐字搬移;
self.* 调用(含其它 mixin 的方法与留守的 _send_json / _db 等)
在混入后的同一类上解析,行为不变。本模块不 import cloud.server。
"""
from cloud.github_app import (
DEFAULT_APP_NAME,
DEFAULT_APP_URL,
AppError,
SetupStateStore,
build_app_manifest,
exchange_manifest_code,
list_installations,
public_app_status,
save_app_config,
)
from cloud.oauth import (
BindError,
OAuthError,
OAuthStateStore,
bind_external_identity,
build_authorization_url,
default_userinfo_client,
find_bound_user,
handle_oauth_callback,
list_bindings,
map_external_identity_to_local_user_with_key,
start_oauth_login,
unbind_external_identity,
)
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
from cloud.sub2api import gateway_origin as _gateway_origin
import html
import json
import os
from urllib.parse import parse_qs, quote, urlparse
from cloud.handler_support import (
_apply_default_quota as _apply_default_quota,
_auth_store as _auth_store,
_default_quota_usd_micros as _default_quota_usd_micros,
_global_admin_ids as _global_admin_ids,
)
class OAuthMixin:
"""OAuthMixin 领域方法(见模块 docstring)。"""
# -- OAuth(pre-auth,靠 state + secret,不走 X-API-Key)-----------
def _oauth_provider(self, path):
parts = [p for p in path.split("/") if p]
if len(parts) != 3 or parts[0] != "oauth":
return None
return parts[1]
def _oauth_login(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_json(404, {"error": "not found"})
return
query = parse_qs(urlparse(self.path).query)
client_id = (query.get("client_id") or [None])[0]
redirect_uri = (query.get("redirect_uri") or [None])[0]
scope = (query.get("scope") or [None])[0]
if not client_id or not redirect_uri:
self._send_json(400, {"error": "client_id and redirect_uri required"})
return
store = OAuthStateStore(self.server.db_path)
try:
state, url = start_oauth_login(
store, provider, client_id, redirect_uri, scope=scope)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
# 只返回 URL,不直接跳转到任何登录页;跳转由调用方决定。
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_clients(self, provider):
"""回调用的 http/userinfo 客户端:测试注入优先,生产走真实路径。
server 属性 oauth_http_client / oauth_userinfo_client 由
create_server 置为 None(生产)或由测试注入 fake;http_client
已注入(fake)而 userinfo 未注入时不走真实 userinfo(fake
token 自带身份字段时不需要它,走真实端点反而会用假 token
撞真实 GitHub)。生产(两者都未注入)时 userinfo 走真实端点。
"""
http_client = getattr(self.server, "oauth_http_client", None)
userinfo_client = getattr(self.server, "oauth_userinfo_client", None)
if userinfo_client is None and http_client is None:
userinfo_client = default_userinfo_client(provider)
return http_client, userinfo_client
def _oauth_finish(self, provider, code, state, client_id, redirect_uri):
"""回调共用后半段:验 state -> 换 token -> 取身份 -> 分叉。
成功返回 6 元组
``(local_id, created, api_key_plain, rotated, identity, is_bind)``;
失败抛 OAuthError(调用方按 JSON/HTML 分别呈现,绝不把
access_token、client_secret、旧 API key 写进任何响应),
绑定冲突抛 BindError(调用方按 409 呈现)。
client_id / redirect_uri 为 None 时由 handle_oauth_callback
从 state 记录取回(真实浏览器回调只带 code + state)。
分叉规则(顺序即优先级):
1. state 带 bind_user_id -> 绑定分支:把身份绑到该本站
账号,返回 is_bind=True、api_key_plain=None(绑定成功页
绝不显示任何 key);冲突抛 BindError;
2. 身份已绑定到某本站账号 -> 登录落到被绑账号,并按
OAuth 登录规矩签发附加 key(created=False、rotated
恒为 False),与 oauth 账号登录同一语义,不发明新语义;
3. 否则走原来的确定性 oauth 账号映射(新建/复用 + 签发
附加 key)。
"""
store = OAuthStateStore(self.server.db_path)
try:
http_client, userinfo_client = self._oauth_clients(provider)
identity = handle_oauth_callback(
store, provider, code, state, client_id, redirect_uri,
http_client=http_client, userinfo_client=userinfo_client)
finally:
try:
store.conn.close()
except Exception:
pass
with _auth_store(self.server.db_path) as auth:
bind_user_id = identity.get("bind_user_id")
if bind_user_id:
binding = bind_external_identity(
auth, provider, identity, bind_user_id)
auth.audit(bind_user_id, "", "bind_external",
"%s:%s" % (provider, identity.get("external_id")))
if _apply_default_quota(auth.conn, bind_user_id):
auth.audit(bind_user_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
identity["already_bound"] = binding["already"]
return (bind_user_id, False, None, False, identity, True)
bound_user_id = find_bound_user(auth, provider, identity)
if bound_user_id is not None:
_key_id, api_key_plain = auth.create_api_key(
bound_user_id, "oauth:%s" % provider)
auth.audit(bound_user_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
auth.audit(bound_user_id, "", "api_key_issued", provider)
if _apply_default_quota(auth.conn, bound_user_id):
auth.audit(bound_user_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
return (bound_user_id, False, api_key_plain, False, identity,
False)
local_id, created, api_key_plain, rotated = \
map_external_identity_to_local_user_with_key(
auth, provider, identity)
auth.audit(local_id, "", "oauth_login",
"%s:%s" % (provider, identity.get("external_id")))
if _apply_default_quota(auth.conn, local_id):
auth.audit(local_id, "", "quota_default",
"quota_usd_micros=%d"
% _default_quota_usd_micros())
if created and _gateway_origin() is not None:
# sub2api 开通(best-effort):建号不因网关开通失败
# 而失败,任务时 _ensure_gateway 会懒开通兜底。
try:
_ensure_sub2api_key(auth.conn, local_id)
except Exception as exc:
auth.audit(local_id, "", "sub2api_provision_failed",
str(exc)[:200])
if not created:
auth.audit(local_id, "", "api_key_issued", provider)
return local_id, created, api_key_plain, rotated, identity, False
def _oauth_callback(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_json(404, {"error": "not found"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
code = body.get("code")
state = body.get("state")
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
if not code or not state:
self._send_json(400, {"error": "code and state required"})
return
try:
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_json(409, {"error": str(e), "reason": e.reason})
return
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
if is_bind:
# 绑定成功:绝不返回任何 key(绑定页的 completer 未必是
# 发起人,key 一旦上页就可能落到别人手里);发起人用
# 自己的 key 调 GET /oauth/{provider}/bindings 核对。
self._send_json(200, {"linked": True, "provider": provider,
"login": identity.get("login"),
"external_id": identity.get("external_id"),
"user_id": local_id,
"already_bound":
identity.get("already_bound", False)})
return
# api_key 明文只在这里出现一次:调用方须立即保存,
# 之后任何接口都不再返回它;登录签发的是附加 key,旧 key
# 仍可继续使用(多 key;不用的旧 key 可在 keys 管理里撤销)。
self._send_json(200, {"user_id": local_id, "created": created,
"provider": provider,
"login": identity.get("login"),
"email": identity.get("email"),
"api_key": api_key_plain,
"api_key_rotated": rotated})
def _oauth_error_page(self, reason, message):
"""GET 回调的 400 错误页:只含 reason 与提示,不含任何密钥、
token 或旧 API key(message 来自 OAuthError,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login failed</title></head><body>"
"<h1>OAuth login failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"<p>Please restart login from the login page.</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _oauth_success_page(self, provider, local_id, created, login,
api_key_plain, rotated):
"""GET 回调的成功页:只显示 login、user_id、created 与新 API key
一次;绝不显示 access_token、client_secret、旧 API key。
rotated 只为向后兼容保留(登录不再轮换,恒为 False):
新 key 是附加 key,旧 key 仍可继续使用,页面不再写“旧 key
已失效”,而是说明多 key 与撤销入口。
"""
rotation_note = (
"<p>Your previous API key has been rotated and no longer works.</p>"
if rotated else
"<p>Previous keys still work (multi-key). Revoke unused keys "
"via the API keys management endpoints.</p>"
if not created else "")
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth login succeeded</title></head><body>"
"<h1>OAuth login succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>user_id: %s</p>"
"<p>new user: %s</p>"
"%s"
"<p>Your API key (shown only once, save it now):</p>"
"<p><code>%s</code></p>"
"<p>Please save it immediately. It will not be shown again "
"after you leave or refresh this page."
"</p>"
"<p>Use it as the X-API-Key header for API calls.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login)),
html.escape(str(local_id)),
"yes" if created else "no",
rotation_note,
html.escape(str(api_key_plain))))
def _oauth_bind_success_page(self, provider, login):
"""绑定成功页:只显示 provider + 外部 login + 成功提示。
绝不显示任何 key,也不显示本站 user_id:回调页的 completer
未必是绑定发起人,key 与内部 id 一旦上页就可能落到别人
手里;发起人用自己的 key 调 GET /oauth/{provider}/bindings
核对绑定。
"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>OAuth binding succeeded</title></head><body>"
"<h1>OAuth binding succeeded</h1>"
"<p>provider: %s</p>"
"<p>login: %s</p>"
"<p>Your external identity is now bound to your account.</p>"
"<p>You can verify it from your bindings list.</p>"
"</body></html>"
% (html.escape(str(provider)),
html.escape(str(login))))
def _oauth_callback_get(self, path):
provider = self._oauth_provider(path)
if provider is None:
self._send_html(404, self._oauth_error_page(
"not_found", "unknown oauth path"))
return
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
client_id = (query.get("client_id") or [None])[0]
redirect_uri = (query.get("redirect_uri") or [None])[0]
if not code or not state:
self._send_html(400, self._oauth_error_page(
"missing_params", "code and state required"))
return
try:
local_id, created, api_key_plain, rotated, identity, is_bind = \
self._oauth_finish(provider, code, state, client_id,
redirect_uri)
except BindError as e:
self._send_html(409, self._oauth_error_page(
e.reason or "bind_conflict", str(e)))
return
except OAuthError as e:
self._send_html(400, self._oauth_error_page(
e.reason or "oauth_failed", str(e)))
return
if is_bind:
self._send_html(200, self._oauth_bind_success_page(
provider, identity.get("login")))
return
self._send_html(200, self._oauth_success_page(
provider, local_id, created, identity.get("login"),
api_key_plain, rotated))
# -- OAuth 绑定(已有账号绑外部身份)------------------------------
def _oauth_bind_route(self, path):
"""绑定相关路径解析:返回 (provider, action) 或 None。
POST /oauth/{provider}/bind/initiate -> (provider, "bind_initiate")
GET|DELETE /oauth/{provider}/bindings -> (provider, "bindings")
其它 /oauth/ 路径返回 None,走原来的登录/回调分发。
"""
parts = [p for p in path.split("/") if p]
if len(parts) == 4 and parts[0] == "oauth" \
and parts[2] == "bind" and parts[3] == "initiate":
return parts[1], "bind_initiate"
if len(parts) == 3 and parts[0] == "oauth" \
and parts[2] == "bindings":
return parts[1], "bindings"
return None
def _oauth_bind_initiate(self, provider):
"""绑定起点:须持本站 key(与登录不同,绑定必须知道绑到谁)。
body 须含 client_id + redirect_uri(与登录同款);state 里
带 bind_user_id,回调时靠它走绑定分支。只返回授权 URL,
不直接跳转;跳转由调用方决定。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
body, err = self._read_json()
if err:
self._send_json(400, {"error": err})
return
client_id = body.get("client_id")
redirect_uri = body.get("redirect_uri")
scope = body.get("scope")
if not client_id or not redirect_uri:
self._send_json(400, {"error": "client_id and redirect_uri required"})
return
store = OAuthStateStore(self.server.db_path)
try:
state = store.create_state(
provider, redirect_uri, client_id=client_id,
bind_user_id=user_id)
url = build_authorization_url(
provider, client_id, redirect_uri, scope=scope, state=state)
except OAuthError as e:
self._send_json(400, {"error": str(e), "reason": e.reason})
return
except ValueError as e:
self._send_json(400, {"error": str(e)})
return
finally:
try:
store.conn.close()
except Exception:
pass
self._send_json(200, {"provider": provider, "state": state,
"authorization_url": url})
def _oauth_bindings_get(self, provider):
"""列出调用方自己的绑定(只看得到自己的,别人的看不到)。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
bindings = list_bindings(auth, user_id)
self._send_json(200, {"provider": provider, "bindings": bindings})
def _oauth_bindings_delete(self, provider):
"""解绑调用方自己在该 provider 上的绑定:本来就没有返回 404。
解绑后该身份登录恢复原来的 oauth 账号映射(旧 oauth 账号
若存在,key 仍可用,不会被解绑删掉)。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
with _auth_store(self.server.db_path) as auth:
ok = unbind_external_identity(auth, provider, user_id)
if ok:
auth.audit(user_id, "", "unbind_external", provider)
if not ok:
self._send_json(404, {"error": "no binding for provider"})
return
self._send_json(200, {"unbound": True, "provider": provider})
# -- GitHub App 一键安装 ------------------------------------------
def _github_app_public_base(self):
base = os.environ.get("GITHUB_APP_PUBLIC_BASE") or ""
return base.rstrip("/") or None
def _github_app_error_page(self, reason, message):
"""App 流程错误页:只含 reason 与提示,不含任何 secret、
pem、code、state(message 来自流程错误,本身不含密钥,
仍做 HTML 转义)。"""
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App setup failed</title></head><body>"
"<h1>GitHub App setup failed</h1>"
"<p>reason: %s</p><p>%s</p>"
"</body></html>"
% (html.escape(str(reason or "error")),
html.escape(str(message))))
def _github_app_setup_state(self):
"""签发 setup state:只限全局管理员(见 _is_global_admin)。
P0 安全批前任何持 key 用户都能签发并回调覆盖全局
App 配置;现在非管理员直接 403 并记审计。要求环境
变量 GITHUB_APP_PUBLIC_BASE(公网域名,如
https://api.diante.me):manifest 里的 hook / 回调地址
都由它拼出。返回 {state, setup_url},setup_url 是用户
在浏览器里打开的表单页;state 一次性、1 小时过期,
且绑定签发人。
"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
if not self._is_global_admin(user_id):
self._audit_admin(user_id, "github_app_setup_denied",
"setup state")
self._send_json(403, {"error": "forbidden"})
return
base = self._github_app_public_base()
if not base:
self._send_json(400, {
"error": "GITHUB_APP_PUBLIC_BASE not configured"})
return
body, err = self._read_json()
if err:
body = {}
org = (body or {}).get("org")
store = SetupStateStore(self.server.db_path)
try:
state = store.create_state(issued_by=user_id)
finally:
try:
store.conn.close()
except Exception:
pass
setup_url = base + "/github-app/setup?state=" + state
if org:
setup_url += "&org=" + quote(str(org))
self._send_json(200, {"state": state, "setup_url": setup_url})
def _github_app_setup_page(self):
"""建 App 表单页:公开页,但 state 必须是操作方签发的。
无 state 或 state 未签发时不渲染表单(state 是回调的
唯一凭证,公开页自己现发 state 等于谁都能覆盖 App 配置)。
表单按 GitHub manifest 文档 POST 到 github.com 的建 App
页(个人账号 /settings/apps/new,组织账号
/organizations/{org}/settings/apps/new),manifest JSON
由 JS 填进隐藏字段;用户在 GitHub 页确认 Create 后跳回
本站 /github-app/callback。
"""
query = parse_qs(urlparse(self.path).query)
state = (query.get("state") or [None])[0]
org = (query.get("org") or [None])[0]
if not state:
self._send_html(400, self._github_app_error_page(
"missing_state",
"setup state required: ask the operator to issue one"))
return
base = self._github_app_public_base()
if not base:
self._send_html(400, self._github_app_error_page(
"not_configured", "GITHUB_APP_PUBLIC_BASE not configured"))
return
manifest = build_app_manifest(
base,
app_name=os.environ.get("GITHUB_APP_NAME") or DEFAULT_APP_NAME,
app_url=os.environ.get("GITHUB_APP_URL") or DEFAULT_APP_URL,
public=os.environ.get("GITHUB_APP_PUBLIC") == "1")
manifest_json = json.dumps(manifest).replace("<", "\\u003c")
if org:
action = ("https://github.com/organizations/%s"
"/settings/apps/new?state=%s"
% (quote(str(org)),
quote(state)))
else:
action = ("https://github.com/settings/apps/new?state=%s"
% quote(state))
perms = ", ".join("%s: %s" % kv
for kv in manifest["default_permissions"].items())
events = ", ".join(manifest["default_events"])
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>Create GitHub App</title></head><body>"
"<h1>Create GitHub App</h1>"
"<p>App name: %s</p>"
"<p>Webhook URL: %s</p>"
"<p>Permissions: %s</p>"
"<p>Events: %s</p>"
"<p>Click Submit to create the app on GitHub. GitHub will "
"redirect back here to finish setup.</p>"
"<form action=\"%s\" method=\"post\">"
"<input type=\"hidden\" name=\"manifest\" id=\"manifest\">"
"<input type=\"submit\" value=\"Create GitHub App\">"
"</form>"
"<script>document.getElementById(\"manifest\").value = "
"JSON.stringify(%s);</script>"
"</body></html>"
% (html.escape(manifest["name"]),
html.escape(manifest["hook_attributes"]["url"]),
html.escape(perms), html.escape(events),
html.escape(action), manifest_json))
self._send_html(200, page)
def _github_app_callback(self):
"""建 App 回调:验 setup state -> 换 code -> 存配置。
state 一次性、1 小时过期;换 code 失败或响应缺字段时
报错误页,不存任何东西。成功页只显示 App 身份(slug、
id、主页)与安装链接,绝不显示 webhook_secret 与 pem
(两者只在库里,server 进程内用)。
P0 安全批后:回调要求签发人仍在 CODEX_GLOBAL_ADMINS
里,否则 state 不消耗、直接报 invalid_state;覆盖全局
配置时记审计。诚实边界:回调是浏览器无认证重定向,
这里核对的是“签发人仍是管理员”,不是“回调者就是
签发人”(见 SetupStateStore.consume_state 文档)。
"""
query = parse_qs(urlparse(self.path).query)
code = (query.get("code") or [None])[0]
state = (query.get("state") or [None])[0]
if not code or not state:
self._send_html(400, self._github_app_error_page(
"missing_params", "code and state required"))
return
store = SetupStateStore(self.server.db_path)
try:
issuer = store.state_issuer(state)
ok = store.consume_state(
state, allowed_issuers=_global_admin_ids())
finally:
try:
store.conn.close()
except Exception:
pass
if not ok:
self._audit_admin(
issuer or "unknown", "github_app_callback_denied",
"state issuer not a global admin")
self._send_html(400, self._github_app_error_page(
"invalid_state", "setup state invalid or expired"))
return
http_client = getattr(self.server, "github_app_exchange_client",
None)
try:
config = exchange_manifest_code(code, http_client=http_client)
except AppError as e:
self._send_html(400, self._github_app_error_page(
e.reason or "exchange_failed", str(e)))
return
conn = self._db()
try:
save_app_config(conn, config)
finally:
conn.close()
self._audit_admin(
issuer or "unknown", "github_app_config_replaced",
"slug=%s app_id=%s" % (config.get("slug"),
config.get("app_id")))
slug = config.get("slug") or ""
page = (
"<!doctype html><html><head><meta charset='utf-8'>"
"<title>GitHub App created</title></head><body>"
"<h1>GitHub App created</h1>"
"<p>App: %s</p>"
"<p>App ID: %s</p>"
"<p>Install it on your repositories:</p>"
"<p><a href=\"https://github.com/apps/%s/installations/new\">"
"Install this GitHub App</a></p>"
"<p>After installing, webhooks from the app are verified "
"automatically. API actions (push, PR) still use the "
"existing PAT until the App-token phase; you may remove "
"the manual webhook once the app is installed.</p>"
"</body></html>"
% (html.escape(slug),
html.escape(str(config.get("app_id"))),
html.escape(slug)))
self._send_html(200, page)
def _github_app_status(self):
"""App 配置与安装状态:须持本站 key;绝不返回 secret/pem。"""
user_id = self._authenticate()
if user_id is None:
self._send_json(401, {"error": "unauthorized"})
return
conn = self._db()
try:
status = public_app_status(conn)
status["installations"] = list_installations(conn)
finally:
conn.close()
self._send_json(200, status)