"""구글 ID 토큰 검증 — "이 토큰이 정말 구글이 **우리 앱에** 발급한 것인가" 만 본다. 프론트(Google Identity Services)가 받아 온 ID 토큰을 그대로 우리 백엔드로 보내면, 여기서 구글 공개키로 서명을 확인하고 신원(sub·email)을 꺼낸다. 그 뒤로는 우리 JWT 다 — 구글 토큰을 세션으로 들고 다니지 않는다. ★ 왜 client_secret 이 없나 코드 교환(authorization code flow)을 하지 않기 때문이다. GIS 는 브라우저에서 ID 토큰을 바로 준다. 서버가 할 일은 교환이 아니라 **검증**이고, 검증에 필요한 건 공개키와 client_id 뿐이다. ★ 반드시 남겨야 할 검사 세 가지 (하나만 빠져도 조용히 뚫린다) 1. 서명 — 구글 JWKS 의 공개키로. 이게 없으면 아무나 JSON 을 만들어 보낸다. 2. aud — 우리 client_id 와 같아야 한다. 없으면 **다른 서비스에 발급된 진짜 구글 토큰**을 그대로 들고 와서 우리 계정이 된다(가장 흔한 구멍이다). 3. iss — accounts.google.com. 서명과 함께 발급자를 못 박는다. email_verified 도 함께 본다 — 미인증 이메일을 신원으로 쓰면 이메일 기반 판단이 전부 흔들린다. ★ 공개키는 돌아간다(rotation). kid 가 캐시에 없으면 한 번 다시 받는다 — TTL 만 믿고 있으면 키가 바뀐 직후 몇 분 동안 전원 로그인 실패다. """ 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). OpenID discovery 를 매번 타지 않고 고정 주소를 쓴다 — 구글이 바꾸지 않는 주소다. _JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs" # 구글은 두 표기를 모두 쓴다. 한쪽만 받으면 어느 날 갑자기 전원 로그인 실패다. _ISSUERS = ("accounts.google.com", "https://accounts.google.com") # 캐시 수명. 구글 응답의 Cache-Control 은 보통 수 시간이라 1시간은 넉넉히 보수적이다. _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 과 짝일 때만 의미가 있다. GIS 크리덴셜에는 access_token 이 # 없으므로 켜 두면 "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 ""))