지식
Security
OpenID Connect: OAuth 2.0 위에 인증 레이어 추가
OpenID Connect(OIDC)가 OAuth 2.0과 다른 점을 설명하고, ID Token 검증, nonce 리플레이 공격 방어, JWKS 공개키 갱신 등 실무 구현을 다룹니다.
지난 글에서 PKCE로 공개 클라이언트를 보호하는 방법을 다뤘다. OAuth 2.0은 인가(Authorization) 프레임워크이고 인증이 아니다. 사용자가 누구인지 확인하려면 OAuth 2.0 위에 **OpenID Connect(OIDC)**를 얹어야 한다.
OAuth 2.0과 OIDC의 차이
“Google로 로그인” 기능을 OAuth 2.0만으로 구현하면 Access Token으로 사용자 정보 API를 호출해야 한다. 하지만 이 방식은 표준이 없어 프로바이더마다 다르다. OIDC는 이를 표준화한다.
OIDC의 핵심 추가 사항:
scope에openid추가- ID Token (JWT) 발급 — 사용자 신원 정보 포함
/userinfo엔드포인트 표준화- Discovery 문서 (
/.well-known/openid-configuration)
ID Token 완전 검증
ID Token을 검증하지 않거나 부분적으로만 검증하면 심각한 취약점이 생긴다.
import httpx
from jose import jwt, JWTError
# 1. Discovery에서 JWKS URI 가져오기
async def get_jwks():
async with httpx.AsyncClient() as client:
discovery = await client.get(
f"{ISSUER_URL}/.well-known/openid-configuration"
)
jwks_uri = discovery.json()["jwks_uri"]
jwks = await client.get(jwks_uri)
return jwks.json()
async def verify_id_token(id_token: str, nonce: str) -> dict:
jwks = await get_jwks()
try:
claims = jwt.decode(
id_token,
jwks,
algorithms=["RS256"], # 알고리즘 고정
audience=CLIENT_ID, # aud 클레임 검증
issuer=ISSUER_URL, # iss 클레임 검증
)
except JWTError as e:
raise ValueError(f"ID Token 검증 실패: {e}")
# nonce 검증 (리플레이 공격 방어)
if claims.get("nonce") != nonce:
raise ValueError("nonce 불일치")
# at_hash 검증 (Access Token과 바인딩 확인)
# (생략 가능하나 권장)
return claims
리플레이 공격 방어: nonce
같은 ID Token을 재사용하는 리플레이 공격을 막기 위해 nonce를 사용한다.
import secrets
async def start_oidc_login(request: Request):
nonce = secrets.token_urlsafe(32)
state = secrets.token_urlsafe(32)
request.session["oidc_nonce"] = nonce
request.session["oauth_state"] = state
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile", # openid 필수
"state": state,
"nonce": nonce, # 리플레이 방어
"code_challenge": challenge,
"code_challenge_method": "S256",
}
return RedirectResponse(f"{AUTHORIZATION_ENDPOINT}?{urlencode(params)}")
토큰 교환 후 ID Token 처리
async def oidc_callback(code: str, state: str, request: Request):
# state, nonce 세션에서 가져오기
if not secrets.compare_digest(state, request.session.pop("oauth_state", "")):
raise HTTPException(400, "Invalid state")
nonce = request.session.pop("oidc_nonce", "")
# 토큰 교환
tokens = await exchange_code_for_tokens(code)
# ID Token 검증
user_info = await verify_id_token(tokens["id_token"], nonce)
# sub 클레임으로 사용자 식별 (email은 변경 가능)
user_id = f"{user_info['iss']}|{user_info['sub']}"
# 세션 생성 (ID Token 자체를 세션에 저장하지 말 것)
request.session["user_id"] = user_id
request.session["email"] = user_info.get("email")
return RedirectResponse("/dashboard")
흔한 실수
1. aud 클레임 미검증
# 위험: audience 검증 없음
claims = jwt.decode(id_token, jwks, algorithms=["RS256"])
# 올바름: audience 명시
claims = jwt.decode(id_token, jwks, algorithms=["RS256"], audience=CLIENT_ID)
다른 앱의 ID Token이 내 앱에서 유효한 것처럼 받아들여질 수 있다.
2. sub 대신 email로 사용자 식별
# 위험: email은 변경 가능
user_id = claims["email"]
# 올바름: iss+sub 조합은 불변
user_id = f"{claims['iss']}|{claims['sub']}"
3. JWKS 캐싱 미갱신
인가 서버는 주기적으로 키를 교체한다. 캐시 TTL을 1시간 이하로 설정하고, 키 검증 실패 시 즉시 갱신해야 한다.
import functools
import time
_jwks_cache = {"keys": None, "expires": 0}
async def get_jwks_cached():
if time.time() > _jwks_cache["expires"]:
_jwks_cache["keys"] = await fetch_jwks()
_jwks_cache["expires"] = time.time() + 3600 # 1시간 TTL
return _jwks_cache["keys"]
보안 체크리스트
-
scope에openid포함 - ID Token의
iss,aud,exp모두 검증 -
nonce생성 및 검증 (리플레이 방어) - 사용자 식별에
sub사용 (email금지) - JWKS 주기적 갱신 (캐시 TTL ≤ 1시간)
- ID Token을 API 인증에 직접 사용 금지
지난 글: OAuth 2.0 PKCE
다음 글: 다중 인증(MFA) 완전 구현
읽어주셔서 감사합니다. 😊