"""OAuth 接线(GitHub / Google)。 真实浏览器登录已接通:server 层提供 ``GET /oauth/{provider}/callback`` (provider 重定向带回 code + state),回调验 state、换 token、 调真实 userinfo 取身份、映射到本站用户并签发本站 API key。 单测一律用注入的 fake http 客户端,不向真实 provider 发请求。 诚实边界(仍未做的部分,见 cloud/README.md 的 OAuth 一节): - 本模块不硬编码任何真实 client_id / client_secret:client_id 由 登录请求方传入并随 state 一起入库(回调时从 state 记录取回, 这样浏览器重定向只带 code + state 也能完成换 token), client_secret 只从环境变量读 (GitHub:``GITHUB_OAUTH_CLIENT_SECRET``, Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不 硬编码、绝不出现在授权 URL 或成功页里。 - 本模块不存储 access_token:回调成功后只返回外部身份信息, access_token 在提取身份后即丢弃。如后续需要用 token 调 provider API,需另设计加密存储,本模块不提供。 - 本模块不做已有本站账号的绑定:映射仍是确定性用户名 ``oauth::`` 新建/复用,绑定流程需另加。 - 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1, 真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。 流程(标准 OAuth2 授权码流程的接线点): state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期) -> 授权 URL(build_authorization_url / start_oauth_login) -> 用户在 provider 处授权,provider 回调本站并带回 code + state -> 回调(handle_oauth_callback):验 state -> 换 token -> 取外部身份 -> 返回身份字典(server 层再映射 + 记审计) 零第三方依赖,只用 Python 标准库。 """ import json import os import secrets import sqlite3 from cloud.db import connect as _db_connect import time import urllib.parse import urllib.request from urllib.parse import parse_qs # state 默认有效期:10 分钟。 STATE_TTL_SECONDS = 600 # 各 provider 的公开端点与默认 scope、client_secret 环境变量名。 # 这些是 OAuth 协议的公开端点地址,不是本站的任何凭证;真实应用 # 的 client_id / client_secret 仍需另行注册并经环境变量供给。 PROVIDERS = { "github": { "authorize_url": "https://github.com/login/oauth/authorize", "token_url": "https://github.com/login/oauth/access_token", "userinfo_url": "https://api.github.com/user", "default_scope": "read:user", "client_secret_env": "GITHUB_OAUTH_CLIENT_SECRET", }, "google": { "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", "token_url": "https://oauth2.googleapis.com/token", "userinfo_url": "https://openidconnect.googleapis.com/v1/userinfo", "default_scope": "openid email profile", "client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET", }, } class OAuthError(Exception): """OAuth 流程失败:state 无效/过期、换 token 失败、身份取不到等。 ``reason`` 供调用方区分(例如记日志或返回不同的错误提示), 取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` / ``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed`` / ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。 """ def __init__(self, message, reason=None): super().__init__(message) self.reason = reason def _provider_config(provider): """取 provider 配置,未知 provider 抛 OAuthError(不猜端点)。""" config = PROVIDERS.get(provider) if config is None: raise OAuthError( "unknown provider: %r (supported: %s)" % (provider, ", ".join(sorted(PROVIDERS))), reason="unknown_provider", ) return config class OAuthStateStore: """基于 SQLite 的 OAuth state 存储。 表 oauth_states:state(主键,随机串)、provider、redirect_uri、 created_at(epoch 秒)。与 AuthStore 的 users / project_members / audit_log 表名不冲突,可共用同一个 db 文件。 """ def __init__(self, db_path): self.db_path = str(db_path) self.conn = _db_connect(self.db_path) self.conn.execute( "CREATE TABLE IF NOT EXISTS oauth_states(" "state TEXT PRIMARY KEY, " "provider TEXT, " "redirect_uri TEXT, " "client_id TEXT, " "created_at REAL)" ) # 老库迁移:脚手架版的表没有 client_id 列,缺了就补上。 # client_id 随 state 入库,是真实浏览器流程的关键:provider # 重定向只带回 code + state,回调靠 state 记录取回 client_id # 才能换 token;补列是 no-op(已有的库再跑一次不变)。 cols = {row[1] for row in self.conn.execute( "PRAGMA table_info(oauth_states)").fetchall()} if "client_id" not in cols: self.conn.execute( "ALTER TABLE oauth_states ADD COLUMN client_id TEXT") self.conn.commit() def create_state(self, provider, redirect_uri, ttl_seconds=STATE_TTL_SECONDS, client_id=None): """生成并入库一个随机 state,返回 state 字符串。 ``ttl_seconds`` 只影响后续 ``validate_state`` 的过期判定, 本函数不把过期时间单独存一列:判定统一用 ``created_at + ttl_seconds``(validate 时传入的 ttl 须与 创建时一致,默认两边都是 600 秒)。 ``client_id`` 随 state 一起入库:真实浏览器回调只带 code + state,回调方靠它取回登录时传入的 client_id。 """ _provider_config(provider) if not redirect_uri or not isinstance(redirect_uri, str): raise ValueError("redirect_uri required") state = secrets.token_urlsafe(32) self.conn.execute( "INSERT INTO oauth_states(state, provider, redirect_uri," " client_id, created_at)" " VALUES(?, ?, ?, ?, ?)", (state, provider, redirect_uri, client_id, time.time()), ) self.conn.commit() return state def validate_state(self, state, provider, redirect_uri=None, ttl_seconds=STATE_TTL_SECONDS, now=None): """校验 state,成功返回记录 dict,失败返回 None(绝不抛错)。 校验规则(任一不满足即拒绝): - state 在库里存在,否则 None(不存在); - 记录的 provider 与传入一致,否则 None(不匹配,且不消耗 该 state,避免一次错 provider 的尝试烧掉合法 state); - redirect_uri 若传入,须与记录一致,否则 None(不匹配); - ``now``(默认 time.time())未超过 ``created_at + ttl_seconds``,否则删除该行并返回 None(过期)。 成功是“一次性”的:返回前删除该行,同一 state 第二次校验 必然失败,防止重放。``now`` 参数只为单测注入时间, 生产调用传默认即可。 """ if not state: return None if now is None: now = time.time() row = self.conn.execute( "SELECT state, provider, redirect_uri, client_id, created_at" " FROM oauth_states WHERE state = ?", (state,), ).fetchone() if row is None: return None stored_state, stored_provider, stored_redirect_uri, stored_client_id, created_at = row if stored_provider != provider: return None if redirect_uri is not None and stored_redirect_uri != redirect_uri: return None if now > created_at + ttl_seconds: self.conn.execute( "DELETE FROM oauth_states WHERE state = ?", (state,)) self.conn.commit() return None self.conn.execute( "DELETE FROM oauth_states WHERE state = ?", (state,)) self.conn.commit() return { "state": stored_state, "provider": stored_provider, "redirect_uri": stored_redirect_uri, "client_id": stored_client_id, "created_at": created_at, } def build_authorization_url(provider, client_id, redirect_uri, scope=None, state=None): """拼授权 URL(纯字符串拼接,不发任何请求、不含任何 secret)。 参数: - provider:``"github"`` 或 ``"google"``,未知抛 OAuthError; - client_id:调用方传入的真实应用 id,本模块不硬编码、不校验 其真实性(注册见模块文档“真实 OAuth 仍缺什么”); - redirect_uri:须与在 provider 处注册的回调地址一致; - scope:不传则用该 provider 的默认 scope(GitHub ``read:user``, Google ``openid email profile``); - state:不传则现场随机生成一个(调用方若要校验,必须自己 用 OAuthStateStore 存下它;推荐直接用 ``start_oauth_login``, 它把生成 + 入库 + 拼 URL 一次做完)。 返回的 URL 只含 client_id / redirect_uri / scope / state(Google 另含 response_type=code),绝不含 client_secret。 """ config = _provider_config(provider) if not client_id or not isinstance(client_id, str): raise ValueError("client_id required") if not redirect_uri or not isinstance(redirect_uri, str): raise ValueError("redirect_uri required") if state is None: state = secrets.token_urlsafe(32) params = { "client_id": client_id, "redirect_uri": redirect_uri, "scope": scope or config["default_scope"], "state": state, } if provider == "google": params["response_type"] = "code" return config["authorize_url"] + "?" + urllib.parse.urlencode(params) def start_oauth_login(state_store, provider, client_id, redirect_uri, scope=None): """登录起点:生成并入库 state,再拼授权 URL。 返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL, provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。 client_id 随 state 一起入库,回调时从 state 记录取回。 """ state = state_store.create_state(provider, redirect_uri, client_id=client_id) url = build_authorization_url( provider, client_id, redirect_uri, scope=scope, state=state) return state, url def _urllib_post(token_url, payload): """默认的真实换 token 实现(urllib POST,返回解析后的 dict)。 只在调用方未注入 http_client 且配好 client_secret 时走到; GitHub 的 token 端点默认回表单编码,本函数先试 JSON、 失败再按表单解析,两种都解析不出则返回空 dict(由调用方 判为换 token 失败)。 """ data = urllib.parse.urlencode(payload).encode("utf-8") request = urllib.request.Request( token_url, data=data, method="POST", headers={ "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded", }, ) with urllib.request.urlopen(request, timeout=30) as response: raw = response.read().decode("utf-8", "replace") try: parsed = json.loads(raw) if isinstance(parsed, dict): return parsed except ValueError: pass form = parse_qs(raw) return {key: values[0] for key, values in form.items() if values} def exchange_code_for_token(provider, code, client_id, redirect_uri, http_client=None, client_secret=None): """用授权码换 token,返回 provider 的 token 响应 dict。 http_client 注入约定(单测与未来真实接线共用同一签名): ``http_client(token_url, payload_dict) -> dict``,其中 payload 含 client_id / code / redirect_uri,Google 另含 grant_type=authorization_code;client_secret 仅在有值时才放进 payload。返回的 dict 须含 ``access_token``,否则视为换 token 失败; http_client 抛异常、或返回含 ``error`` 的 dict,同样视为失败。 client_secret 供给:参数优先,未传则读该 provider 对应的环境变量 (见 PROVIDERS 的 client_secret_env)。真实路径(http_client 为 None)下仍无 secret 时,在发任何请求前就抛 OAuthError, 绝不以空 secret 去撞真实端点。注入了 http_client 的测试路径 允许 secret 缺省(fake 不需要真实 secret),生产调用不得依赖 这一宽松分支。 """ config = _provider_config(provider) if not code or not isinstance(code, str): raise OAuthError("code required", reason="token_exchange_failed") if client_secret is None: client_secret = os.environ.get(config["client_secret_env"]) if http_client is None and not client_secret: raise OAuthError( "client_secret not configured: set %s (never hard-code it)" % config["client_secret_env"], reason="client_secret_missing", ) payload = { "client_id": client_id, "code": code, "redirect_uri": redirect_uri, } if provider == "google": payload["grant_type"] = "authorization_code" if client_secret is not None: payload["client_secret"] = client_secret client = http_client if http_client is not None else _urllib_post try: result = client(config["token_url"], payload) except Exception as exc: raise OAuthError( "token exchange failed: %s" % (exc,), reason="token_exchange_failed" ) from exc if not isinstance(result, dict) or result.get("error"): raise OAuthError( "token exchange failed: %r" % (result,), reason="token_exchange_failed", ) if not result.get("access_token"): raise OAuthError( "token exchange returned no access_token", reason="token_exchange_failed", ) return result def _urllib_get_json(url, headers): """默认的真实 userinfo 实现(urllib GET,返回解析后的 dict)。 非 2xx、非法 JSON、非 dict 一律抛 OAuthError(reason=userinfo_failed), 调用方不得把失败静默当成匿名登录。 """ request = urllib.request.Request(url, method="GET", headers=headers) try: with urllib.request.urlopen(request, timeout=30) as response: raw = response.read().decode("utf-8", "replace") status = response.status except Exception as exc: raise OAuthError( "userinfo request failed: %s" % (exc,), reason="userinfo_failed", ) from exc if not (200 <= status < 300): raise OAuthError( "userinfo request returned HTTP %s" % status, reason="userinfo_failed", ) try: parsed = json.loads(raw) except ValueError as exc: raise OAuthError( "userinfo response is not JSON", reason="userinfo_failed", ) from exc if not isinstance(parsed, dict): raise OAuthError( "userinfo response is not an object", reason="userinfo_failed") return parsed def default_userinfo_client(provider): """返回该 provider 的真实 userinfo 调用(access_token -> dict)。 GitHub 调 https://api.github.com/user(Bearer + User-Agent)取 login/id;Google 调 https://openidconnect.googleapis.com/v1/userinfo 取 sub/email/name。失败一律抛 OAuthError(reason=userinfo_failed)。 """ config = _provider_config(provider) url = config["userinfo_url"] def fetch(access_token): headers = {"Authorization": "Bearer %s" % access_token, "Accept": "application/json"} if provider == "github": # GitHub 要求 User-Agent,否则可能直接 403。 headers["User-Agent"] = "codex-app-server" return _urllib_get_json(url, headers) return fetch def _identity_from_token_or_userinfo(provider, token_result, userinfo_client): """从 token 响应或 userinfo 回调里抽外部身份。 顺序:先看 token 响应自带的身份字段(fake / 某些 provider 会带 id、login、email),没有则调 userinfo_client(access_token)。 userinfo_client 已传入但调用失败、返回非 dict、或无可用身份字段时, 抛 OAuthError(reason=userinfo_failed),绝不静默当成匿名登录; 只有 userinfo_client 本就没传时才返回 None(调用方再判为 identity_missing)。真实 GitHub 的 token 响应不带身份,生产 调用必须传 userinfo_client;真实 Google 的 id_token 解析同样 走 userinfo_client 这一注入点,本模块不解析 JWT。 """ for source in (token_result,): external_id = source.get("id") or source.get("sub") login = source.get("login") or source.get("name") email = source.get("email") if external_id is not None or login is not None or email is not None: return { "provider": provider, "external_id": str(external_id) if external_id is not None else None, "login": login, "email": email, } if userinfo_client is None: return None try: info = userinfo_client(token_result["access_token"]) except OAuthError: raise except Exception as exc: raise OAuthError( "userinfo request failed: %s" % (exc,), reason="userinfo_failed", ) from exc if not isinstance(info, dict): raise OAuthError( "userinfo response is not an object", reason="userinfo_failed") external_id = info.get("id") or info.get("sub") login = info.get("login") or info.get("name") email = info.get("email") if external_id is None and login is None and email is None: raise OAuthError( "userinfo returned no usable identity fields", reason="userinfo_failed", ) return { "provider": provider, "external_id": str(external_id) if external_id is not None else None, "login": login, "email": email, } def handle_oauth_callback(state_store, provider, code, state, client_id, redirect_uri, http_client=None, userinfo_client=None, client_secret=None, now=None, ttl_seconds=STATE_TTL_SECONDS): """处理 provider 回调:验 state -> 换 token -> 返回外部身份。 成功返回 dict:``{"provider", "external_id", "login", "email"}`` (缺失的字段为 None;external_id 至少与 login/email 其一存在, 否则抛 OAuthError)。 失败一律抛 OAuthError,不建任何会话、不写任何用户或审计记录: - state 不存在 -> reason=state_not_found; - state 过期 -> reason=state_expired(过期行在拒绝时被删除); - state 与 provider / redirect_uri 不匹配 -> reason=state_mismatch; - 换 token 失败 -> reason=token_exchange_failed; - userinfo 已传但失败/无可用身份 -> reason=userinfo_failed; - 未传 userinfo_client 且 token 本身无身份 -> reason=identity_missing; - state 里没有 client_id 且调用方也没传 -> reason=client_id_missing。 client_id / redirect_uri 未传时从 state 记录取回(见上文), 因此真实浏览器回调只带 code + state 也能完成。 本函数 deliberately 不做以下三件事,全部留给 server 层(签名 与 auth.py 完全兼容,不发明新格式): 1. 映射到本站用户:可用 ``map_external_identity_to_local_user`` (复用 AuthStore 的 users 表)或 server 层自己的映射; 2. 建会话:本站首版会话就是 AuthStore 的 API key,OAuth 登录后 是否发 key、发给谁,由 server 层决定,本模块不发; 3. 记审计:server 层在映射出本站 user_id 后调用 ``AuthStore.audit(user_id, project_id, "oauth_login", "%s:%s" % (provider, external_id))``——与 auth.py 现有 audit(action, detail) 写法一致,project_id 为登录发生的 项目上下文、无项目上下文时 server 层自定(例如空串), 本模块不替 server 层选 project_id。 access_token 在本函数内只用于取身份(经 userinfo_client), 返回前即丢弃,本模块不存储、不返回它。 """ # 先区分拒绝原因(validate_state 本身只返回记录或 None): # 不存在 / provider 或 redirect_uri 不匹配 / 过期各有 reason; # 过期行在此处删除(validate_state 的过期分支同样会删,幂等)。 if now is None: now = time.time() peek = state_store.conn.execute( "SELECT provider, redirect_uri, client_id, created_at" " FROM oauth_states WHERE state = ?", (state,), ).fetchone() if state else None if peek is None: raise OAuthError("unknown state", reason="state_not_found") peek_provider, peek_redirect_uri, peek_client_id, peek_created_at = peek # 真实浏览器回调只带 code + state:client_id / redirect_uri 未传 # 时从 state 记录取回(登录时已随 state 入库);显式传入的优先。 if client_id is None: client_id = peek_client_id if redirect_uri is None: redirect_uri = peek_redirect_uri if peek_provider != provider or ( redirect_uri is not None and peek_redirect_uri != redirect_uri): raise OAuthError("state does not match provider/redirect_uri", reason="state_mismatch") if not client_id: raise OAuthError( "client_id not recorded for this state; please restart login", reason="client_id_missing") if now > peek_created_at + ttl_seconds: state_store.conn.execute( "DELETE FROM oauth_states WHERE state = ?", (state,)) state_store.conn.commit() raise OAuthError("state expired", reason="state_expired") record = state_store.validate_state( state, provider, redirect_uri=redirect_uri, now=now, ttl_seconds=ttl_seconds) if record is None: raise OAuthError( "invalid state", reason="state_not_found") token_result = exchange_code_for_token( provider, code, client_id, redirect_uri, http_client=http_client, client_secret=client_secret) identity = _identity_from_token_or_userinfo( provider, token_result, userinfo_client) if identity is None: raise OAuthError( "cannot determine external identity: token response carries" " no identity fields and no userinfo_client was provided", reason="identity_missing", ) if identity["external_id"] is None and identity["login"] is None \ and identity["email"] is None: raise OAuthError( "empty external identity", reason="identity_missing") return identity def map_external_identity_to_local_user(auth_store, provider, identity): """把外部身份映射到本站用户(复用 AuthStore 的 users 表)。 确定性用户名:``oauth::``。 已存在则返回 ``(user_id, False)``,不存在则经 ``AuthStore.create_user`` 创建并返回 ``(user_id, True)``—— 创建时生成的 API key 明文按 auth.py 约定只应返回给调用方一次, 本函数不返回它(调用方如需发 key 给登录用户,须另行决定 发放方式,本模块不代发)。这只是脚手架的默认映射:生产若要 把外部身份绑定到已有本站账号(而非每次新建),需另加绑定表 与绑定流程,本函数不假装已做绑定。 """ _provider_config(provider) login = identity.get("login") or identity.get("external_id") \ or identity.get("email") if login is None: raise OAuthError( "identity has no login/external_id/email to map from", reason="identity_missing", ) username = "oauth:%s:%s" % (provider, login) row = auth_store.conn.execute( "SELECT id FROM users WHERE username = ?", (username,) ).fetchone() if row is not None: return row[0], False user_id, _api_key_plain = auth_store.create_user(username) return user_id, True def map_external_identity_to_local_user_with_key(auth_store, provider, identity): """映射到本站用户并签发本站 API key(明文只返回这一次)。 返回 ``(user_id, created, api_key_plain, rotated)``: - 新用户:经 ``AuthStore.create_user`` 创建,返回创建时生成的 key,``rotated`` 为 False; - 已存在用户:经 ``AuthStore.rotate_api_key`` 轮换,返回新 key, 旧 key 即失效,``rotated`` 为 True; 调用方须把明文 key 只在回调成功页/响应里展示一次,之后任何 接口都不再返回它;审计由 server 层记(oauth_login 恒记, 轮换时另记 api_key_rotated)。 """ _provider_config(provider) login = identity.get("login") or identity.get("external_id") \ or identity.get("email") if login is None: raise OAuthError( "identity has no login/external_id/email to map from", reason="identity_missing", ) username = "oauth:%s:%s" % (provider, login) row = auth_store.conn.execute( "SELECT id FROM users WHERE username = ?", (username,) ).fetchone() if row is None: # 新用户:直接返回创建时生成的 key,不再多轮换一次。 user_id, api_key_plain = auth_store.create_user(username) return user_id, True, api_key_plain, False api_key_plain = auth_store.rotate_api_key(row[0]) return row[0], False, api_key_plain, True