"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다.""" import asyncio import time from dataclasses import dataclass import httpx from jose import jwt, JWTError from common.logger import LOG from config.server_configs import google_oauth_config # 구글 공개키(JWKS). _JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" # 구글은 두 표기를 모두 쓴다. _ISSUERS = ("accounts.google.com", "https://accounts.google.com") # 캐시 수명. _JWKS_TTL_SEC = 3600 _HTTP_TIMEOUT_SEC = 5.0 _jwks: dict | None = None _jwks_at: float = 0.0 # 토큰이 동시에 여러 개 들어와도 JWKS 는 한 번만 받는다. _jwks_lock = asyncio.Lock() class GoogleNotConfigured(RuntimeError): """GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다.""" class GoogleTokenInvalid(RuntimeError): """서명·수신자(aud)·발급자(iss)·만료 중 하나라도 어긋났다.""" @dataclass class GoogleAccount: """ID 토큰에서 꺼낸 신원.""" sub: str # 구글 계정의 영구 식별자. email: str name: str def is_configured() -> bool: return bool(google_oauth_config.client_id) async def _fetch_jwks() -> dict: async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_SEC) as client: res = await client.get(_JWKS_URL) res.raise_for_status() return res.json() async def _get_jwks(*, force: bool = False) -> dict: global _jwks, _jwks_at async with _jwks_lock: fresh = _jwks is not None and (time.monotonic() - _jwks_at) < _JWKS_TTL_SEC if fresh and not force: return _jwks try: _jwks = await _fetch_jwks() _jwks_at = time.monotonic() except Exception as ex: LOG.e_no_callstack(f"[GOOGLE] JWKS 조회 실패: {ex}") # 낡은 캐시라도 있으면 그걸로 간다 — 구글이 잠깐 안 될 때 로그인 전체가 죽는 것보다 낫다. if _jwks is None: raise GoogleTokenInvalid("JWKS unavailable") from ex return _jwks def _has_kid(jwks: dict, kid: str | None) -> bool: return any(key.get("kid") == kid for key in (jwks.get("keys") or [])) async def verify_id_token(id_token: str) -> GoogleAccount: if not is_configured(): raise GoogleNotConfigured("GOOGLE_CLIENT_ID 가 비어 있다") if not id_token: raise GoogleTokenInvalid("empty token") try: kid = jwt.get_unverified_header(id_token).get("kid") except JWTError as ex: raise GoogleTokenInvalid("malformed token") from ex jwks = await _get_jwks() # 키 회전 직후: 캐시에 없는 kid 면 한 번만 다시 받는다. if not _has_kid(jwks, kid): jwks = await _get_jwks(force=True) try: claims = jwt.decode( id_token, jwks, algorithms=["RS256"], audience=google_oauth_config.client_id, issuer=_ISSUERS, # at_hash 는 access_token 과 짝일 때만 의미가 있다. options={"verify_at_hash": False}, ) except JWTError as ex: # 이유를 사용자에게 흘리지 않는다 — 로그에만 남긴다. LOG.w(f"[GOOGLE] ID 토큰 거부: {ex}") raise GoogleTokenInvalid(str(ex)) from ex sub = str(claims.get("sub") or "") email = str(claims.get("email") or "") if not sub: raise GoogleTokenInvalid("no sub") # 미인증 이메일은 신원으로 쓸 수 없다 — 남의 주소를 적어 둔 계정일 수 있다. if not claims.get("email_verified"): raise GoogleTokenInvalid("email not verified") return GoogleAccount(sub=sub, email=email, name=str(claims.get("name") or ""))