"""구글 ID 토큰 검증 — 서명·수신자(aud)·발급자(iss)·이메일 인증.""" 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 로 발급된 정상 토큰.""" 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(키 회전 직후).""" 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 가 비어 있을 때.""" monkeypatch.setattr(google_identity.google_oauth_config, "client_id", "") with pytest.raises(GoogleNotConfigured): await verify_id_token(_token(keypair))