feat(identity): Huawei Account Kit login via OIDC JWKS verification

POST /auth/huawei:JWKS 验签(kid 缓存/轮换)+ claims 严格校验 +
UnionID 映射 + T4 双令牌签发(FR-ACC-01;真实联调待 AGC 配置)。

- packages/identity/huawei.py(新建):HuaweiIdTokenVerifier——RS256 验签,
  JWKS 内存缓存(TTL 可配 + 单调钟;kid 未命中强制刷新一次重试,锁内用
  拉取代数防止强制刷新被新鲜缓存短路);aud/iss/exp/iat 全查,audience
  空配置直接拒绝而非跳过;union_id 缺失拒绝(降级 open_id 策略不做,
  留联调期决定);错误映射 40102/40101/60000,无新错误码。
  HuaweiLoginRequest/HuaweiClaims 一并定义于此,未动 schemas.py。
- apps/api/routers/auth.py:追加 /huawei 端点;get_huawei_verifier 依赖
  建于路由模块内(缓存到 app.state,可预注入替换),未动 deps.py;
  复用 T4 的 get_or_create_by_external_identity + issue_session +
  TokenPairResponse 与既有 _token_pair/_access_token。
- tests/unit/test_huawei_verifier.py:cryptography 自造 RSA + 本地 JWKS
  (零外网),19 例覆盖合法 claims/错误签名/过期/aud/iss/iat 缺失/
  audience 未配置/union_id 缺失/HS256 混淆/JWKS 拉取失败/kid 轮换/缓存
  TTL。
- tests/integration/test_auth_huawei.py:FakeFetcher 预注入 app.state,
  真实 PG 验证首次建用户 + 双令牌、同 union_id 复登同 user_id、新 access
  调 /quota、refresh 可轮换、伪造 token 40102。

检查:ruff/format/mypy(T6 列表)/lint-imports 全绿;单测 257 passed
(基线 238 + 新增 19);集成 18 passed(含新增 2)。

偏差说明:无
This commit is contained in:
xsl
2026-08-14 19:59:15 +08:00
parent d5f366fa4c
commit 6c77ff4989
4 changed files with 726 additions and 4 deletions
+140
View File
@@ -0,0 +1,140 @@
"""POST /v1/auth/huawei 集成——真实 PostgreSQL + FakeFetcher 本地 JWKS,零外网。
覆盖(FR-ACC-01):首次登录自动建用户 + T4 双令牌、同 union_id 复登映射到
同一 user_id、新 access 可调 /quota、伪造 id_token 被拒(40102)。
验签细节(claims/kid 轮换)由 tests/unit/test_huawei_verifier.py 穷举。
"""
from __future__ import annotations
import json
import os
import time
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.algorithms import RSAAlgorithm
from packages.identity.huawei import HuaweiIdTokenVerifier
AUDIENCE = "app-int-1008"
ISSUER = "https://accounts.huawei.com"
JWKS_URL = "https://jwks.local/certs"
KID = "kid-int"
# 模块级固定密钥对:同一用例内多次登录共享 JWKS,避免每个 token 重新生成密钥。
_SIGNING_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_WRONG_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _jwk(kid: str, private_key: rsa.RSAPrivateKey) -> dict[str, Any]:
jwk: dict[str, Any] = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
jwk.update({"kid": kid, "alg": "RS256", "use": "sig"})
return jwk
async def _local_jwks_fetch(url: str) -> str:
assert url == JWKS_URL
return json.dumps({"keys": [_jwk(KID, _SIGNING_KEY)]})
def _id_token(*, union_id: str | None = None, key: rsa.RSAPrivateKey = _SIGNING_KEY) -> str:
"""自签 id_token:union_id 缺省随机(新用户),指定则复现同一外部身份。"""
now = int(time.time())
payload: dict[str, Any] = {
"iss": ISSUER,
"aud": AUDIENCE,
"iat": now - 60,
"exp": now + 600,
"union_id": union_id or f"union-{uuid.uuid4().hex[:12]}",
"open_id": f"open-{uuid.uuid4().hex[:12]}",
}
return jwt.encode(payload, key, algorithm="RS256", headers={"kid": KID})
def _infra_available() -> bool:
return bool(os.getenv("MEIDA_RUN_INTEGRATION"))
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not _infra_available(),
reason="set MEIDA_RUN_INTEGRATION=1 with PostgreSQL and Redis running",
),
]
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
"""预注入 FakeFetcher 验签器到 app.state。
get_huawei_verifier 优先读 app.state 缓存,因此注入后整条链路不触网;
verifier 自带 audience/issuer,也不依赖 .env 是否已配 AGC 凭证。
"""
from apps.api.main import create_app
from packages.shared.config import get_settings
app = create_app(get_settings())
app.state.huawei_verifier = HuaweiIdTokenVerifier(
jwks_url=JWKS_URL,
audience=AUDIENCE,
issuer=ISSUER,
cache_seconds=86400,
http_fetch=_local_jwks_fetch,
)
transport = httpx.ASGITransport(app=app)
async with (
httpx.AsyncClient(transport=transport, base_url="http://test") as http,
app.router.lifespan_context(app),
):
yield http
async def _login(client: httpx.AsyncClient, id_token: str) -> dict[str, Any]:
resp = await client.post("/v1/auth/huawei", json={"id_token": id_token})
assert resp.status_code == 200
body = resp.json()
assert body["code"] == 0
return body["data"]
async def test_huawei_login_issues_pair_relogin_maps_same_user(client: httpx.AsyncClient) -> None:
union_id = f"union-{uuid.uuid4().hex[:12]}"
# 首次登录:自动建用户 + T4 双令牌(契约字段与 dev-token/refresh 一致)
first = await _login(client, _id_token(union_id=union_id))
assert first["access_token"]
assert first["refresh_token"]
assert first["user_id"]
assert first["token_type"] == "Bearer"
assert first["refresh_expires_in"] > 0
# 同 union_id 复登:映射唯一(FR-ACC-01),换发全新会话
second = await _login(client, _id_token(union_id=union_id))
assert second["user_id"] == first["user_id"]
assert second["refresh_token"] != first["refresh_token"]
# 新 access 直接可调需鉴权接口
quota = await client.get(
"/v1/quota", headers={"Authorization": f"Bearer {first['access_token']}"}
)
assert quota.status_code == 200
assert quota.json()["code"] == 0
# 华为签发的 refresh 同样进 T4 轮换链路
rotated = await client.post("/v1/auth/refresh", json={"refresh_token": first["refresh_token"]})
assert rotated.status_code == 200
assert rotated.json()["data"]["refresh_token"] != first["refresh_token"]
async def test_forged_id_token_rejected(client: httpx.AsyncClient) -> None:
# 同 kid 但另一把私钥签发:JWKS 命中 kid、验签失败 → 40102
resp = await client.post("/v1/auth/huawei", json={"id_token": _id_token(key=_WRONG_KEY)})
assert resp.status_code == 401
assert resp.json()["code"] == 40102
+296
View File
@@ -0,0 +1,296 @@
"""HuaweiIdTokenVerifier 纯逻辑单测——自造 RSA 密钥对 + 本地 JWKS,零外网。
覆盖:合法 claims 解析、错误签名、过期、aud/iss 不符、audience 未配置、
union_id 缺失、非 RS256 算法、JWKS 拉取失败、kid 轮换刷新与缓存 TTL。
路由与 DB 路径由 tests/integration/test_auth_huawei.py 验证。
"""
from __future__ import annotations
import json
import time
from collections.abc import Awaitable, Callable
from typing import Any
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from jwt.algorithms import RSAAlgorithm
from packages.identity.huawei import HuaweiIdTokenVerifier
from packages.shared.errors import AppError, ErrorCode
AUDIENCE = "app-1008"
ISSUER = "https://accounts.huawei.com"
JWKS_URL = "https://jwks.local/certs"
Fetch = Callable[[str], Awaitable[str]]
class FakeFetcher:
"""本地 JWKS 源:可热替换内容(模拟轮换)、统计拉取次数、可抛错。"""
def __init__(self, jwks: dict[str, Any]) -> None:
self.jwks = jwks
self.calls = 0
self.fail = False
async def __call__(self, url: str) -> str:
assert url == JWKS_URL
self.calls += 1
if self.fail:
msg = "network down"
raise RuntimeError(msg)
return json.dumps(self.jwks)
def _keypair() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
def _jwk(kid: str, private_key: rsa.RSAPrivateKey) -> dict[str, Any]:
jwk: dict[str, Any] = json.loads(RSAAlgorithm.to_jwk(private_key.public_key()))
jwk.update({"kid": kid, "alg": "RS256", "use": "sig"})
return jwk
def _token(
private_key: rsa.RSAPrivateKey | str,
*,
kid: str = "kid-1",
alg: str = "RS256",
aud: str = AUDIENCE,
iss: str = ISSUER,
exp_in: int = 600,
iat_in: int = -60,
payload_overrides: dict[str, Any] | None = None,
drop_claims: tuple[str, ...] = (),
) -> str:
"""自签 id_token。时间参数以当前时刻为基准(exp_in/iat_in 为相对秒)。"""
now = int(time.time())
payload: dict[str, Any] = {
"iss": iss,
"aud": aud,
"iat": now + iat_in,
"exp": now + exp_in,
"union_id": "union-1",
"open_id": "open-1",
}
payload.update(payload_overrides or {})
for claim in drop_claims:
payload.pop(claim, None)
return jwt.encode(payload, private_key, algorithm=alg, headers={"kid": kid})
def _verifier(
fetcher: Fetch,
*,
audience: str = AUDIENCE,
cache_seconds: int = 86400,
) -> HuaweiIdTokenVerifier:
return HuaweiIdTokenVerifier(
jwks_url=JWKS_URL,
audience=audience,
issuer=ISSUER,
cache_seconds=cache_seconds,
http_fetch=fetcher,
)
def _assert_code(exc_info: pytest.ExceptionInfo[AppError], code: ErrorCode) -> None:
assert exc_info.value.code == code, (
f"期望 {code}({int(code)}),实际 {exc_info.value.code}({int(exc_info.value.code)})"
)
# --- 合法路径 ---------------------------------------------------------------
async def test_valid_token_parses_claims() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
before = int(time.time())
claims = await _verifier(fetcher).verify(_token(key))
after = int(time.time())
assert claims.union_id == "union-1"
assert claims.open_id == "open-1"
# 时间断言容忍秒边界(token 在 before/after 之间签发)
assert before - 60 <= claims.iat <= after - 59
assert before + 600 <= claims.exp <= after + 601
async def test_valid_token_without_open_id_still_maps_by_union_id() -> None:
# open_id 可缺(union_id 才是映射键),缺了不应拒绝登录
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
claims = await _verifier(fetcher).verify(_token(key, drop_claims=("open_id",)))
assert claims.union_id == "union-1"
assert claims.open_id is None
async def test_union_id_camel_case_claim_is_accepted() -> None:
# 联调前对 snake/camel 两种拼法都收(见 huawei._first_str),此处锁行为
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
token = _token(key, payload_overrides={"unionID": "union-c"}, drop_claims=("union_id",))
claims = await _verifier(fetcher).verify(token)
assert claims.union_id == "union-c"
# --- token 侧拒绝(40102 / 40101)------------------------------------------
async def test_wrong_signing_key_rejected() -> None:
# kid 相同但用另一把私钥签 → JWKS 命中 kid 却验签失败 → 40102
signing_key, jwks_key = _keypair(), _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", jwks_key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(signing_key, kid="kid-1"))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_expired_token_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, exp_in=-30, iat_in=-3600))
_assert_code(exc_info, ErrorCode.TOKEN_EXPIRED)
async def test_wrong_audience_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, aud="someone-else"))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_missing_audience_claim_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, drop_claims=("aud",)))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_wrong_issuer_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, iss="https://evil.example.com"))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_missing_iat_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, drop_claims=("iat",)))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_audience_unconfigured_rejected() -> None:
# AGC 未配置(HUAWEI_AUDIENCE 空)时必须拒绝而不是跳过 aud 校验
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher, audience="").verify(_token(key))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
# 且应在拉取 JWKS 之前就拒绝(未联调状态不该打上游)
assert fetcher.calls == 0
async def test_missing_union_id_rejected() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key, drop_claims=("union_id",)))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
async def test_hs256_token_rejected_before_verification() -> None:
# alg 混淆攻击:拿对称密钥伪造 header,必须在取 JWKS 前拒绝
# (密钥给足 32 字节,避免 PyJWT 的短密钥告警干扰输出)
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(
_token("hmac-secret-0123456789abcdef0123456789abcdef", alg="HS256", kid="kid-1")
)
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
assert fetcher.calls == 0
async def test_garbage_token_rejected() -> None:
fetcher = FakeFetcher({"keys": []})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify("not-a-jwt-at-all")
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
# --- JWKS 侧(60000 / kid 轮换 / 缓存)--------------------------------------
async def test_jwks_fetch_failure_maps_upstream_error() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
fetcher.fail = True
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key))
_assert_code(exc_info, ErrorCode.UPSTREAM_ERROR)
async def test_jwks_payload_not_jwks_shape_maps_upstream_error() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
fetcher.jwks = {"unexpected": True}
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(key))
_assert_code(exc_info, ErrorCode.UPSTREAM_ERROR)
async def test_unknown_kid_rejected_after_one_forced_refresh() -> None:
key = _keypair()
other = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
with pytest.raises(AppError) as exc_info:
await _verifier(fetcher).verify(_token(other, kid="kid-unknown"))
_assert_code(exc_info, ErrorCode.TOKEN_INVALID)
# 未命中 → 强制刷新一次 → 仍没有 → 拒绝:恰好两次拉取
assert fetcher.calls == 2
async def test_kid_rotation_refreshes_cache_and_retry_succeeds() -> None:
old_key, new_key = _keypair(), _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", old_key)]})
verifier = _verifier(fetcher)
claims = await verifier.verify(_token(old_key, kid="kid-1"))
assert claims.union_id == "union-1"
assert fetcher.calls == 1 # TTL 内第二次验证应走缓存
# 上游轮换:JWKS 换成 kid-2,本地缓存仍是旧 keys
fetcher.jwks = {"keys": [_jwk("kid-2", new_key)]}
claims = await verifier.verify(
_token(new_key, kid="kid-2", payload_overrides={"union_id": "union-2"})
)
assert claims.union_id == "union-2"
assert fetcher.calls == 2 # kid 未命中触发一次强制刷新,随后验签成功
async def test_cache_reuses_jwks_within_ttl() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
verifier = _verifier(fetcher)
for _ in range(3):
await verifier.verify(_token(key))
assert fetcher.calls == 1
async def test_cache_expires_when_ttl_is_zero() -> None:
key = _keypair()
fetcher = FakeFetcher({"keys": [_jwk("kid-1", key)]})
verifier = _verifier(fetcher, cache_seconds=0)
await verifier.verify(_token(key))
await verifier.verify(_token(key))
assert fetcher.calls == 2