~12 min · auth-security, oauth2, pkce, authorization-code
Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"OAuth2 가 2012년 secret 가진 confidential server-side client 위해 설계. 세계가 secret 못 지키는 모바일 앱과 SPA 로 가득. PKCE 가 per-request 증명 추가해서 fix. 2026년에 모든 flow — confidential 이든 public 이든 — 가 PKCE 씀."
OAuth2 가 실제 뭔지
OAuth2 가 delegated authorization protocol. User 가 제 3자 앱한테 자기 대신 resource 접근 권한 부여, 제 3자한테 password 절대 안 공유. 전형적 예: "이 앱이 네 Google 캘린더 읽게 허용." Google 한테 직접 인증; Google 이 앱한테 scoped token 발급; 앱이 token 으로 캘린더 접근.
역할 넷:
Resource owner — 너, user.
Client — 접근 요청하는 제 3자 앱.
Authorization server — user 인증하고 token 발급하는 identity provider (Google, GitHub, Auth0, Anthropic).
Resource server — 보호된 resource 보유한 API (Google Calendar API, GitHub API). 자주 auth server 와 같은 조직.
Authorization Code Flow 가 존재하는 이유
Flow 의 일: auth server 에서 client 한테 token 받기, user 의 브라우저 주소창이나 브라우저 history (누설 가능) 에 token 노출 없이.
Client 가 user 브라우저를 ?response_type=code&client_id=...&redirect_uri=...&scope=...&state=... 와 함께 auth server 로 redirect.
User 가 auth server 에 인증 (login, MFA, 뭐든), scope 검토, 동의.
Auth server 가 redirect_uri?code=...&state=... 로 redirect back.
Client 의 BACKEND 가 그 code 받아서 client_secret 과 함께 auth server token endpoint 에 POST, access_token (그리고 보통 refresh_token) 받음.
Client 가 access_token 을 resource server request 에 Authorization: Bearer ... 로 씀.
중간 code 가 브라우저 통해 보내기 안전한 일회용, 단기 placeholder. 실제 token 이 절대 브라우저 주소창 안 만짐.
모바일과 SPA client 문제
위 step 4 가 client_secret 요구 — request 가 정당 client 앱에서 오는 거 증명하는 값, impostor 아님. Server-쪽 앱이 env var 에 저장 (안전). 모바일 앱이 binary 에 embed (binary 가진 누구든 추출). SPA 가 JavaScript 에 ship (URL 가진 누구든 추출).
역사적으로 OAuth2 spec 이 우회로 Implicit Flow 제공: code 교환 완전 건너뛰고 redirect URI fragment 에 token 직접 돌려줌. 근데 브라우저 history, referrer header 통해 token 새고, refresh token 미지원. 은퇴.
PKCE 가 어떻게 fix
PKCE (Proof Key for Code Exchange, RFC 7636) 가 사전 공유 필요 없는 per-request secret 추가:
Redirect 전 client 가 random code_verifier (43-128 글자) 생성하고 code_challenge = base64url(sha256(code_verifier)) 계산.
Client 가 초기 authorization request 에 code_challenge 보냄: ?response_type=code&client_id=...&code_challenge=...&code_challenge_method=S256.
Auth server 가 발급된 code 와 함께 challenge 저장.
Client 가 code 를 token 으로 교환할 때 원래 code_verifier 같이 보냄.
Auth server 가 sha256(code_verifier) == code_challenge 확인. Yes 면 token 발급. No 면 거부.
승리: authorization code 가로채는 공격자가 code_verifier 모르고 교환 못 함 — 원래 client 만 앎, 로컬에서 생성해서. client_secret 불필요.
2026년에 모든 OAuth2 client 가 PKCE 써야. 원래 모바일과 SPA client 용 의도, IETF (RFC 9700, BCP 240) 가 이제 confidential client 한테도 PKCE 권장. 비용 거의 영 (flow 당 sha256 hash 하나) 이고 authorization-code-interception 공격 한 카테고리 제거. 켜; 논쟁 마.
Scope — 세밀한 권한 부여
scope parameter 가 client 가 요청하는 거 나열: scope=read:calendar write:calendar.events. Auth server 가 동의 동안 user 한테 표시 ("이 앱이 원함: 캘린더 읽기, 캘린더 이벤트 생성"). 부여된 scope 가 발급된 token 의 claim 됨; resource server 가 token 쓸 때 강제.
Scope 설계 API-specific. Google API 가 URL-스타일 scope 씀 (https://www.googleapis.com/auth/calendar.readonly); GitHub 가 짧은 이름 씀 (repo, user:email); Stripe 가 OAuth scope 많이 안 씀 — 대부분 통합이 full-account 라서. Convention 골라; 문서화.
OIDC — OAuth2 에 identity 추가
OAuth2 가 authorization, authentication 아님. "이 token 이 캘린더 읽을 권한 있음" 이 user 가 누군지 안 알려줌. OpenID Connect (OIDC) 가 OAuth2 위에 앉아서 response 에 id_token (user identity claim 담은 서명된 JWT) 추가. 대부분 현대 "X 로 sign in" 버튼 (Google, Apple, Microsoft) 이 raw OAuth2 아닌 OIDC 구현. Flow shape 동일; OIDC 가 그냥 id_token 추가.
cwkPippa 의 OAuth 현실
cwkPippa 의 brain adapter 가 provider 셋에 대해 OAuth2 씀: Claude 용 Anthropic OAuth (confidential client, server-쪽), Codex 용 ChatGPT OAuth (confidential client), Gemini 용 Cloud Code Assist OAuth (confidential client). 셋 다 저장된 client_secrets 가진 backend 에서 돌아 — 그 specific 케이스엔 PKCE 불필요, 브라우저-쪽 public client 안 관여라서. cwk-site 의 user auth 가 Supabase 통해 가는데 자체 아래에서 PKCE-flavored OAuth 돌림. Full PKCE flow 가 redirect 보유하는 브라우저 있을 때마다 작용.
Code
Client: redirect 전 verifier + challenge 생성·python
# Client 쪽 — PKCE verifier 와 challenge 생성
import secrets, hashlib, base64
# Step 1: verifier 생성 (random URL-safe string, 43-128 글자)
code_verifier = secrets.token_urlsafe(64)
# Step 2: challenge = base64url(sha256(verifier)) 유도
challenge_bytes = hashlib.sha256(code_verifier.encode('ascii')).digest()
code_challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=').decode('ascii')
# Step 3: authorization URL 구성
from urllib.parse import urlencode
auth_url = 'https://auth.example.com/authorize?' + urlencode({
'response_type': 'code',
'client_id': 'my-app-id',
'redirect_uri': 'https://my-app.example.com/callback',
'scope': 'read:user write:repo',
'state': secrets.token_urlsafe(16), # CSRF 보호
'code_challenge': code_challenge,
'code_challenge_method': 'S256',
})
print('브라우저에서 이 URL 열어:', auth_url)
# code_verifier 저장 — 교환 step 에 필요
Code 를 PKCE verifier 써서 access_token 으로 교환·python
# Client 쪽 — authorization code 를 token 으로 교환
import httpx
# Auth server 가 redirect_uri?code=...&state=... 로 redirect 후
# (보낸 state 일치 검증!)
resp = httpx.post(
'https://auth.example.com/token',
data={
'grant_type': 'authorization_code',
'client_id': 'my-app-id',
'redirect_uri': 'https://my-app.example.com/callback',
'code': 'redirect 에서 받은 code',
'code_verifier': code_verifier, # <-- 이 flow 시작했다는 증명
# confidential client 는 client_secret 도 여기 보냄; public client 안 보냄
},
)
tokens = resp.json()
print(tokens)
# {
# "access_token": "eyJhbGciOi...",
# "token_type": "Bearer",
# "expires_in": 3600,
# "refresh_token": "def50200...",
# "scope": "read:user write:repo"
# }
Auth server: token 교환 동안 PKCE verifier validate·python
# Server 쪽 — auth server 가 token 교환 동안 PKCE validate
from fastapi import FastAPI, Form, HTTPException
import hashlib, base64, secrets, time
app = FastAPI()
_codes: dict[str, dict] = {} # code -> {client_id, code_challenge, redirect_uri, user_id, exp}
@app.post('/token')
async def token(
grant_type: str = Form(...),
client_id: str = Form(...),
redirect_uri: str = Form(...),
code: str = Form(...),
code_verifier: str = Form(...),
):
if grant_type != 'authorization_code':
raise HTTPException(400, detail='미지원 grant_type')
record = _codes.pop(code, None)
if not record or record['exp'] < time.time():
raise HTTPException(400, detail='무효 혹은 만료된 code')
if record['client_id'] != client_id or record['redirect_uri'] != redirect_uri:
raise HTTPException(400, detail='client_id 혹은 redirect_uri 불일치')
# 핵심 체크: client 가 verifier 알았어?
derived_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('ascii')).digest()
).rstrip(b'=').decode('ascii')
if derived_challenge != record['code_challenge']:
raise HTTPException(400, detail='PKCE verifier 불일치')
# 다 좋음 — access token 발급 (JWT 나 opaque)
return {
'access_token': issue_access_token(record['user_id']),
'token_type': 'Bearer',
'expires_in': 3600,
'refresh_token': issue_refresh_token(record['user_id']),
}
Authorization Code + PKCE flow 가진 작은 OAuth2 auth server FastAPI 에 구현: /authorize (가짜 동의 페이지 render, code_challenge 받음), /token (code_verifier validate, access+refresh 발급). 그 다음 전체 flow 걷는 client 써: verifier+challenge 생성, authorize URL 열기, redirect URL 다시 붙여넣기, code+verifier 를 token 으로 교환, 보호된 /me endpoint 에 access token 써. 보너스: 일부러 잘못된 code_verifier 제출하고 auth server 가 400 돌려주는 거 검증.
Hint
Verifier 생성은 그냥 secrets.token_urlsafe(64); challenge 는 = padding 떼낸 base64url(sha256(verifier)). Auth server 가 각 발급된 code 와 code_challenge 저장; /token 에서 제출된 verifier 에서 유도된 challenge 계산하고 비교. 전체 PKCE 추가가 코드 20줄쯤 — 가치가 복잡도 아니라 spec 이 가졌던 'public client 가 OAuth 안전히 못 씀' 문제 제거.
Progress
Progress is local-only — sign in to sync across devices.