OAuth 2.1이 왔다: 뭐가 바뀌었고, 뭐가 사라졌고, 앱을 어떻게 마이그레이션하나
2024년 이전에 SPA 배포해본 적 있으시죠? 그 OAuth 구현, 아마 취약해요. "이론적으로 취약" 같은 얘기가 아니라, 진짜 뚫릴 수 있는 수준이요.
React 튜토리얼에서 다들 따라했던 Implicit Grant 플로우? OAuth 2.1에서 삭제됐어요. 모바일 앱에서 쓰던 Resource Owner Password Credentials(ROPC) 플로우? 이것도 삭제. URL 쿼리 스트링에 Bearer 토큰 넣는 거? 금지.
OAuth 2.1은 마이너 버전 업데이트가 아니에요. 10년 동안 쌓인 보안 삽질을 스펙에 반영한 거고, 실제 프로덕션 코드를 깨뜨려요. 여러분이 쓰는 라이브러리도 슬슬 OAuth 2.1 기본값으로 넘어가고 있고, ID 프로바이더들도 레거시 엔드포인트를 닫고 있어요. 아직 안 옮겼으면, 솔직히 시간 별로 안 남았어요.
이 글에서는 OAuth 2.1의 브레이킹 체인지를 전부 다루고, 왜 이런 결정이 내려졌는지 설명하고(쓸데없는 관료주의가 아니라는 것도 같이), 바로 쓸 수 있는 TypeScript 마이그레이션 코드를 드릴게요.
OAuth 2.1이 대체 뭔데?
OAuth 2.1은 새 프로토콜이 아니에요. OAuth 2.0(RFC 6749)에 2012년 이후 나온 보안 베스트 프랙티스 RFC를 다 합친 거예요. 쉽게 말해, OAuth 2.0에 14년치 에러타, 보안 권고, "이거 꼭 이렇게 하세요" 같은 권장사항을 코어 스펙에 때려넣은 거라고 보면 돼요.
흡수된 주요 RFC:
| RFC | 다루는 내용 | OAuth 2.1 영향 |
|---|---|---|
| RFC 7636 | PKCE (Proof Key for Code Exchange) | 모든 클라이언트에 의무화 |
| RFC 7009 | 토큰 폐기(Revocation) | 코어 기능으로 통합 |
| RFC 8252 | 네이티브 앱 베스트 프랙티스 | 루프백 리다이렉트 표준화 |
| RFC 9207 | 인가 서버 발급자 식별(Issuer Identification) | 발급자 검증 필수 |
| RFC 9126 | Pushed Authorization Requests (PAR) | 고보안 플로우에 권장 |
| RFC 9449 | DPoP (Demonstration of Proof-of-Possession) | Bearer 토큰 대신 권장 |
실질적인 효과: 6개 스펙 대신 1개만 읽으면 돼요. 하지만 마이그레이션 비용은 진짜인데, 수백만 앱이 아직 쓰고 있는 플로우를 OAuth 2.1이 _제거_하기 때문이에요.
4가지 브레이킹 체인지
1. Implicit Grant가 사라졌어요
삭제된 것: response_type=token 플로우 전체.
OAuth 2.0에서 Implicit Grant는 클라이언트 시크릿을 안전하게 저장할 수 없는 브라우저 앱용으로 만들어졌어요. 인가 서버가 URL 프래그먼트에 액세스 토큰을 바로 꽂아줬거든요(#access_token=...). 간편하긴 한데, 보안 쪽으로는 재앙이었어요.
왜 취약한지:
// Implicit Flow 취약점 체인
1. 유저가 "Google로 로그인" 클릭
2. 리다이렉트: https://auth.example.com/authorize?
response_type=token&
client_id=my-spa&
redirect_uri=https://app.example.com/callback
3. 인증 후 콜백으로 리다이렉트:
https://app.example.com/callback#access_token=eyJhbGciOi...
// 🚨 문제 1: URL 프래그먼트에 토큰 노출
// - 브라우저 히스토리에 남음
// - 유저와 서버 사이의 프록시/CDN 로그에 기록됨
// - 페이지의 모든 JavaScript에서 접근 가능 (XSS = 게임 오버)
// 🚨 문제 2: 토큰 수신자 검증 불가
// - 공격자가 리다이렉트를 가로채서 토큰 탈취 가능
// - PKCE 없음, 코드 교환 없음, 검증 단계 없음
// �� 문제 3: 리프레시 토큰 없음
// - 단기 토큰이라 계속 재인증해야 함
// - 유저가 짜증나니까 개발자가 토큰 수명을 늘림
// - URL 프래그먼트에 장기 토큰 = 더 나쁜 보안
OAuth 2.1 대체 방법: PKCE가 포함된 Authorization Code 플로우. 모든 SPA, 모바일 앱, Implicit을 쓰던 모든 클라이언트가 전환해야 해요.
// ❌ 이전: Implicit Grant (OAuth 2.1에서 삭제) const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'token'); // ← 금지됨 authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); window.location.href = authUrl.toString(); // ✅ 이후: Authorization Code + PKCE const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'code'); // ← 토큰이 아니라 코드 authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString();
2. PKCE가 모든 클라이언트에 의무화됐어요
바뀐 점: PKCE는 OAuth 2.0에서 선택사항이었고, 퍼블릭 클라이언트(SPA, 모바일 앱)에만 권장됐어요. OAuth 2.1에서는 이미 클라이언트 시크릿이 있는 컨피덴셜 서버사이드 앱을 포함해 모든 클라이언트 타입에 의무화돼요.
컨피덴셜 클라이언트도 PKCE가 필요한 이유:
클라이언트 시크릿이 있더라도, 리다이렉트 과정에서 인가 코드가 가로채질 수 있어요. PKCE는 공격자가 자기 인가 코드를 피해자 세션에 주입하는 인가 코드 인젝션 공격을 방지해요. 클라이언트 시크릿은 토큰 엔드포인트를 보호하고, PKCE는 인가 플로우를 보호하는 거예요.
PKCE 동작 원리:
import { createHash, randomBytes } from 'crypto'; // 1단계: 암호학적으로 안전한 랜덤 코드 검증기 생성 function generateCodeVerifier(): string { // 32바이트 = base64url에서 43자 return randomBytes(32) .toString('base64url'); } // 2단계: 검증기로부터 코드 챌린지 생성 async function generateCodeChallenge(verifier: string): Promise<string> { // SHA-256 해시 후 base64url 인코딩 const hash = createHash('sha256') .update(verifier) .digest(); return Buffer.from(hash).toString('base64url'); } // 3단계: 인가 요청에 포함 const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); // 챌린지를 인가 서버에 전송 // 검증기는 안전하게 저장 (sessionStorage, localStorage 말고) sessionStorage.setItem('pkce_verifier', verifier); // 4단계: 토큰 교환 시 검증기 포함 async function exchangeCode(code: string): Promise<TokenResponse> { const verifier = sessionStorage.getItem('pkce_verifier'); const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier!, // ← 원래 요청을 만든 게 나라는 증명 }), }); return response.json(); }
검증 플로우:
Client Auth Server
| |
|-- code_challenge=SHA256(v) ----> | 인가 요청
| | (챌린지 저장)
|<-------- code=abc123 ----------- | 인가 응답
| |
|-- code=abc123 -----------------> | 토큰 요청
| code_verifier=v | (SHA256(v) 계산 후
| | 저장된 챌린지와 비교)
|<-------- access_token ---------- | 토큰 응답
| |
// 공격자가 코드를 가로채도 교환할 수 없어요
// 원래 요청에서 보낸 code_challenge와 매칭되는
// code_verifier가 없으니까요.
3. ROPC(Resource Owner Password Credentials)가 사라졌어요
삭제된 것: grant_type=password 플로우.
ROPC는 앱이 유저 아이디/비밀번호를 직접 받아서 토큰으로 바꿀 수 있게 해줬어요. 인가 서버로 리다이렉트 못 하는 레거시 앱의 "임시 이전 경로"로 만들었는데, 현실에서는 OAuth 보안의 장점을 전부 날려버리는 꼼수가 돼버렸어요.
왜 제거됐는지:
// ❌ ROPC: 앱이 원시 자격증명을 직접 처리 const response = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'password', // ← OAuth 2.1에서 삭제 username: '[email protected]', // ← 앱이 비밀번호를 볼 수 있음 password: 'hunter2', // ← 피싱 위험, 크리덴셜 스터핑 client_id: CLIENT_ID, }), }); // 🚨 문제점: // 1. 앱이 유저의 원시 비밀번호를 가짐 — OAuth의 존재 이유를 부정 // 2. MFA 미지원 — password grant로는 2FA 불가 // 3. 동의 화면 없음 — 유저가 어떤 권한을 부여하는지 제어 불가 // 4. 유저에게 서드파티 앱에 비밀번호 입력하는 습관을 만듦 // 5. 앱이 뚫리면 모든 유저 비밀번호가 노출됨
OAuth 2.1 대체 방법은 유즈케이스에 따라 달라요:
| 시나리오 | 이전 플로우 | 새 플로우 |
|---|---|---|
| 유저 로그인 (웹/모바일) | ROPC | Authorization Code + PKCE |
| 머신-투-머신 인증 | ROPC (오용) | Client Credentials |
| CLI 도구 인증 | ROPC | Device Authorization (RFC 8628) |
| 레거시 시스템 마이그레이션 | ROPC | Token Exchange (RFC 8693) |
CLI용 Device Authorization 플로우:
// ✅ Device Authorization Flow (CLI 도구에서 ROPC 대체) async function deviceLogin(): Promise<void> { // 1단계: 디바이스 코드 요청 const deviceResponse = await fetch('https://auth.example.com/device', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLI_CLIENT_ID, scope: 'read write', }), }); const { device_code, user_code, verification_uri, interval } = await deviceResponse.json(); // 2단계: 유저에게 코드 표시 console.log(`${verification_uri} 열고 코드 입력: ${user_code}`); // 3단계: 완료될 때까지 폴링 while (true) { await sleep(interval * 1000); const tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLI_CLIENT_ID, }), }); const result = await tokenResponse.json(); if (result.access_token) { saveTokens(result); console.log('인증 성공!'); return; } if (result.error === 'expired_token') { throw new Error('로그인이 만료됐어요. 다시 시도해주세요.'); } // 'authorization_pending' 또는 'slow_down' → 계속 폴링 } }
4. 리다이렉트 URI의 엄격한 매칭
바뀐 점: OAuth 2.0에서는 "느슨한" 리다이렉트 URI 매칭이 허용됐어요. 접두사 매칭, 와일드카드 서브도메인 등이요. OAuth 2.1에서는 정확한 문자열 매칭이 필수예요.
// ❌ OAuth 2.0: 이런 "느슨한" 패턴이 허용됐음 // 등록: https://app.example.com/callback // 매칭: https://app.example.com/callback?foo=bar ← OK // 매칭: https://app.example.com/callback/extra ← OK (접두사 매칭) // 매칭: https://*.example.com/callback ← OK (와일드카드) // ✅ OAuth 2.1: 정확한 문자열 매칭만 허용 // 등록: https://app.example.com/callback // 매칭: https://app.example.com/callback ← OK // 불일치: https://app.example.com/callback?foo=bar ← 거부 // 불일치: https://app.example.com/callback/ ← 거부 (슬래시 하나 차이) // 불일치: https://sub.example.com/callback ← 거부
왜 생각보다 중요한지:
오픈 리다이렉트 취약점은 가장 흔한 OAuth 공격 중 하나였어요. 공격자가 https://evil.com/steal 같은 리다이렉트 URI를 등록하고, 인가 서버가 접두사 매칭과 느슨한 비교를 사용했다면, 인가 코드를 자기 서버로 리다이렉트할 수 있었어요.
마이그레이션 체크리스트:
// 리다이렉트 URI 등록 점검 const redirectUris = { // ❌ 수정이 필요한 것들: bad: [ 'https://app.example.com/*', // 와일드카드 — 허용 안 됨 'https://app.example.com/auth/callback/', // 트레일링 슬래시 불일치 'http://localhost:3000/callback', // 프로덕션에서 HTTP ], // ✅ 올바른 등록: good: [ 'https://app.example.com/auth/callback', // 정확한 매칭, 슬래시 없음 'https://staging.example.com/auth/callback', // 스테이징용 별도 등록 'http://127.0.0.1:3000/callback', // 개발용 루프백 (RFC 8252) 'http://[::1]:3000/callback', // 개발용 IPv6 루프백 ], }; // 참고: 개발 환경에서 RFC 8252는 루프백 주소(127.0.0.1과 [::1])에서의 // HTTP를 허용하지만, "localhost"는 안 돼요 (DNS 해석 이슈)
추가 보안 요구사항
4가지 브레이킹 체인지 외에도, OAuth 2.1은 여러 보안 프랙티스를 강화해요:
URL에 Bearer 토큰 넣기 금지
// ❌ OAuth 2.0에서 이게 허용됐음 fetch('https://api.example.com/data?access_token=eyJhbGciOi...'); // URL의 토큰 = 서버 액세스 로그, 프록시 로그, CDN 로그, 브라우저 히스토리에 기록 // ✅ OAuth 2.1: Authorization 헤더만 사용 fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer eyJhbGciOi...', }, }); // ✅ 또는 폼 제출 시 POST 바디에 fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ access_token: 'eyJhbGciOi...', }), });
리프레시 토큰 로테이션
OAuth 2.1은 리프레시 토큰 로테이션을 강력히 권장해요(사실상 필수). 리프레시 토큰을 사용할 때마다 이전 토큰은 무효화되고 새 토큰이 발급돼요.
interface TokenStore { accessToken: string; refreshToken: string; expiresAt: number; } async function refreshAccessToken(store: TokenStore): Promise<TokenStore> { const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: store.refreshToken, client_id: CLIENT_ID, }), }); if (!response.ok) { // 리프레시 토큰이 이미 사용됐거나 폐기됨 // 재인증 필요 throw new AuthenticationRequiredError('세션이 만료됐어요'); } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, // ← 새 리프레시 토큰! expiresAt: Date.now() + data.expires_in * 1000, }; } // 🚨 중요: 레이스 컨디션 처리 // 두 탭이 동시에 리프레시하면, 하나는 새 리프레시 토큰을 받고 // 다른 하나는 이전 토큰이 무효화돼서 실패해요. // 뮤텍스나 리더 일렉션 패턴을 사용하세요: class TokenRefresher { private refreshPromise: Promise<TokenStore> | null = null; async getValidToken(store: TokenStore): Promise<TokenStore> { if (store.expiresAt > Date.now() + 30_000) { return store; // 30초 버퍼로 아직 유효 } // 동시 리프레시 시도 중복 제거 if (!this.refreshPromise) { this.refreshPromise = refreshAccessToken(store).finally(() => { this.refreshPromise = null; }); } return this.refreshPromise; } }
완전한 마이그레이션: React SPA 예제
Implicit Grant를 쓰던 일반적인 React SPA의 전체 before/after 마이그레이션이에요:
// === auth.ts — OAuth 2.1 준수 인증 모듈 === const AUTH_CONFIG = { authority: 'https://auth.example.com', clientId: 'my-spa-client', redirectUri: 'https://app.example.com/auth/callback', // 정확한 매칭 scope: 'openid profile email', tokenEndpoint: 'https://auth.example.com/token', authorizeEndpoint: 'https://auth.example.com/authorize', }; // --- PKCE 유틸리티 --- function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function sha256(plain: string): Promise<ArrayBuffer> { const encoder = new TextEncoder(); return crypto.subtle.digest('SHA-256', encoder.encode(plain)); } async function generatePKCE(): Promise<{ verifier: string; challenge: string; }> { const verifier = generateRandomString(32); const hashed = await sha256(verifier); const challenge = btoa(String.fromCharCode(...new Uint8Array(hashed))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); return { verifier, challenge }; } // --- 로그인 플로우 --- export async function login(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // PKCE 검증기와 state를 sessionStorage에 저장 // (리다이렉트에서 살아남고, 탭 닫으면 초기화) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, state, code_challenge: challenge, code_challenge_method: 'S256', }); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?${params.toString()}`; } // --- 콜백 핸들러 --- export async function handleCallback(): Promise<{ accessToken: string; refreshToken: string; idToken: string; }> { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // CSRF 방지를 위한 state 검증 const savedState = sessionStorage.getItem('oauth_state'); if (!state || state !== savedState) { throw new Error('state 파라미터가 유효하지 않아요. CSRF 공격 가능성.'); } const verifier = sessionStorage.getItem('oauth_code_verifier'); if (!verifier) { throw new Error('PKCE 검증기가 없어요. 로그인 플로우를 다시 시작하세요.'); } // 코드를 토큰으로 교환 const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: AUTH_CONFIG.redirectUri, client_id: AUTH_CONFIG.clientId, code_verifier: verifier, }), }); if (!response.ok) { const error = await response.json(); throw new Error(`토큰 교환 실패: ${error.error_description}`); } // 정리 sessionStorage.removeItem('oauth_code_verifier'); sessionStorage.removeItem('oauth_state'); // URL 정리 window.history.replaceState({}, '', window.location.pathname); return response.json(); } // --- 토큰 관리 --- export async function refreshToken( currentRefreshToken: string ): Promise<{ accessToken: string; refreshToken: string; }> { const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: AUTH_CONFIG.clientId, }), }); if (!response.ok) { // 리프레시 토큰이 로테이션되어 이미 사용됐거나 폐기됨 throw new Error('SESSION_EXPIRED'); } return response.json(); } // --- 로그아웃 --- export async function logout(idToken: string): Promise<void> { // 서버 사이드에서 토큰 폐기 await fetch('https://auth.example.com/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: idToken, token_type_hint: 'access_token', client_id: AUTH_CONFIG.clientId, }), }); // 로컬 상태 정리 후 리다이렉트 sessionStorage.clear(); window.location.href = 'https://auth.example.com/logout?' + new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: 'https://app.example.com', }).toString(); }
ID 프로바이더별 마이그레이션 가이드
주요 ID 프로바이더마다 OAuth 2.1 적용 타임라인이 달라요:
Auth0 / Okta
// Auth0 SDK v2+는 이미 Authorization Code + PKCE가 기본값 // 마이그레이션: SDK 업데이트하고 레거시 설정 제거 // ❌ 이전 Auth0 설정 const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', useRefreshTokens: false, // ← Implicit에서 흔했던 설정 cacheLocation: 'localstorage', // ← 리프레시 토큰 없이 필요했음 }); // ✅ 새 Auth0 설정 (OAuth 2.1 준수) const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', authorizationParams: { redirect_uri: 'https://app.example.com/callback', // 정확한 매칭 }, useRefreshTokens: true, // ← 리프레시 토큰 로테이션 활성화 cacheLocation: 'memory', // ← 메모리 캐시가 더 안전 });
Google OAuth
// Google은 2022년에 신규 앱의 Implicit 플로우를 이미 폐기 // 기존 앱: 마이그레이션 마감일은 다름 // ❌ 이전: Google Sign-In (Implicit) // <script src="https://apis.google.com/js/platform.js"></script> // gapi.auth2.init({ client_id: '...' }) — 폐기됨 // ✅ 이후: Google Identity Services (Authorization Code + PKCE) // 새 GIS 라이브러리에서 코드 플로우 사용 google.accounts.oauth2.initCodeClient({ client_id: GOOGLE_CLIENT_ID, scope: 'openid profile email', ux_mode: 'redirect', redirect_uri: 'https://app.example.com/auth/google/callback', state: generateState(), });
Microsoft Entra ID (Azure AD)
// MSAL.js v2+는 기본으로 Authorization Code + PKCE 사용 import { PublicClientApplication } from '@azure/msal-browser'; const msalConfig = { auth: { clientId: 'YOUR_CLIENT_ID', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID', redirectUri: 'https://app.example.com/auth/callback', // 정확한 매칭 }, cache: { cacheLocation: 'sessionStorage', // ← localStorage보다 안전 storeAuthStateInCookie: false, }, }; const msalInstance = new PublicClientApplication(msalConfig); // 로그인 — MSAL v2+에서 PKCE는 자동 await msalInstance.loginRedirect({ scopes: ['openid', 'profile', 'User.Read'], });
OAuth 2.1 이상의 보안 강화
OAuth 2.1은 최소 기준이지, 여기서 끝이 아니에요. 민감한 데이터 다루는 프로덕션 앱이라면 여기서 더 가야 해요:
DPoP (Demonstration of Proof-of-Possession)
DPoP는 액세스 토큰을 특정 클라이언트에 바인딩해서 토큰 도난과 리플레이 공격을 방지해요. 누구나 사용할 수 있는 Bearer 토큰 대신, DPoP 토큰은 클라이언트의 키 쌍에 암호학적으로 바인딩돼요.
// DPoP: Proof-of-Possession 토큰 async function createDPoPProof( url: string, method: string, accessToken?: string ): Promise<string> { // DPoP 키 쌍 생성 또는 가져오기 const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'] ); const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey), }; const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), // 토큰 바인딩을 위한 액세스 토큰 해시 포함 ...(accessToken && { ath: await sha256Base64url(accessToken), }), }; return signJWT(header, payload, keyPair.privateKey); } // 사용법: 모든 API 요청에 DPoP 증명 첨부 const dpopProof = await createDPoPProof( 'https://api.example.com/data', 'GET', accessToken ); fetch('https://api.example.com/data', { headers: { 'Authorization': `DPoP ${accessToken}`, 'DPoP': dpopProof, }, });
Pushed Authorization Requests (PAR)
PAR은 리다이렉트 전에 인가 파라미터를 서버에 직접 보내서 인가 요청 변조를 방지해요:
// PAR: 인가 파라미터를 먼저 서버 사이드로 푸시 async function initiateLoginWithPAR(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // 1단계: 인가 요청을 서버에 푸시 const parResponse = await fetch('https://auth.example.com/par', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, response_type: 'code', state, code_challenge: challenge, code_challenge_method: 'S256', }), }); const { request_uri } = await parResponse.json(); // 2단계: request_uri만으로 리다이렉트 // (모든 파라미터가 서버 사이드에 저장, URL에 노출 안 됨) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?` + `client_id=${AUTH_CONFIG.clientId}&` + `request_uri=${encodeURIComponent(request_uri)}`; }
OAuth 2.1과 AI 에이전트
OAuth 2.1의 덜 논의되는 함의 중 하나가 바로 새로운 AI 에이전트 생태계에서의 역할이에요. Model Context Protocol(MCP) 서버와 Agent-to-Agent(A2A) 프로토콜이 성숙해지면서, OAuth 2.1이 위임된 AI 접근의 보안 기반이 되고 있어요.
// AI 에이전트 OAuth: 범위 제한, 시간 제한 접근 // AI 에이전트는 절대 전체 유저 권한을 가져서는 안 돼요 const agentTokenRequest = { grant_type: 'authorization_code', code: authorizationCode, code_verifier: pkceVerifier, client_id: 'ai-agent-client', redirect_uri: 'https://agent.example.com/oauth/callback', // ← AI 에이전트에 좁은 스코프 scope: 'read:emails read:calendar', // 'write:*'는 안 됨 // ← 단기 토큰 요청 // (에이전트 작업에 며칠짜리 접근은 필요 없음) }; // MCP 서버 인증의 경우: // MCP 스펙은 AI 에이전트를 통한 서드파티 도구 접근에 // PKCE가 포함된 OAuth 2.1을 권장해요. // 이렇게 하면 유저가 AI 에이전트가 자기 데이터에 // 접근하는 범위에 명시적으로 동의하게 돼요.
왜 중요한지: GitHub 리포, Slack 채널, 이메일 받은 편지함에 접근하는 AI 에이전트에는 암호학적으로 검증 가능하고, 좁은 범위의, 시간 제한이 있는 인가가 필요해요. 환경 변수에 박아둔 고정 API 키가 아니라요. PKCE + DPoP가 포함된 OAuth 2.1이 바로 이걸 제공해요.
흔한 마이그레이션 실수
실수 1: PKCE 검증기를 localStorage에 저장
// ❌ 나쁨: localStorage는 세션 간에 유지됨 localStorage.setItem('pkce_verifier', verifier); // XSS 공격으로 이걸 읽어서 코드 교환을 완료할 수 있음 // ✅ 좋음: sessionStorage는 탭 닫으면 초기화 sessionStorage.setItem('pkce_verifier', verifier); // 여전히 XSS에 취약하지만, 노출 시간이 짧음 // ✅ 베스트: 서버 사이드에 HTTP-only 세션 쿠키로 저장 // (BFF / Backend-for-Frontend 패턴용)
실수 2: 리프레시 토큰 레이스 컨디션 미처리
// ❌ 나쁨: 두 개의 동시 API 호출이 두 번의 리프레시 시도를 트리거 // 탭 1: refresh_token=abc → 새 refresh_token=def 받음 // 탭 2: refresh_token=abc → 실패 (abc가 이미 로테이션됨) // 탭 2가 불필요하게 유저를 로그아웃 시킴 // ✅ 좋음: 뮤텍스로 토큰 리프레시를 중앙화 class TokenManager { private refreshLock: Promise<void> | null = null; async getAccessToken(): Promise<string> { const tokens = this.getStoredTokens(); if (this.isExpired(tokens)) { if (!this.refreshLock) { this.refreshLock = this.doRefresh().finally(() => { this.refreshLock = null; }); } await this.refreshLock; } return this.getStoredTokens().accessToken; } }
실수 3: state 파라미터 무시
// ❌ 나쁨: state 파라미터 없음 = CSRF 보호 없음 const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}`; // 공격자가 피해자를 공격자 계정으로 로그인시키는 URL을 만들 수 있음 // ✅ 좋음: 항상 state를 생성하고 검증 const state = crypto.randomUUID(); sessionStorage.setItem('oauth_state', state); const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&state=${state}`; // 콜백에서: 코드 교환 전에 state가 일치하는지 확인
실수 4: 개발 환경에 "localhost" 리다이렉트 사용
// ❌ 나쁨: "localhost"는 DNS로 해석됨 (하이재킹 가능) const devRedirect = 'http://localhost:3000/callback'; // ✅ 좋음: 루프백 IP 주소 사용 (RFC 8252) const devRedirect = 'http://127.0.0.1:3000/callback'; // 또는 IPv6: 'http://[::1]:3000/callback' // DNS 없이 로컬로 해석되어 리다이렉트 하이재킹 방지
마이그레이션 체크리스트
기존 OAuth 구현을 점검할 때 이 체크리스트를 활용하세요:
## OAuth 2.1 마이그레이션 체크리스트 ### 긴급 (IdP 강제 적용 전에 반드시 수정) - [ ] 모든 `response_type=token` 사용 제거 (Implicit Grant) - [ ] 모든 `grant_type=password` 사용 제거 (ROPC) - [ ] 모든 authorization code 플로우에 PKCE 추가 - [ ] 리다이렉트 URI 정확한 매칭으로 전환 - [ ] URL 쿼리 스트링의 Bearer 토큰 제거 ### 높은 우선순위 - [ ] 리프레시 토큰 로테이션 구현 - [ ] 리프레시 토큰 레이스 컨디션 처리 (뮤텍스 패턴) - [ ] PKCE 검증기를 localStorage 대신 sessionStorage에 저장 - [ ] 개발 환경 리다이렉트를 localhost에서 127.0.0.1로 변경 - [ ] 모든 인가 요청에 state 파라미터 추가 ### 권장 - [ ] 고보안 토큰 바인딩을 위한 DPoP 구현 - [ ] 민감한 플로우에 PAR (Pushed Authorization Requests) 사용 - [ ] AI 에이전트 토큰 범위를 좁게 (읽기 전용, 시간 제한) - [ ] 서드파티 라이브러리의 OAuth 2.1 준수 여부 점검 - [ ] 로그아웃 시 토큰 폐기 설정
마무리
OAuth 2.1은 뭔가를 더 복잡하게 만드는 게 아니에요. 10년 동안 사고 터진 원인들을 스펙에서 아예 잘라낸 거예요. Implicit Grant는 편하자고 만들었다가 구멍 뚫린 꼼수였고, ROPC는 "임시로만 쓸게요" 하던 게 10년 넘게 눌러앉은 거였고, 와일드카드 리다이렉트는 편했는데 결국 뒤통수를 쳤어요.
대부분의 앱은 마이그레이션이 생각보다 간단해요:
-
Implicit Grant를 Authorization Code + PKCE로 바꾸세요. 제일 임팩트 큰 작업이에요. 모던 SDK(Auth0, MSAL, Firebase) 쓰고 있으면, SDK 버전만 올려도 알아서 처리되는 경우가 많아요.
-
ROPC 플로우는 밀어버리세요. 적절한 대체재로 갈아타면 돼요: 유저 로그인은 Authorization Code, 서버 간 통신은 Client Credentials, CLI는 Device Authorization.
-
리다이렉트 URI 전수 점검하세요. 프로덕션, 스테이징, 개발 환경 전부 정확한 매칭 URI로 등록하고, 로컬은
localhost대신127.0.0.1쓰세요. -
리프레시 토큰 로테이션 켜세요. 탭 여러 개 열어놨을 때 동시 리프레시 터지는 거, 뮤텍스 패턴으로 막아야 해요.
-
토큰 URL에 넣지 마세요.
Authorization헤더만 쓰면 돼요.
ID 프로바이더들은 벌써 움직이고 있어요. Auth0, Okta, Google, Microsoft 전부 레거시 플로우를 닫았거나 강제 적용 일정을 잡았어요. 지금 여유 있을 때 옮기는 게, 나중에 인증이 갑자기 400 뱉기 시작하고 나서 야근하면서 옮기는 것보다 백배 나아요.
관련 도구 둘러보기
Pockit의 무료 개발자 도구를 사용해 보세요