Skip to content
Published on

OAuth 2.0 완전 정복 — 인증과 인가의 모든 것

Authors
  • Name
    Twitter
OAuth Deep Dive

들어가며

"Google로 로그인" 버튼을 누르면 무슨 일이 벌어질까요? 단 한 번의 클릭 뒤에는 OAuth 2.0이라는 정교한 프로토콜이 돌아갑니다.

OAuth를 이해하면 소셜 로그인, API 인증, 마이크로서비스 간 통신, 심지어 Anthropic의 Claude API 인증 정책까지 명확해집니다.

핵심 개념: 인증 vs 인가

인증 (Authentication): "너 누구야?"
  → 사용자가 본인임을 증명
ID/비밀번호, 생체인식, OTP

인가 (Authorization): "뭘 할 수 있어?"
  → 인증된 사용자에게 권한 부여
  → 이 앱이 네 Google Calendar를 읽어도 돼?

OAuth 2.0 = 인가(Authorization) 프레임워크
OpenID Connect = OAuth 2.0 위에 인증을 추가한 것

OAuth 2.0의 4가지 역할

┌──────────────┐
Resource Owner│사용자 (영주)
└──────┬───────┘
"이 앱에 내 캘린더 접근 허용"
┌──────────────┐     ┌──────────────────┐
Client    │────▶│ Authorization  (우리 앱)   │◀────│    Server└──────┬───────┘       (Google OAuth)       │             └──────────────────┘
Access Token
┌──────────────┐
ResourceServer     │  ← Google Calendar API
└──────────────┘

Grant Types (인가 방식)

1. Authorization Code (가장 중요!)

웹 앱에서 가장 많이 쓰이는 방식입니다.

[1] 사용자 → 우리앱: "Google로 로그인" 클릭
[2] 우리앱 → Google: 리다이렉트 (client_id, redirect_uri, scope)
[3] 사용자 → Google: 로그인 + "허용" 클릭
[4] Google → 우리앱: redirect_uri?code=AUTH_CODE
[5] 우리앱 → Google: AUTH_CODE + client_secret → Access Token
[6] 우리앱 → Google API: Access Token으로 데이터 요청
# Step 2: Authorization 요청 URL 생성
import urllib.parse

auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode({
    "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
    "redirect_uri": "https://yourapp.com/callback",
    "response_type": "code",
    "scope": "openid email profile https://www.googleapis.com/auth/calendar.readonly",
    "state": "random_csrf_token_abc123",  # CSRF 방지!
    "access_type": "offline",  # Refresh Token도 받기
})
# → 사용자를 이 URL로 리다이렉트

# Step 5: Authorization Code → Access Token 교환
import requests

token_response = requests.post("https://oauth2.googleapis.com/token", data={
    "code": "AUTH_CODE_FROM_CALLBACK",
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",  # 서버에서만!
    "redirect_uri": "https://yourapp.com/callback",
    "grant_type": "authorization_code",
})

tokens = token_response.json()
# {
#   "access_token": "ya29.xxx...",      ← API 호출용 (1시간)
#   "refresh_token": "1//xxx...",        ← 갱신용 (영구)
#   "id_token": "eyJhbGci...",           ← 사용자 정보 (OIDC)
#   "expires_in": 3600,
#   "token_type": "Bearer"
# }

# Step 6: API 호출
calendar = requests.get(
    "https://www.googleapis.com/calendar/v3/calendars/primary/events",
    headers={"Authorization": f"Bearer {tokens['access_token']}"}
)

왜 이렇게 복잡한가?

  • Authorization Code는 브라우저 URL에 노출되지만, 1회용이고 단독으로는 쓸 수 없음
  • Access Token 교환은 서버 간 통신 (client_secret 보호)
  • 프론트엔드에 Secret이 절대 노출되지 않음

2. Authorization Code + PKCE (모바일/SPA 필수!)

모바일 앱이나 SPA(React 등)에는 client_secret을 안전하게 저장할 곳이 없습니다.

import hashlib
import base64
import secrets

# Client가 code_verifier 생성 (비밀 값)
code_verifier = secrets.token_urlsafe(64)
# "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk..."

# code_challenge = SHA256(code_verifier) → Base64URL
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b'=').decode()

# Authorization 요청에 challenge 포함
auth_url = f"...&code_challenge={code_challenge}&code_challenge_method=S256"

# Token 교환 시 verifier 제출 (Secret 대신!)
token_response = requests.post("https://oauth2.googleapis.com/token", data={
    "code": auth_code,
    "client_id": "YOUR_CLIENT_ID",
    "code_verifier": code_verifier,  # Secret 대신 이걸 검증!
    "redirect_uri": "...",
    "grant_type": "authorization_code",
})

PKCE의 핵심: Auth Code를 가로채도 code_verifier가 없으면 Token을 못 받음.

3. Client Credentials (서버 간 통신)

# 사용자 개입 없이 서버 → 서버 직접 인증
# 예: 우리 백엔드 → Google Cloud API
token = requests.post("https://oauth2.googleapis.com/token", data={
    "client_id": "SERVICE_CLIENT_ID",
    "client_secret": "SERVICE_CLIENT_SECRET",
    "grant_type": "client_credentials",
    "scope": "https://www.googleapis.com/auth/cloud-platform",
})

4. Refresh Token (갱신)

# Access Token 만료 (1시간) → Refresh Token으로 갱신
new_tokens = requests.post("https://oauth2.googleapis.com/token", data={
    "refresh_token": "1//xxx...",
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
    "grant_type": "refresh_token",
})
# 새 Access Token 발급 (Refresh Token은 유지)

Token 종류 비교

Token수명용도저장 위치
Authorization Code수 초Token 교환용 1회성URL 파라미터 (즉시 소멸)
Access Token1시간API 호출메모리 (브라우저) / DB (서버)
Refresh Token영구 (취소 가능)Access Token 갱신서버 DB (안전하게!)
ID Token (OIDC)1시간사용자 정보 확인메모리

JWT (JSON Web Token) 구조

Access Token과 ID Token은 보통 JWT 형식입니다:

eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik...
├── Header ──────────┤├── Payload ─────────────────────────────...
import base64
import json

jwt = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"

# Header
header = json.loads(base64.urlsafe_b64decode(jwt.split('.')[0] + '=='))
# {"alg": "RS256", "typ": "JWT"}

# Payload
payload = json.loads(base64.urlsafe_b64decode(jwt.split('.')[1] + '=='))
# {"sub": "1234567890", "name": "Youngju Kim", "iat": 1709420400}

# Signature = RS256(header + "." + payload, private_key)
# → 서버의 public key로 검증 (위변조 불가)

OpenID Connect (OIDC) — OAuth + 인증

OAuth 2.0만으로는 "이 사용자가 누구인지" 알 수 없음
OIDC가 id_token을 추가해서 사용자 정보 제공

scope에 "openid" 추가 → id_token 발급
scope에 "profile email" 추가 → 이름, 이메일 포함

보안 체크리스트

✅ state 파라미터로 CSRF 방지
PKCE 사용 (SPA/모바일 필수)
✅ redirect_uri 정확히 매칭 (와일드카드 X)
Refresh Token은 서버에만 저장
Access Token은 Authorization 헤더로만 전송
HTTPS 필수 (Token 탈취 방지)
Access Token을 URL 파라미터에 넣지 않기
❌ client_secret을 프론트엔드에 노출하지 않기
Token을 localStorage에 저장하지 않기 (XSS 취약)

실무 예시: Anthropic OAuth 이슈

최근 Anthropic이 OAuth 정책을 변경한 것도 이 맥락입니다:

기존: Claude Pro 구독 → OAuth Token → 서드파티  (OpenClaw)
변경: OAuth Token은 Claude.ai / Claude Code에서만 사용 가능
이유: Token 차익거래 방지 (구독료 < API 토큰 가치)
대안: Anthropic API Key (종량제) 사용

📝 퀴즈 — OAuth 2.0 (클릭해서 확인!)

Q1. OAuth 2.0에서 인증(Authentication)과 인가(Authorization)의 차이는? ||인증: 사용자가 누구인지 확인. 인가: 인증된 사용자에게 특정 리소스 접근 권한 부여. OAuth 2.0은 인가 프레임워크이고, OIDC가 인증을 추가||

Q2. Authorization Code Grant에서 Code가 1회용인 이유는? ||Code는 브라우저 URL에 노출되므로 탈취 위험. 1회용이고 client_secret 없이는 Token 교환 불가하므로 단독으로는 무용지물||

Q3. PKCE에서 code_verifier와 code_challenge의 관계는? ||code_challenge = SHA256(code_verifier)의 Base64URL 인코딩. 인가 요청 시 challenge를 보내고, 토큰 교환 시 verifier를 보내 서버가 검증||

Q4. Refresh Token을 localStorage에 저장하면 안 되는 이유는? ||XSS 공격으로 JavaScript가 localStorage를 읽을 수 있음. Refresh Token은 장기 유효하므로 탈취 시 지속적인 접근 가능. httpOnly 쿠키 또는 서버 DB에 저장해야 함||

Q5. Client Credentials Grant는 어떤 상황에 쓰이나? ||사용자 개입 없이 서버 간 직접 인증. 예: 백엔드 서비스가 다른 API 서버에 접근할 때. client_id + client_secret만으로 Token 발급||

Q6. state 파라미터가 없으면 어떤 공격이 가능한가? ||CSRF 공격 — 공격자가 자신의 계정으로 발급받은 Authorization Code를 피해자의 콜백 URL로 보내, 피해자를 공격자 계정에 로그인시킬 수 있음||

Q7. JWT의 Signature는 무엇을 보장하나? ||토큰 내용(Header + Payload)이 위변조되지 않았음을 보장. 서버의 Private Key로 서명하고 Public Key로 검증. 단, 암호화는 아님 — Payload는 Base64 디코딩으로 누구나 읽을 수 있음||