이미지 로딩 중...
A
AI Generated
2025. 11. 5. · 3 Views
프리미엄
OAuth 트러블슈팅 가이드 고급 개발자용
OAuth 인증 과정에서 발생하는 실전 문제들과 해결 방법을 다룹니다. 토큰 갱신, PKCE 플로우, 보안 취약점 등 실무에서 자주 마주치는 OAuth 이슈를 코드 레벨에서 해결하는 방법을 제시합니다.
카테고리:JavaScript
언어:TypeScript
난이도:advanced
메인 태그:#OAuth
서브 태그:
#PKCE#TokenRefresh#SecurityBestPractices#AuthenticationFlow
들어가며
이 글에서는 OAuth 트러블슈팅 가이드 고급 개발자용에 대해 상세히 알아보겠습니다. 총 8가지 주요 개념을 다루며, 각각의 개념에 대한 설명과 실제 코드 예제를 함께 제공합니다.
목차
- Authorization_Code_Flow_PKCE_구현
- Refresh_Token_Rotation_패턴
- Token_Storage_보안_전략
- State_Parameter_CSRF_방어
- Silent_Token_Refresh_구현
- OAuth_Scope_최소_권한_원칙
- ID_Token_검증_및_보안
- Token_Introspection_활성_검증
1. Authorization_Code_Flow_PKCE_구현
개요
PKCE(Proof Key for Code Exchange)는 OAuth 2.0의 보안 확장으로, 공개 클라이언트(SPA, 모바일 앱)에서 Authorization Code가 탈취되는 것을 방지합니다. code_verifier와 code_challenge를 사용하여 인증 코드 탈취 공격을 막을 수 있습니다.
코드 예제
```typescript
import crypto from 'crypto';
class PKCEAuthFlow {
private codeVerifier: string;
private codeChallenge: string;
constructor() {
// 1. Code Verifier 생성 (43-128자의 랜덤 문자열)
this.codeVerifier = this.generateCodeVerifier();
// 2. Code Challenge 생성 (SHA256 해시)
this.codeChallenge = this.generateCodeChallenge(this.codeVerifier);
}
private generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
private generateCodeChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
// 인증 URL 생성 (code_challenge 포함)
getAuthorizationUrl(): string {
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
response_type: 'code',
scope: 'openid profile email',
code_challenge: this.codeChallenge,
code_challenge_method: 'S256'
});
return `https://auth.provider.com/authorize?${params}`;
}
// 토큰 교환 (code_verifier 사용)
async exchangeCodeForToken(code: string): Promise<any> {
const response = await fetch('https://auth.provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://yourapp.com/callback',
client_id: 'your_client_id',
code_verifier: this.codeVerifier // PKCE 검증용
})
});
return response.json();
}
}
### 설명
이 코드는 OAuth 2.0 PKCE 플로우를 구현하여 공개 클라이언트의 보안을 강화합니다. PKCE는 Authorization Code가 중간에 탈취되더라도 공격자가 토큰을 얻지 못하도록 보호하는 메커니즘입니다. 첫 번째 단계에서 generateCodeVerifier()는 crypto.randomBytes(32)로 32바이트의 암호학적으로 안전한 랜덤 값을 생성하고, base64url로 인코딩하여 43-128자 사이의 문자열을 만듭니다. 이 값은 클라이언트만 알고 있어야 하며 절대 외부로 노출되어서는 안 됩니다. 두 번째 단계에서 generateCodeChallenge()는 code_verifier를 SHA256 해시 알고리즘으로 해시하고 base64url로 인코딩합니다. 이 challenge 값은 인증 요청 시 서버로 전송되며, 서버는 이 값을 저장해둡니다. 해시를 사용하기 때문에 challenge 값만 보고는 원본 verifier를 역산할 수 없습니다. getAuthorizationUrl()에서는 일반적인 OAuth 파라미터에 더해 code_challenge와 code_challenge_method('S256'은 SHA256을 의미)를 포함시킵니다. 사용자가 이 URL로 이동하여 인증을 완료하면 Authorization Code가 발급됩니다. 마지막으로 exchangeCodeForToken()에서 Authorization Code를 토큰으로 교환할 때 원본 code_verifier를 함께 전송합니다. 인증 서버는 받은 verifier를 SHA256으로 해시하여 이전에 저장해둔 challenge와 일치하는지 검증합니다. 일치하면 토큰을 발급하고, 일치하지 않으면 거부합니다. 실무에서 PKCE는 SPA, React Native, Flutter 등 클라이언트 시크릿을 안전하게 보관할 수 없는 환경에서 필수적입니다. Authorization Code가 탈취되더라도 공격자는 code_verifier를 모르기 때문에 토큰을 얻을 수 없습니다. OAuth 2.1 표준에서는 모든 클라이언트에 PKCE 사용을 권장하고 있습니다.
---
## 2. Refresh_Token_Rotation_패턴
### 개요
Refresh Token Rotation은 토큰 재사용 공격을 방지하는 보안 패턴입니다. 리프레시 토큰으로 새 액세스 토큰을 받을 때마다 새로운 리프레시 토큰도 함께 발급받아 이전 토큰을 무효화합니다. 이를 통해 토큰 탈취 시 피해를 최소화할 수 있습니다.
### 코드 예제
```typescript
```typescript
interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
}
class TokenManager {
private accessToken: string = '';
private refreshToken: string = '';
private tokenFamily: string = ''; // 토큰 패밀리 추적
async refreshAccessToken(): Promise<string> {
try {
const response = await fetch('https://auth.provider.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: 'your_client_id'
})
});
if (!response.ok) {
// 재사용 감지 시 모든 토큰 무효화
if (response.status === 401) {
await this.revokeAllTokens();
throw new Error('Token reuse detected. All tokens revoked.');
}
throw new Error('Token refresh failed');
}
const tokens: TokenResponse = await response.json();
// 새 토큰으로 업데이트 (이전 refresh token은 자동 무효화됨)
this.accessToken = tokens.access_token;
this.refreshToken = tokens.refresh_token;
// 안전한 저장소에 저장 (HttpOnly Cookie 권장)
await this.securelyStoreTokens(tokens);
return this.accessToken;
} catch (error) {
console.error('Token refresh error:', error);
throw error;
}
}
private async revokeAllTokens(): Promise<void> {
// 토큰 패밀리 전체 무효화 요청
await fetch('https://auth.provider.com/revoke', {
method: 'POST',
body: new URLSearchParams({
token: this.refreshToken,
token_family: this.tokenFamily
})
});
this.clearTokens();
}
private clearTokens(): void {
this.accessToken = '';
this.refreshToken = '';
this.tokenFamily = '';
}
private async securelyStoreTokens(tokens: TokenResponse): Promise<void> {
// HttpOnly Cookie나 Secure Storage 사용
// localStorage는 XSS 공격에 취약하므로 사용 금지
}
}
### 설명
이 코드는 Refresh Token Rotation 패턴을 구현하여 토큰 탈취 공격의 피해를 최소화합니다. 일반적인 OAuth 플로우에서는 리프레시 토큰이 장기간 유효하여 탈취될 경우 심각한 보안 위협이 됩니다. Refresh Token Rotation은 리프레시 토큰을 일회용으로 만드는 개념입니다. refreshAccessToken() 메서드가 호출될 때마다 서버는 새로운 access_token과 함께 새로운 refresh_token도 발급합니다. 동시에 이전에 사용된 refresh_token은 서버에서 즉시 무효화됩니다. 핵심 보안 메커니즘은 토큰 재사용 감지입니다. 만약 공격자가 이미 사용된 리프레시 토큰을 재사용하려고 시도하면, 서버는 이를 감지하고 401 에러를 반환합니다. 이 경우 revokeAllTokens()가 호출되어 해당 토큰 패밀리에 속한 모든 토큰(정상 사용자의 토큰 포함)을 무효화합니다. 이는 false positive를 유발할 수 있지만, 보안을 우선시하는 트레이드오프입니다. tokenFamily는 동일한 인증 세션에서 발급된 모든 토큰을 추적하는 식별자입니다. 재사용 감지 시 이 패밀리에 속한 모든 토큰을 무효화함으로써, 공격자뿐만 아니라 피해자도 강제로 로그아웃시켜 추가 피해를 방지합니다. securelyStoreTokens()는 토큰을 안전하게 저장하는 로직을 담당합니다. 리프레시 토큰은 절대 localStorage에 저장해서는 안 되며, HttpOnly 및 Secure 플래그가 설정된 쿠키나 React Native의 Keychain, Flutter의 flutter_secure_storage 같은 안전한 저장소를 사용해야 합니다. 실무에서 이 패턴은 Auth0, Okta, AWS Cognito 등 주요 인증 서비스에서 표준으로 채택하고 있습니다. 토큰 탈취가 발생하더라도 피해 기간을 최소화할 수 있으며, 이상 징후를 빠르게 감지할 수 있습니다. 단, 네트워크 오류로 인한 정상적인 재시도와 실제 공격을 구분하기 위해 타임스탬프나 재시도 윈도우를 설정하는 것이 좋습니다.
---
## 3. Token_Storage_보안_전략
### 개요
OAuth 토큰을 클라이언트에 저장할 때는 XSS와 CSRF 공격을 모두 고려해야 합니다. 액세스 토큰과 리프레시 토큰을 각각 다른 방식으로 저장하고, 적절한 보안 헤더를 설정하여 공격 표면을 최소화합니다.
### 코드 예제
```typescript
```typescript
// 서버 사이드 (Node.js/Express)
import express, { Request, Response } from 'express';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
// 토큰 저장: HttpOnly Cookie (리프레시 토큰) + 메모리 (액세스 토큰)
app.post('/auth/callback', async (req: Request, res: Response) => {
const { code } = req.body;
// Authorization Code를 토큰으로 교환
const tokens = await exchangeCodeForTokens(code);
// 리프레시 토큰: HttpOnly Cookie (XSS 방어)
res.cookie('refresh_token', tokens.refresh_token, {
httpOnly: true, // JavaScript 접근 불가 (XSS 방어)
secure: true, // HTTPS만 전송
sameSite: 'strict', // CSRF 방어
maxAge: 7 * 24 * 60 * 60 * 1000, // 7일
path: '/api/auth/refresh' // 특정 경로에만 전송
});
// 액세스 토큰: 메모리에만 저장 (클라이언트)
// 절대 Cookie나 localStorage에 저장하지 말것!
res.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
token_type: 'Bearer'
});
});
// 토큰 갱신 엔드포인트
app.post('/api/auth/refresh', async (req: Request, res: Response) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
const newTokens = await refreshAccessToken(refreshToken);
// 새 리프레시 토큰으로 Cookie 업데이트
res.cookie('refresh_token', newTokens.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});
// 새 액세스 토큰 반환
res.json({
access_token: newTokens.access_token,
expires_in: newTokens.expires_in
});
} catch (error) {
res.clearCookie('refresh_token');
res.status(401).json({ error: 'Token refresh failed' });
}
});
async function exchangeCodeForTokens(code: string): Promise<any> {
// OAuth 토큰 교환 로직
}
async function refreshAccessToken(refreshToken: string): Promise<any> {
// 토큰 갱신 로직
}
### 설명
이 코드는 OAuth 토큰을 안전하게 저장하는 실전 전략을 구현합니다. 토큰 저장은 OAuth 보안에서 가장 중요한 부분이며, 잘못 구현하면 전체 인증 시스템이 무용지물이 됩니다. 핵심 원칙은 "리프레시 토큰은 HttpOnly Cookie, 액세스 토큰은 메모리"입니다. 리프레시 토큰은 장기간 유효하고 강력한 권한을 가지므로, JavaScript에서 접근할 수 없는 HttpOnly Cookie에 저장합니다. httpOnly: true 플래그는 document.cookie를 통한 접근을 완전히 차단하여 XSS 공격으로부터 보호합니다. secure: true 플래그는 HTTPS 연결에서만 쿠키를 전송하도록 강제합니다. HTTP 연결에서는 중간자 공격(MITM)으로 쿠키가 탈취될 수 있기 때문에 프로덕션 환경에서는 필수입니다. sameSite: 'strict'는 크로스 사이트 요청에서 쿠키를 전송하지 않도록 하여 CSRF 공격을 방어합니다. path 속성을 '/api/auth/refresh'로 제한하면 리프레시 토큰이 다른 API 요청에는 전송되지 않습니다. 이는 공격 표면을 줄이고, 불필요한 네트워크 오버헤드를 방지합니다. 만약 공격자가 다른 엔드포인트에 XSS를 삽입하더라도 리프레시 토큰에는 접근할 수 없습니다. 액세스 토큰은 짧은 수명(보통 15분)을 가지므로 메모리에만 저장합니다. React에서는 useState나 Context API, Vue에서는 reactive state에 보관하며, 페이지를 새로고침하면 사라집니다. 이 경우 /api/auth/refresh를 호출하여 새 액세스 토큰을 받아옵니다. /api/auth/refresh 엔드포인트는 Cookie에서 자동으로 리프레시 토큰을 읽어와 처리합니다. 클라이언트는 토큰을 명시적으로 전송할 필요가 없으며, 브라우저가 자동으로 Cookie를 포함시킵니다. 갱신 성공 시 새로운 리프레시 토큰으로 Cookie를 업데이트하여 Token Rotation을 구현합니다. 실무에서 이 패턴은 SPA(Single Page Application)에서 가장 안전한 토큰 저장 방법으로 인정받고 있습니다. localStorage나 sessionStorage는 XSS 공격에 완전히 노출되므로 절대 사용해서는 안 됩니다. BFF(Backend for Frontend) 패턴과 결합하면 더욱 강력한 보안을 달성할 수 있습니다.
---
## 4. State_Parameter_CSRF_방어
### 개요
OAuth 인증 과정에서 state 파라미터는 CSRF 공격을 방어하는 핵심 메커니즘입니다. 인증 요청 시 생성한 랜덤 state 값을 저장했다가, 콜백에서 받은 값과 비교하여 요청의 무결성을 검증합니다. 또한 PKCE와 함께 사용하여 다층 보안을 구현합니다.
### 코드 예제
```typescript
```typescript
import crypto from 'crypto';
interface AuthSession {
state: string;
codeVerifier: string;
nonce?: string;
createdAt: number;
}
class OAuthSecurityManager {
private sessions = new Map<string, AuthSession>();
// 인증 시작: state와 PKCE 생성
initiateAuth(): { authUrl: string; state: string } {
// 1. State 생성 (CSRF 방어용)
const state = crypto.randomBytes(32).toString('base64url');
// 2. PKCE Code Verifier 생성
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// 3. Nonce 생성 (ID Token 재생 공격 방어)
const nonce = crypto.randomBytes(16).toString('base64url');
// 4. 세션 저장 (5분 유효)
this.sessions.set(state, {
state,
codeVerifier,
nonce,
createdAt: Date.now()
});
// 5. 인증 URL 생성
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
response_type: 'code',
scope: 'openid profile email',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
nonce: nonce
});
const authUrl = `https://auth.provider.com/authorize?${params}`;
return { authUrl, state };
}
// 콜백 검증: state 및 세션 확인
validateCallback(receivedState: string, code: string): AuthSession {
// 1. State 존재 여부 확인
const session = this.sessions.get(receivedState);
if (!session) {
throw new Error('Invalid state: session not found (possible CSRF attack)');
}
// 2. 세션 만료 확인 (5분)
const age = Date.now() - session.createdAt;
if (age > 5 * 60 * 1000) {
this.sessions.delete(receivedState);
throw new Error('Session expired');
}
// 3. State 일치 여부 확인
if (session.state !== receivedState) {
throw new Error('State mismatch (possible CSRF attack)');
}
// 4. 세션 삭제 (일회용)
this.sessions.delete(receivedState);
return session;
}
// 주기적 세션 정리
cleanupExpiredSessions(): void {
const now = Date.now();
for (const [state, session] of this.sessions.entries()) {
if (now - session.createdAt > 5 * 60 * 1000) {
this.sessions.delete(state);
}
}
}
}
// 사용 예시
const authManager = new OAuthSecurityManager();
// 로그인 시작
const { authUrl, state } = authManager.initiateAuth();
// state를 안전한 곳에 저장 (HttpOnly Cookie 권장)
// 사용자를 authUrl로 리다이렉트
// 콜백 처리
try {
const session = authManager.validateCallback(receivedState, code);
// session.codeVerifier를 사용하여 토큰 교환
} catch (error) {
// CSRF 공격 또는 만료된 세션 처리
console.error('Auth validation failed:', error);
}
### 설명
이 코드는 OAuth 2.0의 state 파라미터를 활용하여 CSRF(Cross-Site Request Forgery) 공격을 방어하는 완전한 구현을 제공합니다. CSRF 공격은 공격자가 사용자 모르게 인증 요청을 위조하여 공격자의 계정으로 로그인시키는 기법입니다. initiateAuth() 메서드는 인증 플로우를 시작할 때 호출됩니다. 가장 먼저 crypto.randomBytes(32)로 암호학적으로 안전한 32바이트 랜덤 값을 생성하여 state로 사용합니다. 이 값은 추측 불가능해야 하며, 각 인증 요청마다 고유해야 합니다. 동시에 PKCE를 위한 codeVerifier와 codeChallenge도 생성합니다. 또한 OpenID Connect를 사용하는 경우 nonce 값도 생성하여 ID Token 재생 공격을 방어합니다. 이 세 가지 값(state, codeVerifier, nonce)은 모두 서버 측 세션이나 암호화된 쿠키에 안전하게 저장됩니다. 생성된 state는 인증 URL의 쿼리 파라미터로 포함되어 OAuth 제공자에게 전송됩니다. 사용자가 인증을 완료하면, OAuth 제공자는 이 state 값을 그대로 콜백 URL에 포함시켜 돌려보냅니다. 중요한 점은 state 값이 클라이언트와 서버 간에만 공유되며, OAuth 제공자는 이 값을 수정하지 않고 그대로 반환한다는 것입니다. validateCallback() 메서드는 OAuth 콜백을 받았을 때 호출됩니다. 먼저 받은 state로 세션을 조회하여 존재 여부를 확인합니다. 세션이 없다면 이는 공격자가 임의로 만든 state이거나, 이미 사용된 state일 가능성이 높습니다. 다음으로 세션의 생성 시간을 확인하여 5분 이상 경과했다면 만료 처리합니다. state 일치 여부를 확인한 후 세션을 즉시 삭제하여 일회용으로 만듭니다. 이는 재생 공격(replay attack)을 방어합니다. 검증에 성공하면 저장해둔 codeVerifier를 반환하여 토큰 교환 시 사용할 수 있도록 합니다. cleanupExpiredSessions()은 메모리 누수를 방지하기 위해 주기적으로 만료된 세션을 정리합니다. 프로덕션 환경에서는 Redis나 데이터베이스를 사용하여 세션을 관리하고, TTL(Time To Live) 기능으로 자동 만료를 구현하는 것이 좋습니다. 실무에서 state 파라미터 누락은 가장 흔한 OAuth 보안 취약점 중 하나입니다. 많은 개발자가 선택사항으로 생각하지만, OAuth 2.0 RFC 6749에서는 CSRF 방어를 위해 state 사용을 강력히 권장합니다. 특히 소셜 로그인을 구현할 때는 반드시 state를 검증해야 계정 연동 공격을 방어할 수 있습니다.
---
## 5. Silent_Token_Refresh_구현
### 개요
사용자 경험을 해치지 않으면서 액세스 토큰을 자동으로 갱신하는 패턴입니다. 토큰 만료 전에 미리 갱신을 시도하고, API 요청 중 401 에러가 발생하면 자동으로 재시도합니다. 동시 요청 시 토큰 갱신이 중복으로 발생하지 않도록 처리합니다.
### 코드 예제
```typescript
```typescript
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
class ApiClient {
private accessToken: string = '';
private tokenExpiresAt: number = 0;
private refreshPromise: Promise<string> | null = null;
private readonly REFRESH_THRESHOLD = 60 * 1000; // 만료 1분 전 갱신
private api: AxiosInstance;
constructor() {
this.api = axios.create({
baseURL: 'https://api.yourapp.com'
});
// 요청 인터셉터: 토큰 자동 갱신
this.api.interceptors.request.use(
async (config) => {
await this.ensureValidToken();
config.headers.Authorization = `Bearer ${this.accessToken}`;
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터: 401 시 토큰 갱신 후 재시도
this.api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// 401 에러이고 아직 재시도하지 않았다면
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 토큰 강제 갱신
this.accessToken = await this.forceRefreshToken();
// 원래 요청 재시도
originalRequest.headers = originalRequest.headers || {};
originalRequest.headers.Authorization = `Bearer ${this.accessToken}`;
return this.api.request(originalRequest);
} catch (refreshError) {
// 리프레시 실패 시 로그아웃
this.handleLogout();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
}
// 토큰 유효성 확인 및 자동 갱신
private async ensureValidToken(): Promise<void> {
const now = Date.now();
const timeUntilExpiry = this.tokenExpiresAt - now;
// 만료 임박 또는 이미 만료됨
if (timeUntilExpiry < this.REFRESH_THRESHOLD) {
await this.forceRefreshToken();
}
}
// 토큰 강제 갱신 (중복 요청 방지)
private async forceRefreshToken(): Promise<string> {
// 이미 갱신 중이면 같은 Promise 반환
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.refreshTokenInternal();
try {
const newToken = await this.refreshPromise;
return newToken;
} finally {
this.refreshPromise = null;
}
}
// 실제 토큰 갱신 로직
private async refreshTokenInternal(): Promise<string> {
const response = await fetch('https://api.yourapp.com/auth/refresh', {
method: 'POST',
credentials: 'include' // HttpOnly Cookie 포함
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json();
this.accessToken = data.access_token;
this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
return this.accessToken;
}
private handleLogout(): void {
this.accessToken = '';
this.tokenExpiresAt = 0;
// 로그인 페이지로 리다이렉트
window.location.href = '/login';
}
// 공개 API
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.api.get<T>(url, config);
return response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.api.post<T>(url, data, config);
return response.data;
}
}
// 사용 예시
const apiClient = new ApiClient();
// 토큰은 자동으로 관리됨
const userData = await apiClient.get('/user/profile');
const orders = await apiClient.get('/orders');
### 설명
이 코드는 사용자가 인지하지 못하도록 백그라운드에서 토큰을 자동으로 갱신하는 Silent Token Refresh 패턴을 구현합니다. 이는 사용자 경험을 크게 향상시키며, 세션 만료로 인한 데이터 손실을 방지합니다. 핵심 메커니즘은 Axios 인터셉터를 활용한 자동 토큰 관리입니다. 요청 인터셉터에서 ensureValidToken()을 호출하여 모든 API 요청 전에 토큰 유효성을 검증합니다. tokenExpiresAt에서 현재 시간을 뺀 값이 REFRESH_THRESHOLD(1분)보다 작으면 토큰이 곧 만료된다고 판단하고 미리 갱신합니다. 이 예방적 갱신 전략은 실제 API 요청 중에 토큰이 만료되는 상황을 최소화합니다. 만약 액세스 토큰이 15분 유효하다면, 14분이 지난 시점부터는 모든 요청이 자동으로 토큰 갱신을 트리거합니다. 이렇게 하면 사용자가 폼을 작성하거나 파일을 업로드하는 중에 토큰이 만료되어 작업이 실패하는 상황을 방지할 수 있습니다. forceRefreshToken()의 핵심은 refreshPromise를 사용한 중복 방지입니다. 만약 여러 API 요청이 거의 동시에 발생하면(예: 대시보드 로딩 시 10개 API 동시 호출), 모든 요청이 토큰 갱신을 시도할 수 있습니다. refreshPromise를 확인하여 이미 갱신 중이면 새로운 갱신을 시작하지 않고 진행 중인 Promise를 반환합니다. 이렇게 하면 토큰 갱신 API가 한 번만 호출됩니다. 응답 인터셉터는 예방적 갱신이 실패했거나 예상치 못한 토큰 만료 시의 최후 방어선입니다. 401 에러를 받으면 originalRequest._retry 플래그를 확인하여 무한 루프를 방지하고, 토큰을 갱신한 후 실패한 요청을 자동으로 재시도합니다. 사용자는 에러를 경험하지 않으며, 단지 약간의 지연만 느끼게 됩니다. refreshTokenInternal()은 실제 토큰 갱신 API를 호출합니다. credentials: 'include'를 사용하여 HttpOnly Cookie에 저장된 리프레시 토큰이 자동으로 포함되도록 합니다. 새로운 액세스 토큰을 받으면 tokenExpiresAt을 업데이트하여 다음 갱신 시점을 계산할 수 있게 합니다. 리프레시 토큰도 만료되어 갱신에 실패하면 handleLogout()을 호출하여 사용자를 로그인 페이지로 리다이렉트합니다. 이 경우 사용자는 다시 로그인해야 하지만, 이는 보안을 위해 필요한 조치입니다. 실무에서 이 패턴은 SPA의 표준 인증 구현으로 자리잡았습니다. React Query나 SWR 같은 데이터 페칭 라이브러리와 결합하면, 토큰 갱신 실패 시 모든 쿼리를 자동으로 무효화하고 로그인 페이지로 이동시킬 수 있습니다. 또한 토큰 갱신 API 응답에 새로운 리프레시 토큰도 포함되어 있다면 Token Rotation 패턴과 자연스럽게 통합됩니다.
---
## 6. OAuth_Scope_최소_권한_원칙
### 개요
OAuth scope는 액세스 토큰이 가진 권한을 정의합니다. 최소 권한 원칙(Principle of Least Privilege)에 따라 필요한 최소한의 scope만 요청하고, 동적으로 scope를 추가할 수 있도록 구현합니다. 이를 통해 보안을 강화하고 사용자 신뢰를 얻을 수 있습니다.
### 코드 예제
```typescript
```typescript
type OAuthScope = 'openid' | 'profile' | 'email' | 'read:calendar' | 'write:calendar' | 'read:contacts' | 'write:contacts';
interface ScopeRequest {
required: OAuthScope[];
optional?: OAuthScope[];
}
class ScopeManager {
private grantedScopes: Set<OAuthScope> = new Set();
// 초기 로그인: 최소 권한만 요청
getInitialAuthUrl(): string {
const scopes: OAuthScope[] = ['openid', 'profile', 'email'];
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
response_type: 'code',
scope: scopes.join(' '),
// prompt: 'consent'는 프로덕션에서 제거 (매번 동의 요구 방지)
});
return `https://auth.provider.com/authorize?${params}`;
}
// 기능별 동적 권한 요청
async requestAdditionalScopes(request: ScopeRequest): Promise<boolean> {
// 이미 가지고 있는 scope 필터링
const neededScopes = request.required.filter(
scope => !this.grantedScopes.has(scope)
);
if (neededScopes.length === 0) {
return true; // 이미 모든 권한 보유
}
// 사용자에게 추가 권한 필요성 설명
const userConsent = await this.showConsentDialog(neededScopes);
if (!userConsent) {
return false;
}
// Incremental Authorization (Google 스타일)
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
response_type: 'code',
scope: neededScopes.join(' '),
include_granted_scopes: 'true', // 기존 권한 유지
prompt: 'consent', // 새 권한은 명시적 동의 필요
state: this.generateState()
});
// 팝업으로 권한 요청 (페이지 이탈 방지)
const authWindow = window.open(
`https://auth.provider.com/authorize?${params}`,
'oauth-popup',
'width=500,height=600'
);
return this.waitForAuthCompletion(authWindow);
}
// Scope 검증: API 호출 전 권한 확인
async ensureScopes(scopes: OAuthScope[]): Promise<void> {
const missingScopes = scopes.filter(s => !this.grantedScopes.has(s));
if (missingScopes.length > 0) {
const granted = await this.requestAdditionalScopes({
required: missingScopes
});
if (!granted) {
throw new Error(`Missing required scopes: ${missingScopes.join(', ')}`);
}
}
}
// 토큰 응답에서 granted scopes 추출
updateGrantedScopes(tokenResponse: any): void {
const scopeString = tokenResponse.scope || '';
const scopes = scopeString.split(' ') as OAuthScope[];
scopes.forEach(scope => this.grantedScopes.add(scope));
}
// 권한 다운그레이드: 불필요한 권한 제거
async revokeScopes(scopes: OAuthScope[]): Promise<void> {
// 특정 scope만 취소하는 API (제공자별로 다름)
await fetch('https://auth.provider.com/revoke-scopes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scopes })
});
scopes.forEach(scope => this.grantedScopes.delete(scope));
}
private async showConsentDialog(scopes: OAuthScope[]): Promise<boolean> {
// 사용자 친화적 설명과 함께 동의 요청
const messages = {
'read:calendar': '일정을 읽어 알림을 표시하기 위해',
'write:calendar': '일정을 생성하고 수정하기 위해',
'read:contacts': '연락처를 검색하고 표시하기 위해',
'write:contacts': '새 연락처를 추가하기 위해'
};
const reasons = scopes.map(s => messages[s] || s).join('\n');
return confirm(`다음 권한이 필요합니다:\n\n${reasons}\n\n허용하시겠습니까?`);
}
private generateState(): string {
return crypto.randomUUID();
}
private async waitForAuthCompletion(authWindow: Window | null): Promise<boolean> {
// 팝업 완료 대기 로직 (postMessage 사용)
return new Promise((resolve) => {
const handleMessage = (event: MessageEvent) => {
if (event.data.type === 'oauth-success') {
window.removeEventListener('message', handleMessage);
resolve(true);
} else if (event.data.type === 'oauth-error') {
window.removeEventListener('message', handleMessage);
resolve(false);
}
};
window.addEventListener('message', handleMessage);
});
}
}
// 사용 예시
const scopeManager = new ScopeManager();
// 초기 로그인: 최소 권한
window.location.href = scopeManager.getInitialAuthUrl();
// 캘린더 기능 사용 시: 동적 권한 요청
async function shareCalendarEvent() {
await scopeManager.ensureScopes(['read:calendar', 'write:calendar']);
// 이제 캘린더 API 호출 가능
}
### 설명
이 코드는 OAuth scope를 최소 권한 원칙에 따라 관리하는 고급 패턴을 구현합니다. 많은 앱이 초기 로그인 시 모든 권한을 한꺼번에 요청하는데, 이는 사용자에게 불신을 주고 전환율을 낮춥니다. getInitialAuthUrl()은 애플리케이션의 핵심 기능에 필요한 최소 권한만 요청합니다. 대부분의 앱에서 openid(사용자 식별), profile(이름, 프로필 사진), email(이메일 주소)만 있으면 기본적인 사용자 경험을 제공할 수 있습니다. 캘린더나 연락처 같은 민감한 권한은 나중에 실제로 필요할 때 요청합니다. requestAdditionalScopes()는 Incremental Authorization(점진적 권한 부여) 패턴을 구현합니다. 사용자가 "캘린더에 일정 추가" 버튼을 클릭했을 때 비로소 write:calendar 권한을 요청합니다. include_granted_scopes: 'true' 파라미터는 기존 토큰의 권한을 유지하면서 새 권한을 추가하도록 합니다. 이는 Google OAuth에서 지원하는 기능이며, 다른 제공자는 다른 메커니즘을 사용할 수 있습니다. 팝업 윈도우를 사용하는 이유는 사용자가 현재 작업 중인 페이지를 이탈하지 않도록 하기 위함입니다. 만약 전체 페이지 리다이렉트를 사용하면 사용자가 작성 중이던 폼 데이터가 손실될 수 있습니다. 팝업이 닫히면 postMessage API를 통해 메인 윈도우에 결과를 전달합니다. ensureScopes()는 방어적 프로그래밍 패턴입니다. API 호출 전에 필요한 scope를 명시적으로 선언하고, 없으면 자동으로 요청합니다. 이렇게 하면 "권한 부족" 에러를 받은 후 처리하는 것보다 사용자 경험이 훨씬 좋습니다. 예를 들어, shareCalendarEvent() 함수는 실행 전에 필요한 권한이 있는지 확인하고, 없으면 사용자에게 요청합니다. updateGrantedScopes()는 토큰 응답의 scope 필드에서 실제로 부여받은 권한을 파싱합니다. 중요한 점은 요청한 scope와 부여받은 scope가 다를 수 있다는 것입니다. 사용자가 일부 권한을 거부하거나, 관리자 정책으로 특정 권한이 제한될 수 있습니다. 따라서 항상 응답에서 실제 권한을 확인해야 합니다. revokeScopes()는 권한 다운그레이드를 구현합니다. 사용자가 "캘린더 연동 해제" 설정을 선택하면 해당 권한만 취소합니다. 이는 사용자에게 제어권을 주어 신뢰를 높이며, GDPR 같은 개인정보 보호 규정 준수에도 도움이 됩니다. showConsentDialog()는 기술적 scope 이름(read:calendar)을 사용자 친화적 설명으로 변환합니다. "이 권한이 왜 필요한지" 명확히 설명하면 사용자가 권한 요청을 승인할 확률이 높아집니다. OAuth 제공자의 동의 화면에 표시되는 메시지를 커스터마이즈할 수 있다면 활용하세요. 실무에서 이 패턴은 전환율을 크게 향상시킵니다. Google의 연구에 따르면 초기 로그인 시 최소 권한만 요청하면 전환율이 20-30% 증가한다고 합니다. 또한 사용자가 앱을 더 신뢰하게 되어 장기적 유지율도 향상됩니다. Slack, Notion, Trello 같은 주요 SaaS 앱들이 모두 이 패턴을 사용합니다.
---
## 7. ID_Token_검증_및_보안
### 개요
OpenID Connect의 ID Token은 사용자 인증 정보를 담고 있는 JWT입니다. 단순히 디코딩하는 것이 아니라 서명 검증, issuer/audience 확인, 만료 시간 체크, nonce 검증 등 필수 보안 검사를 수행해야 합니다. 이를 통해 토큰 위조 공격을 방어할 수 있습니다.
### 코드 예제
```typescript
```typescript
import { jwtVerify, createRemoteJWKSet, JWTPayload } from 'jose';
interface IDTokenClaims extends JWTPayload {
iss: string; // Issuer (발급자)
sub: string; // Subject (사용자 ID)
aud: string; // Audience (클라이언트 ID)
exp: number; // Expiration time
iat: number; // Issued at
nonce?: string; // CSRF 방어용
email?: string;
email_verified?: boolean;
name?: string;
picture?: string;
}
class IDTokenValidator {
private jwksUrl: string;
private issuer: string;
private clientId: string;
private jwks: ReturnType<typeof createRemoteJWKSet>;
constructor(config: { jwksUrl: string; issuer: string; clientId: string }) {
this.jwksUrl = config.jwksUrl;
this.issuer = config.issuer;
this.clientId = config.clientId;
// JWKS (JSON Web Key Set) 엔드포인트에서 공개키 자동 로드
this.jwks = createRemoteJWKSet(new URL(this.jwksUrl));
}
// ID Token 검증 (모든 보안 체크 수행)
async validateIDToken(idToken: string, expectedNonce?: string): Promise<IDTokenClaims> {
try {
// 1. 서명 검증 + 기본 클레임 체크
const { payload } = await jwtVerify(idToken, this.jwks, {
issuer: this.issuer, // iss 클레임 검증
audience: this.clientId, // aud 클레임 검증
clockTolerance: 60 // 시간 차이 60초 허용
});
const claims = payload as IDTokenClaims;
// 2. Nonce 검증 (CSRF 및 재생 공격 방어)
if (expectedNonce) {
if (!claims.nonce) {
throw new Error('ID Token missing nonce claim');
}
if (claims.nonce !== expectedNonce) {
throw new Error('Nonce mismatch (possible replay attack)');
}
}
// 3. 발급 시간 검증 (너무 오래된 토큰 거부)
const now = Math.floor(Date.now() / 1000);
const tokenAge = now - claims.iat;
if (tokenAge > 3600) { // 1시간 이상 된 토큰
throw new Error('ID Token too old');
}
// 4. Subject 존재 확인
if (!claims.sub) {
throw new Error('ID Token missing sub claim');
}
// 5. 이메일 검증 상태 확인 (선택적)
if (claims.email && !claims.email_verified) {
console.warn('User email not verified:', claims.email);
}
return claims;
} catch (error) {
console.error('ID Token validation failed:', error);
throw new Error(`Invalid ID Token: ${error.message}`);
}
}
// Access Token을 사용하지 않고 ID Token에서 직접 사용자 정보 추출
async getUserInfo(idToken: string, nonce?: string): Promise<{
userId: string;
email: string;
name: string;
picture?: string;
emailVerified: boolean;
}> {
const claims = await this.validateIDToken(idToken, nonce);
return {
userId: claims.sub,
email: claims.email || '',
name: claims.name || 'Unknown',
picture: claims.picture,
emailVerified: claims.email_verified || false
};
}
// at_hash 검증 (Access Token 무결성 확인)
async validateAccessTokenHash(
idToken: string,
accessToken: string
): Promise<boolean> {
const claims = await this.validateIDToken(idToken);
if (!claims.at_hash) {
return true; // at_hash가 없으면 검증 불필요
}
// at_hash는 access_token의 SHA256 해시의 왼쪽 절반
const encoder = new TextEncoder();
const data = encoder.encode(accessToken);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = new Uint8Array(hashBuffer);
const leftHalf = hashArray.slice(0, hashArray.length / 2);
// Base64URL 인코딩
const base64url = btoa(String.fromCharCode(...leftHalf))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return base64url === claims.at_hash;
}
}
// 사용 예시
const validator = new IDTokenValidator({
jwksUrl: 'https://auth.provider.com/.well-known/jwks.json',
issuer: 'https://auth.provider.com',
clientId: 'your_client_id'
});
// OAuth 콜백 처리
async function handleCallback(code: string, nonce: string) {
// 1. 토큰 교환
const tokens = await exchangeCodeForTokens(code);
// 2. ID Token 검증
const userInfo = await validator.getUserInfo(tokens.id_token, nonce);
// 3. Access Token 무결성 확인
const isValid = await validator.validateAccessTokenHash(
tokens.id_token,
tokens.access_token
);
if (!isValid) {
throw new Error('Access Token integrity check failed');
}
// 4. 사용자 정보로 세션 생성
console.log('Authenticated user:', userInfo);
}
async function exchangeCodeForTokens(code: string): Promise<any> {
// 토큰 교환 로직
}
### 설명
이 코드는 OpenID Connect ID Token의 완전한 보안 검증 프로세스를 구현합니다. 많은 개발자가 jwt.decode()로 단순히 디코딩만 하는데, 이는 치명적인 보안 취약점입니다. ID Token은 반드시 서명 검증을 거쳐야 합니다. 첫 번째 핵심은 JWKS(JSON Web Key Set)를 사용한 자동 서명 검증입니다. OAuth 제공자는 /.well-known/jwks.json 엔드포인트에 공개키를 게시하며, jose 라이브러리의 createRemoteJWKSet()은 이 키를 자동으로 가져와 캐싱합니다. jwtVerify()는 ID Token의 서명을 이 공개키로 검증하여, 토큰이 실제로 해당 제공자가 발급했고 중간에 변조되지 않았음을 보장합니다. issuer와 audience 검증은 토큰 치환 공격을 방어합니다. 공격자가 다른 앱의 유효한 ID Token을 훔쳐 우리 앱에 사용하려 해도, aud(audience) 클레임이 우리 clientId와 일치하지 않아 거부됩니다. 마찬가지로 iss(issuer)를 검증하여 신뢰하지 않는 제공자의 토큰을 거부합니다. nonce 검증은 ID Token 재생 공격(replay attack)을 방어합니다. 인증 요청 시 생성한 랜덤 nonce를 ID Token의 nonce 클레임과 비교하여, 이전에 사용된 토큰이 재사용되는 것을 방지합니다. 공격자가 네트워크 트래픽을 스니핑하여 ID Token을 얻더라도, nonce가 일치하지 않아 사용할 수 없습니다. 토큰 나이(token age) 검증은 OpenID Connect 스펙에는 없지만 실무에서 중요한 방어 계층입니다. iat(issued at) 클레임을 확인하여 1시간 이상 된 토큰은 거부합니다. 이는 토큰이 오랜 시간 동안 어딘가에 저장되었다가 사용되는 시나리오를 방어합니다. at_hash(Access Token hash) 검증은 ID Token과 Access Token이 함께 발급되었음을 보장합니다. 공격자가 한 세션의 ID Token과 다른 세션의 Access Token을 섞어 사용하려는 공격을 방어합니다. Access Token의 SHA256 해시의 왼쪽 절반을 Base64URL 인코딩한 값이 at_hash와 일치해야 합니다. clockTolerance: 60은 서버 간 시간 차이를 허용합니다. 클라이언트와 OAuth 제공자의 시계가 정확히 일치하지 않을 수 있으므로, exp(만료 시간) 검증 시 60초의 여유를 줍니다. 프로덕션 환경에서는 NTP로 시계를 동기화하는 것이 좋습니다. email_verified 필드는 이메일 주소가 실제로 사용자의 것인지 확인되었는지를 나타냅니다. 중요한 작업(예: 비밀번호 재설정 이메일 발송)을 수행하기 전에 이 필드를 확인해야 합니다. 일부 OAuth 제공자는 이메일 검증 없이 계정을 생성할 수 있기 때문입니다. 실무에서 ID Token 검증 누락은 매우 흔한 보안 취약점입니다. OWASP Top 10의 "Broken Authentication"에 해당하며, 공격자가 임의의 사용자로 로그인할 수 있게 됩니다. 반드시 프로덕션 환경에서는 완전한 검증을 수행해야 하며, jose, jsonwebtoken(Node.js), PyJWT(Python) 같은 검증된 라이브러리를 사용하세요. 절대 직접 JWT 파싱을 구현하지 마세요.
---
## 8. Token_Introspection_활성_검증
### 개요
액세스 토큰의 서명 검증만으로는 토큰이 실제로 유효한지 알 수 없습니다. 토큰이 취소(revoke)되었거나 사용자가 로그아웃했을 수 있습니다. Token Introspection은 OAuth 서버에 실시간으로 토큰 상태를 확인하여 취소된 토큰을 거부합니다.
### 코드 예제
```typescript
```typescript
interface IntrospectionResponse {
active: boolean;
scope?: string;
client_id?: string;
username?: string;
token_type?: string;
exp?: number;
iat?: number;
sub?: string;
}
class TokenIntrospector {
private introspectionUrl: string;
private clientId: string;
private clientSecret: string;
private cache = new Map<string, { active: boolean; expiresAt: number }>();
private readonly CACHE_TTL = 60 * 1000; // 1분 캐싱
constructor(config: {
introspectionUrl: string;
clientId: string;
clientSecret: string;
}) {
this.introspectionUrl = config.introspectionUrl;
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
}
// 토큰 활성 상태 확인 (RFC 7662)
async introspectToken(token: string): Promise<IntrospectionResponse> {
// 1. 캐시 확인 (성능 최적화)
const cached = this.cache.get(token);
if (cached && Date.now() < cached.expiresAt) {
return { active: cached.active };
}
// 2. OAuth 서버에 introspection 요청
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
const response = await fetch(this.introspectionUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${credentials}`
},
body: new URLSearchParams({
token: token,
token_type_hint: 'access_token' // 성능 최적화
})
});
if (!response.ok) {
throw new Error(`Introspection failed: ${response.status}`);
}
const result: IntrospectionResponse = await response.json();
// 3. 결과 캐싱 (active=false는 더 오래 캐싱)
const cacheDuration = result.active ? this.CACHE_TTL : this.CACHE_TTL * 5;
this.cache.set(token, {
active: result.active,
expiresAt: Date.now() + cacheDuration
});
return result;
}
// Express 미들웨어: 토큰 검증
createMiddleware() {
return async (req: any, res: any, next: any) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.substring(7);
try {
const introspection = await this.introspectToken(token);
if (!introspection.active) {
return res.status(401).json({
error: 'Token is not active (revoked or expired)'
});
}
// 토큰 정보를 request에 추가
req.auth = {
sub: introspection.sub,
scope: introspection.scope,
clientId: introspection.client_id
};
next();
} catch (error) {
console.error('Token introspection error:', error);
return res.status(500).json({ error: 'Token validation failed' });
}
};
}
// Scope 기반 권한 검사 미들웨어
requireScopes(...requiredScopes: string[]) {
return (req: any, res: any, next: any) => {
const tokenScopes = (req.auth?.scope || '').split(' ');
const hasAllScopes = requiredScopes.every(required =>
tokenScopes.includes(required)
);
if (!hasAllScopes) {
return res.status(403).json({
error: 'Insufficient permissions',
required: requiredScopes,
granted: tokenScopes
});
}
next();
};
}
// 주기적 캐시 정리
startCacheCleanup(): void {
setInterval(() => {
const now = Date.now();
for (const [token, cached] of this.cache.entries()) {
if (now >= cached.expiresAt) {
this.cache.delete(token);
}
}
}, 5 * 60 * 1000); // 5분마다 정리
}
}
// 사용 예시 (Express 백엔드)
import express from 'express';
const app = express();
const introspector = new TokenIntrospector({
introspectionUrl: 'https://auth.provider.com/oauth/introspect',
clientId: 'your_client_id',
clientSecret: 'your_client_secret'
});
introspector.startCacheCleanup();
// 모든 API에 토큰 검증 적용
app.use('/api', introspector.createMiddleware());
// 특정 권한 필요한 엔드포인트
app.get(
'/api/admin/users',
introspector.requireScopes('admin:read'),
(req, res) => {
res.json({ users: [] });
}
);
app.post(
'/api/calendar/events',
introspector.requireScopes('write:calendar'),
(req, res) => {
// req.auth에 사용자 정보 포함됨
console.log('User ID:', req.auth.sub);
res.json({ success: true });
}
);
### 설명
이 코드는 RFC 7662 Token Introspection 표준을 구현하여 액세스 토큰의 실시간 유효성을 검증합니다. JWT 기반 토큰의 가장 큰 문제는 발급 후 취소할 수 없다는 것입니다(Stateless의 대가). Introspection은 이 문제를 해결합니다. 핵심 개념은 "토큰의 서명이 유효하다 ≠ 토큰을 사용할 수 있다"입니다. 사용자가 "모든 기기에서 로그아웃" 버튼을 누르거나, 관리자가 계정을 정지시키면 발급된 액세스 토큰은 즉시 무효화되어야 합니다. 하지만 JWT는 자체 서명으로 검증되므로 서버에 물어보지 않는 한 취소 여부를 알 수 없습니다. introspectToken()은 OAuth 서버의 /oauth/introspect 엔드포인트를 호출합니다. 이 엔드포인트는 client credentials(클라이언트 ID와 시크릿)로 인증되며, 토큰의 현재 상태를 반환합니다. active: true면 사용 가능, false면 취소되었거나 만료된 것입니다. 성능 최적화를 위해 1분간 캐싱을 사용합니다. 모든 API 요청마다 OAuth 서버에 네트워크 요청을 보내면 지연 시간이 크게 증가하고 서버 부하도 높아집니다. 1분 캐싱은 보안과 성능 사이의 합리적인 균형입니다. active=false 결과는 5분간 캐싱하여, 취소된 토큰에 대한 반복 요청을 줄입니다. token_type_hint: 'access_token'은 서버가 어떤 종류의 토큰인지 추측할 필요 없도록 힌트를 제공합니다. OAuth 서버는 액세스 토큰과 리프레시 토큰을 다른 데이터베이스 테이블에 저장할 수 있으므로, 힌트를 제공하면 조회 속도가 빨라집니다. createMiddleware()는 Express 미들웨어로 자동 토큰 검증을 구현합니다. Authorization 헤더에서 Bearer 토큰을 추출하고, introspection으로 검증한 후, 결과를 req.auth에 저장합니다. 이후 라우트 핸들러에서 req.auth.sub로 사용자 ID에 접근할 수 있습니다. requireScopes() 미들웨어는 세밀한 권한 제어를 구현합니다. 토큰이 유효하더라도 필요한 scope가 없으면 403 Forbidden을 반환합니다. 예를 들어 /api/admin/users는 admin:read scope가 필요하며, 일반 사용자 토큰으로는 접근할 수 없습니다. 캐시 정리는 메모리 누수를 방지합니다. 장기 실행 서버에서 캐시를 정리하지 않으면 메모리가 계속 증가합니다. 5분마다 만료된 항목을 삭제하여 메모리를 회수합니다. 프로덕션 환경에서는 Redis 같은 외부 캐시를 사용하는 것이 좋습니다. 실무에서 introspection은 마이크로서비스 아키텍처에서 특히 중요합니다. API Gateway에서 introspection으로 토큰을 검증한 후, 백엔드 서비스에는 검증된 사용자 정보만 전달합니다. 이렇게 하면 각 서비스가 OAuth 서버에 직접 접근할 필요가 없어져 결합도가 낮아집니다. Opaque Token(불투명 토큰) 방식을 사용하는 OAuth 서버(예: OAuth 2.0 기본 구현)에서는 introspection이 필수입니다. Opaque Token은 JWT가 아닌 랜덤 문자열이므로 서버에 문의하지 않고는 아무 정보도 얻을 수 없습니다. Auth0의 Opaque Access Token이나 Keycloak의 기본 토큰이 이에 해당합니다. 보안과 성능 트레이드오프를 고려하여 캐싱 전략을 조정하세요. 금융 앱처럼 높은 보안이 필요하면 캐싱을 10초로 줄이고, 소셜 미디어 앱처럼 성능이 중요하면 5분으로 늘릴 수 있습니다.
---
## 마치며
이번 글에서는 OAuth 트러블슈팅 가이드 고급 개발자용에 대해 알아보았습니다.
총 8가지 개념을 다루었으며, 각각의 사용법과 예제를 살펴보았습니다.
### 관련 태그
#OAuth #PKCE #TokenRefresh #SecurityBestPractices #AuthenticationFlow
#OAuth#PKCE#TokenRefresh#SecurityBestPractices#AuthenticationFlow#JavaScript
댓글 (0)
댓글을 작성하려면 로그인이 필요합니다.