여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
128 lines
4.6 KiB
Python
128 lines
4.6 KiB
Python
"""구글 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))
|