OAuth real browser login: GET callback, userinfo, API key issuance

Add GET /oauth/{provider}/callback (code+state from query, client_id
and redirect_uri recovered from the state record) alongside the
existing POST callback. Callback now fetches real userinfo
(GitHub api.github.com/user, Google openidconnect userinfo) and
issues a usable local API key on success: new users get the
create_user key, existing users rotate (old key dead, audit
oauth_login + api_key_rotated). Key plaintext appears only once
on the success page/response; failures are 400 HTML/JSON without
secrets, and a missing client_secret fails before any network call.
State remains one-time, 10 minutes, provider/redirect_uri matched.
Tests: 10 new OAuth real-login tests pass; host 27, cloud 249
(4 skipped are the pre-existing real-Postgres tests).
Real end-to-end still needs a registered OAuth App and a public
HTTPS callback entry (server still binds localhost only).
This commit is contained in:
host committed 2026-10-26 03:35:40 +08:00
1 parent fbb00b8b6f
commit 0ed301b010
5 files changed
+637 -69

No files matched your search

+168 -33
View File
@@ -1,23 +1,25 @@
"""OAuth 接线脚手架(GitHub / Google,首版)。
"""OAuth 接线(GitHub / Google)。
诚实边界:这只是脚手架,不是已接通的真实登录。
- 本模块不向真实 GitHub / Google 发任何请求:默认的 HTTP 路径
(``_urllib_post``)只在调用方真的传入真实 code 且配好
client_secret 时才会被走到;单测一律用注入的 fake http 客户端。
真实浏览器登录已接通: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 由
调用方以参数传入,client_secret 只从环境变量读
登录请求方传入并随 state 一起入库(回调时从 state 记录取回,
这样浏览器重定向只带 code + state 也能完成换 token),
client_secret 只从环境变量读
(GitHub:``GITHUB_OAUTH_CLIENT_SECRET``,
Google:``GOOGLE_OAUTH_CLIENT_SECRET``),绝不写文件、绝不
硬编码、绝不出现在授权 URL 里。
硬编码、绝不出现在授权 URL 或成功页里。
- 本模块不存储 access_token:回调成功后只返回外部身份信息,
access_token 在提取身份后即丢弃。如后续需要用 token 调 provider
API,需另设计加密存储,本模块不提供。
- 本模块不创建本站会话:回调成功返回的只是外部身份字典,
映射到本站用户、建会话、记审计由 server 层用 ``auth.AuthStore``
完成(见 ``handle_oauth_callback`` 文档里的 server 层示例)。
附带的 ``map_external_identity_to_local_user`` 只复用 auth.py 已有
的 users 表(按确定性用户名查找/创建),不发明新的身份表,
也不发明新的审计格式。
- 本模块不做已有本站账号的绑定:映射仍是确定性用户名
``oauth:<provider>:<login>`` 新建/复用,绑定流程需另加。
- 本模块不提供公网 https 入口:server 默认只绑 127.0.0.1,
真实浏览器登录还需要在部署侧配公网可达的 https 回调地址。
流程(标准 OAuth2 授权码流程的接线点):
state 生成(OAuthStateStore.create_state,存库,默认 10 分钟过期)
@@ -49,12 +51,14 @@ 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",
},
@@ -67,7 +71,7 @@ class OAuthError(Exception):
``reason`` 供调用方区分(例如记日志或返回不同的错误提示),
取值:``unknown_provider`` / ``state_not_found`` / ``state_expired`` /
``state_mismatch`` / ``client_secret_missing`` / ``token_exchange_failed``
/ ``identity_missing``。
/ ``identity_missing`` / ``userinfo_failed`` / ``client_id_missing``。
"""
def __init__(self, message, reason=None):
@@ -103,26 +107,40 @@ class OAuthStateStore:
"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):
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, created_at)"
" VALUES(?, ?, ?, ?)",
(state, provider, redirect_uri, time.time()),
"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
@@ -148,13 +166,13 @@ class OAuthStateStore:
if now is None:
now = time.time()
row = self.conn.execute(
"SELECT state, provider, redirect_uri, created_at"
"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, created_at = row
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:
@@ -171,6 +189,7 @@ class OAuthStateStore:
"state": stored_state,
"provider": stored_provider,
"redirect_uri": stored_redirect_uri,
"client_id": stored_client_id,
"created_at": created_at,
}
@@ -217,8 +236,10 @@ def start_oauth_login(state_store, provider, client_id, redirect_uri,
返回 ``(state, authorization_url)``:调用方把用户重定向到该 URL,
provider 回调时带回的 state 交给 ``handle_oauth_callback`` 校验。
client_id 随 state 一起入库,回调时从 state 记录取回。
"""
state = state_store.create_state(provider, redirect_uri)
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
@@ -310,15 +331,72 @@ def exchange_code_for_token(provider, code, client_id, redirect_uri,
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);
两边都没有可用身份时返回 None。真实 GitHub 的 token 响应不带
身份,生产调用必须传 userinfo_client(例如调
https://api.github.com/user 并取 login/id);真实 Google 的
id_token 解析同样走 userinfo_client 这一注入点,本模块不解析 JWT。
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")
@@ -335,15 +413,24 @@ def _identity_from_token_or_userinfo(provider, token_result, userinfo_client):
return None
try:
info = userinfo_client(token_result["access_token"])
except Exception:
return None
except OAuthError:
raise
except Exception as exc:
raise OAuthError(
"userinfo request failed: %s" % (exc,),
reason="userinfo_failed",
) from exc
if not isinstance(info, dict):
return None
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:
return 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,
@@ -368,7 +455,12 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
- state 与 provider / redirect_uri 不匹配 ->
reason=state_mismatch;
- 换 token 失败 -> reason=token_exchange_failed;
- 拿不到外部身份 -> reason=identity_missing。
- 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 完全兼容,不发明新格式):
@@ -392,17 +484,27 @@ def handle_oauth_callback(state_store, provider, code, state, client_id,
if now is None:
now = time.time()
peek = state_store.conn.execute(
"SELECT provider, redirect_uri, created_at"
"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_created_at = peek
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,))
@@ -460,3 +562,36 @@ def map_external_identity_to_local_user(auth_store, provider, identity):
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