Files
xsl 6c77ff4989 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)。

偏差说明:无
2026-08-14 19:59:15 +08:00

141 lines
4.9 KiB
Python
Raw Permalink 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.
"""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