"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증. ★ 왜 대역이 아니라 진짜 서명을 쓰나 이 파일이 지키는 건 "우리가 받아들이면 안 되는 토큰을 거부하는가" 하나다. 검증 함수를 대역으로 바꾸면 그 질문 자체가 사라진다. 그래서 여기서는 테스트용 RSA 키를 만들어 **진짜로 서명하고**, 구글 공개키 조회(_fetch_jwks)만 그 키로 바꿔 끼운다. 네트워크는 타지 않는다. 특히 aud 검사: 남의 서비스에 발급된 **진짜 구글 토큰**을 그대로 우리 서버에 보내면 서명도 발급자도 전부 맞다. 그걸 거르는 검사는 aud 하나뿐이다. """ import time import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from jose import jwk from jose import jwt as jose_jwt from services.external import google_identity from services.external.google_identity import GoogleNotConfigured, GoogleTokenInvalid, verify_id_token _KID = "test-key-1" _CLIENT_ID = "our-app.apps.googleusercontent.com" @pytest.fixture(scope="module") def keypair(): """테스트 전용 RSA 키 → (서명용 PEM, 구글 JWKS 를 흉내 낸 공개키 묶음).""" key = rsa.generate_private_key(public_exponent=65537, key_size=2048) private_pem = key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ).decode() public_pem = key.public_key().public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo, ).decode() entry = {k: (v.decode() if isinstance(v, bytes) else v) for k, v in jwk.construct(public_pem, "RS256").to_dict().items()} entry["kid"] = _KID return private_pem, {"keys": [entry]} @pytest.fixture(autouse=True) def google_configured(monkeypatch, keypair): """client_id 를 채우고 JWKS 조회를 테스트 키로 바꾼다. 캐시는 테스트마다 비운다.""" _, jwks = keypair monkeypatch.setattr(google_identity.google_oauth_config, "client_id", _CLIENT_ID) async def _fetch(): return jwks monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch) monkeypatch.setattr(google_identity, "_jwks", None) monkeypatch.setattr(google_identity, "_jwks_at", 0.0) def _token(keypair, **overrides) -> str: private_pem, _ = keypair claims = { "iss": "https://accounts.google.com", "aud": _CLIENT_ID, "sub": "1234567890", "email": "boss@example.com", "email_verified": True, "name": "김사장", "exp": int(time.time()) + 600, "iat": int(time.time()), } claims.update(overrides) return jose_jwt.encode(claims, private_pem, algorithm="RS256", headers={"kid": _KID}) async def test_valid_token_yields_identity(keypair): """검증: 우리 client_id 로 발급된 정상 토큰. 기대결과: sub·email·name 이 그대로 나온다.""" account = await verify_id_token(_token(keypair)) assert account.sub == "1234567890" assert account.email == "boss@example.com" assert account.name == "김사장" async def test_token_for_another_app_is_refused(keypair): """검증: 서명·발급자는 진짜인데 aud 가 **다른 서비스**인 토큰. 기대결과: 거부 — 이 검사가 없으면 남의 앱 토큰으로 우리 계정에 들어온다.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, aud="someone-else.apps.googleusercontent.com")) async def test_token_from_another_issuer_is_refused(keypair): """검증: 우리 aud 를 달고 있지만 iss 가 구글이 아닌 토큰. 기대결과: 거부.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, iss="https://evil.example.com")) async def test_expired_token_is_refused(keypair): """검증: 만료된 토큰. 기대결과: 거부 — 한 번 새어 나간 토큰이 영원히 열쇠가 되지 않게.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, exp=int(time.time()) - 10)) async def test_unverified_email_is_refused(keypair): """검증: email_verified=false. 기대결과: 거부 — 미인증 이메일을 신원으로 쓰면 이메일 기반 중복 판정이 전부 흔들린다.""" with pytest.raises(GoogleTokenInvalid): await verify_id_token(_token(keypair, email_verified=False)) async def test_tampered_signature_is_refused(keypair): """검증: 본문을 바꾼 토큰(서명 불일치). 기대결과: 거부.""" head, payload, sig = _token(keypair).split(".") other = _token(keypair, sub="9999999999").split(".")[1] with pytest.raises(GoogleTokenInvalid): await verify_id_token(f"{head}.{other}.{sig}") async def test_unknown_kid_refetches_keys_once(keypair, monkeypatch): """검증: 캐시에 없는 kid(키 회전 직후). 기대결과: JWKS 를 한 번 더 받아 검증에 성공한다 — TTL 만 믿으면 회전 직후 전원 로그인 실패다.""" private_pem, jwks = keypair calls = {"n": 0} async def _fetch(): calls["n"] += 1 # 첫 호출은 우리 kid 가 없는(=낡은) 묶음을 준다. return {"keys": []} if calls["n"] == 1 else jwks monkeypatch.setattr(google_identity, "_fetch_jwks", _fetch) account = await verify_id_token(_token(keypair)) assert account.sub == "1234567890" assert calls["n"] == 2 async def test_missing_client_id_disables_google_login(monkeypatch, keypair): """검증: GOOGLE_CLIENT_ID 가 비어 있을 때. 기대결과: GoogleNotConfigured — 검증을 시도조차 하지 않는다(aud 대조 대상이 없다).""" monkeypatch.setattr(google_identity.google_oauth_config, "client_id", "") with pytest.raises(GoogleNotConfigured): await verify_id_token(_token(keypair))