
本文详解为何基于服务账号的 id Token 认证无法与 compute engine vm 的自托管服务(如 redis api)正常通信,并指出根本原因在于 google 的服务账号身份验证机制不适用于直接面向公网 ip 的非托管服务,同时提供正确实现方案与关键避坑指南。
google Cloud 的 IDTokenCredentials(即 service_account.IDTokenCredentials.from_service_account_file)并非用于向任意公网 IP + 端口发起“带身份认证的 http 请求”,而是专为 Google 托管服务(如 Cloud Run、Cloud Functions、IAP-protected backends)设计的身份验证机制。其核心逻辑是:生成一个短期 JWT ID Token,以 target_audience 作为该 Token 的 aud(受众)声明,由 Google 的身份验证中间件(如 IAP 或 Cloud Run 的内置 Auth)负责校验该 Token 的签名、时效性及 aud 是否匹配其自身服务标识。
然而,你的 Compute Engine VM 上运行的是完全自托管的容器服务(如 flask/fastapi + redis),它不具备任何内置的 Google ID Token 验证能力。你当前的代码:
target_audience = f"https://{ip}:{port}" creds = service_account.IDTokenCredentials.from_service_account_file( KEY_FILE, target_audience=target_audience)
会生成一个 aud 为 https://
✅ 正确方案:使用服务账号密钥进行服务端身份校验(而非客户端 Token 注入)
-
服务端(VM 容器内)需主动验证请求来源:
在你的应用中(例如 python FastAPI),添加中间件,从 Authorization: Bearer提取 JWT,并使用 Google 的公钥集(https://www.googleapis.com/oauth2/v3/certs)验证其签名、aud、iss(应为 https://accounts.google.com)和有效期。 -
客户端仍可使用 IDTokenCredentials,但 target_audience 必须是服务端可识别的逻辑标识(非 IP):
实际上,target_audience 应设为一个服务标识符(如 my-vm-service),并在服务端硬编码校验该值,而非动态拼接 IP。因为 IP 可能变化,且 Google 不允许 aud 为裸 IP(违反 OIDC 规范)。
示例服务端验证逻辑(Python + PyJWT):
from google.auth import crypt from google.auth.transport import requests as google_requests import jwt import requests # 获取 Google 公钥(缓存复用) def get_google_public_keys(): resp = requests.get("https://www.googleapis.com/oauth2/v3/certs") return resp.json() def verify_id_token(token: str, expected_audience: str = "my-vm-service") -> dict: keys = get_google_public_keys() header = jwt.get_unverified_header(token) key_id = header.get("kid") signing_key = None for key in keys["keys"]: if key["kid"] == key_id: signing_key = crypt.RSASigner.from_string(key["x5c"][0]) break if not signing_key: raise ValueError("Invalid key ID") payload = jwt.decode( token, signing_key, algorithms=["RS256"], audience=expected_audience, issuer="https://accounts.google.com", ) return payload # FastAPI 中间件示例 @app.middleware("http") async def auth_middleware(request: Request, call_next): auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return jsONResponse({"error": "Unauthorized"}, status_code=401) token = auth_header.split(" ")[1] try: claims = verify_id_token(token, expected_audience="my-vm-service") request.state.user_email = claims.get("email") except Exception as e: return jsonResponse({"error": "Invalid token"}, status_code=401) return await call_next(request)
客户端保持类似结构,但修正 target_audience:
# ✅ 正确:audience 是逻辑服务名,非 IP target_audience = "my-vm-service" # 与服务端校验一致 creds = service_account.IDTokenCredentials.from_service_account_file( "vm-key.json", target_audience=target_audience) authed_session = AuthorizedSession(creds) response = authed_session.post( url=f"http://{ip}:8000/get_attributes", # 注意:服务端若无 HTTPS,此处用 http json=uuids, timeout=10 # 增大超时,避免误报 ReadTimeout )
⚠️ 关键注意事项:
- 不要禁用 ssl 验证(verify=False):这会带来严重安全风险;若服务端使用自签名证书,请配置正确的 CA Bundle。
- VPC 防火墙规则与服务账号无关:0.0.0.0/0 规则仅控制网络可达性,不提供身份认证能力;服务账号认证必须由应用层显式实现。
- 避免将服务账号密钥(.json)部署到生产 VM:应改用 VM 关联的服务账号(Compute Engine metadata server),通过 google.auth.default() 自动获取凭据,更安全且免密钥管理。
总结:ReadTimeout 的本质不是网络问题,而是服务端未实现 ID Token 校验逻辑导致请求挂起。真正的解决方案是——将服务账号认证从“客户端单向注入”转变为“服务端主动验证”,并确保 audience 语义清晰、传输协议匹配、超时设置合理。