OAuth 2.0 심화는 PKCE(Proof Key for Code Exchange) 보안 확장과 Refresh Token 관리, 그리고 다양한 그랜트 타입을 다룬다. 공개 클라이언트(SPA, 모바일)에서의 안전한 인증 흐름을 구현한다.
OAuth 2.0 그랜트 타입
| 그랜트 타입 | 적합 대상 | 특징 |
|---|
| Authorization Code + PKCE | SPA, 모바일 | 현재 권장 표준 |
| Client Credentials | 서버-서버 | 사용자 없는 M2M |
| Device Code | TV, IoT | 브라우저 없는 기기 |
Implicit | (deprecated) | 보안 취약, 사용 금지 |
PKCE (Proof Key for Code Exchange)
typescript
// 클라이언트: PKCE 파라미터 생성
function generatePKCE() {
// 1. code_verifier: 43~128자 랜덤 문자열
const codeVerifier = crypto.randomBytes(32)
.toString('base64url');
// 2. code_challenge: SHA-256(code_verifier)
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// 인증 요청
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('code_verifier', codeVerifier);
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'my-app');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('state', generateState()); // CSRF 방지
window.location.href = authUrl.toString();
// 콜백: 코드를 토큰으로 교환
async function handleCallback(code: string) {
const codeVerifier = sessionStorage.getItem('code_verifier')!;
const tokens = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-app',
code_verifier: codeVerifier, // 검증용
}),
}).then(r => r.json());
return tokens; // { access_token, refresh_token, expires_in }
}
Refresh Token 관리
typescript
class AuthManager {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private expiresAt: number = 0;
async getValidToken(): Promise<string> {
if (this.accessToken && Date.now() < this.expiresAt - 60_000) {
return this.accessToken; // 1분 여유 두고 재사용
}
if (this.refreshToken) {
await this.refreshAccessToken();
return this.accessToken!;
}
throw new Error('인증이 필요합니다');
}
private async refreshAccessToken() {
const res = await fetch('https://auth.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken!,
client_id: 'my-app',
}),
});
if (!res.ok) {
this.clear(); // Refresh Token 만료 → 재로그인
throw new Error('토큰 갱신 실패');
}
const data = await res.json();
this.accessToken = data.access_token;
this.refreshToken = data.refresh_token; // Rotation
this.expiresAt = Date.now() + data.expires_in * 1000;
}
}
관련 개념