본문으로 건너뛰기

🔐 OAuth 2.0이란?

📖 정의

OAuth 2.0은 사용자가 비밀번호를 공유하지 않고도 다른 웹사이트나 애플리케이션에 자신의 정보 접근 권한을 부여할 수 있는 개방형 표준 프로토콜입니다. "Google로 로그인", "GitHub로 로그인" 같은 소셜 로그인이 대표적인 예시입니다. 인증(Authentication)이 아닌 권한 부여(Authorization)에 초점을 맞추며, 안전하고 편리한 사용자 경험을 제공합니다.

🎯 비유로 이해하기

호텔 키카드

전통적인 방식 = 호텔 마스터키
호텔 주인: "마스터키 드릴게요"
당신: "이건 모든 방을 열 수 있잖아요! 😱"
"제 비밀번호를 알려주는 것과 같아요"

OAuth 2.0 = 호텔 키카드
호텔 주인: "이 키카드는..."
✅ 당신의 방만 열 수 있어요
✅ 체크아웃 시 자동 만료돼요
✅ 분실해도 마스터키는 안전해요
✅ 필요하면 언제든 무효화할 수 있어요

당신: "완벽해요!"

대리인 위임장

시나리오: 은행 업무를 대리인에게 맡기기

❌ 비밀번호 공유 방식
당신: "제 은행 비밀번호는 1234입니다"
대리인: "은행에 가서 모든 걸 할 수 있겠네요!"
위험: 예금 인출, 계좌 삭제 등 모든 권한

✅ OAuth 방식
당신: "은행에 가서 위임장을 작성합니다"
은행: "어떤 권한을 주실 건가요?"
당신: "잔액 조회만 가능하도록 해주세요"
은행: "기간은요?"
당신: "1주일만요"

대리인은:
✅ 잔액만 조회 가능
✅ 1주일 후 자동 만료
✅ 당신의 비밀번호 모름
✅ 언제든 권한 취소 가능

⚙️ 작동 원리

1. OAuth 2.0 주요 개념

┌─────────────────────────────────────────┐
│ OAuth 2.0 4가지 역할 │
└─────────────────────────────────────────┘

1. Resource Owner (리소스 소유자)
└─ 사용자 (당신)

2. Client (클라이언트)
└─ 서비스를 사용하려는 애플리케이션
└─ 예: 당신이 만든 웹사이트

3. Authorization Server (인증 서버)
└─ 권한을 부여하는 서버
└─ 예: Google, GitHub, Naver

4. Resource Server (리소스 서버)
└─ 사용자의 정보를 가지고 있는 서버
└─ 예: Google API, GitHub API

2. OAuth 2.0 인증 흐름 (Authorization Code)

┌──────────┐                                 ┌──────────┐
│ 사용자 │ │ 클라이언트│
│ (당신) │ │ (웹앱) │
└─────┬────┘ └────┬─────┘
│ │
│ 1. "Google로 로그인" 클릭 │
│──────────────────────────────────────────>│
│ │
│ 2. Google 로그인 페이지로 리다이렉트 │
│<──────────────────────────────────────────│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ 3. 로그인│ (인증 서버) │ │
│────────>│ │ │
│ │ │ │
│ 4. 권한 요청 │
│ "이 앱이 당신의 이메일과 │
│ 프로필을 보려고 합니다" │
│<────────│ │ │
│ │ │ │
│ 5. 승인 │ │ │
│────────>│ │ │
│ │ │ │
│ │ 6. Authorization Code 발급 │
│ │ (일회용 코드) │
│<────────│ │ │
│ └──────────────────┘ │
│ │
│ 7. Authorization Code 전달 │
│──────────────────────────────────────────>│
│ │
│ ┌──────────────────┐ │
│ │ Google │ │
│ │ (인증 서버) │ 8. Code와 │
│ │ │<─── Client │
│ │ │ Secret │
│ │ │ 교환 │
│ │ │ │
│ │ 9. Access Token │ ────────────>│
│ │ 발급 │ │
│ └──────────────────┘ │
│ │
│ ┌──────────────────┐ │
│ │ Google API │ │
│ │ (리소스 서버) │ 10. Token으로│
│ │ │<──── 사용자 │
│ │ │ 정보 요청│
│ │ │ │
│ │ 11. 사용자 정보 │ ────────────>│
│ │ 응답 │ │
│ └──────────────────┘ │
│ │
│ 12. 로그인 완료! │
│<──────────────────────────────────────────│
│ │

핵심:
- 사용자 비밀번호는 Google에만 입력
- 웹앱은 비밀번호를 절대 알 수 없음
- Access Token으로만 권한 있는 정보 접근

3. OAuth 2.0 Grant Types

========== 1. Authorization Code (가장 안전) ==========
사용: 서버 사이드 웹 애플리케이션
장점: 가장 안전 (Client Secret 사용)
예시: "Google로 로그인"

흐름:
1. 사용자 → Google 로그인
2. Google → Authorization Code 발급
3. 서버 → Code + Secret으로 Access Token 교환
4. 서버 → Token으로 API 호출

========== 2. Implicit (단순하지만 덜 안전) ==========
사용: 브라우저 전용 앱 (SPA)
장점: 간단 (서버 불필요)
단점: 보안 취약 (Token이 URL에 노출)
예시: 구식 SPA

흐름:
1. 사용자 → Google 로그인
2. Google → Access Token 직접 발급 (URL에)
3. 브라우저 → Token으로 API 호출
⚠️ 현재는 사용 권장 안 함

========== 3. Resource Owner Password (레거시) ==========
사용: 신뢰할 수 있는 앱만
장점: 간단
단점: 사용자 비밀번호를 앱이 알게 됨
예시: 자사 모바일 앱

흐름:
1. 앱 → 사용자 ID/PW 받음
2. 앱 → Authorization Server에 전달
3. Authorization Server → Access Token 발급
⚠️ OAuth 철학에 반함, 비권장

========== 4. Client Credentials ==========
사용: 서버 간 통신 (M2M)
장점: 사용자 인증 불필요
예시: 백엔드 서비스 간 통신

흐름:
1. 서버 → Client ID + Secret 전달
2. Authorization Server → Access Token 발급
3. 서버 → Token으로 API 호출

========== 5. PKCE (Proof Key for Code Exchange) ==========
사용: 모바일 앱, SPA (현재 권장)
장점: Authorization Code + 추가 보안
예시: 현대적인 모바일/SPA 앱

흐름:
1. 앱 → code_verifier 생성 (랜덤 문자열)
2. 앱 → code_challenge 생성 (verifier 해시)
3. 앱 → challenge와 함께 Authorization Code 요청
4. Google → Code 발급
5. 앱 → Code + verifier로 Token 교환
6. Google → verifier 검증 후 Token 발급

💡 실제 예시

Google OAuth 2.0 로그인 (Node.js)

// ========== 1. Google Cloud Console 설정 ==========
/*
1. https://console.cloud.google.com 접속
2. 프로젝트 생성
3. "API 및 서비스" → "사용자 인증 정보"
4. "OAuth 2.0 클라이언트 ID" 생성
5. 승인된 리디렉션 URI 추가:
http://localhost:3000/auth/google/callback
6. Client ID와 Client Secret 받기
*/

// ========== 2. 서버 구현 ==========
const express = require('express');
const axios = require('axios');
const session = require('express-session');

const app = express();

// 세션 설정
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: true
}));

// Google OAuth 설정
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/google/callback';

// ========== Step 1: 로그인 시작 ==========
app.get('/auth/google', (req, res) => {
// Google 인증 URL 생성
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';

const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code', // Authorization Code 요청
scope: 'openid email profile', // 요청할 권한
access_type: 'offline', // Refresh Token 받기
prompt: 'consent' // 항상 동의 화면 표시
});

// Google 로그인 페이지로 리다이렉트
res.redirect(`${authUrl}?${params}`);
});

// ========== Step 2: 콜백 처리 ==========
app.get('/auth/google/callback', async (req, res) => {
const { code, error } = req.query;

// 사용자가 거부한 경우
if (error) {
return res.status(400).send(`인증 실패: ${error}`);
}

try {
// Authorization Code를 Access Token으로 교환
const tokenResponse = await axios.post(
'https://oauth2.googleapis.com/token',
{
code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code'
}
);

const {
access_token,
refresh_token,
expires_in,
token_type
} = tokenResponse.data;

console.log('Access Token:', access_token);
console.log('Expires in:', expires_in, 'seconds');

// Access Token으로 사용자 정보 가져오기
const userResponse = await axios.get(
'https://www.googleapis.com/oauth2/v2/userinfo',
{
headers: {
Authorization: `Bearer ${access_token}`
}
}
);

const user = userResponse.data;
/*
{
id: "123456789",
email: "user@gmail.com",
verified_email: true,
name: "홍길동",
picture: "https://lh3.googleusercontent.com/...",
locale: "ko"
}
*/

// 세션에 사용자 정보 저장
req.session.user = {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
accessToken: access_token,
refreshToken: refresh_token
};

// 데이터베이스에 사용자 저장
await saveOrUpdateUser(user);

// 홈 페이지로 리다이렉트
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth 에러:', error.response?.data || error.message);
res.status(500).send('인증 처리 중 오류 발생');
}
});

// ========== 사용자 정보 확인 ==========
app.get('/dashboard', (req, res) => {
if (!req.session.user) {
return res.redirect('/');
}

res.send(`
<h1>안녕하세요, ${req.session.user.name}님!</h1>
<img src="${req.session.user.picture}" alt="프로필" />
<p>이메일: ${req.session.user.email}</p>
<a href="/logout">로그아웃</a>
`);
});

// ========== 로그아웃 ==========
app.get('/logout', async (req, res) => {
// Google Access Token 취소 (선택사항)
const accessToken = req.session.user?.accessToken;
if (accessToken) {
try {
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);
} catch (error) {
console.error('Token 취소 실패:', error.message);
}
}

// 세션 삭제
req.session.destroy();
res.redirect('/');
});

// ========== 홈 페이지 ==========
app.get('/', (req, res) => {
res.send(`
<h1>OAuth 2.0 데모</h1>
<a href="/auth/google">
<button>Google로 로그인</button>
</a>
`);
});

app.listen(3000, () => {
console.log('서버 실행: http://localhost:3000');
});

Passport.js로 간편하게 구현

// ========== Passport.js 사용 ==========
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// Passport 설정
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback'
},
async (accessToken, refreshToken, profile, done) => {
// 사용자 정보 처리
try {
// 데이터베이스에서 사용자 찾기 또는 생성
let user = await User.findOne({ googleId: profile.id });

if (!user) {
user = await User.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
picture: profile.photos[0].value
});
}

done(null, user);
} catch (error) {
done(error, null);
}
}
));

// 세션 직렬화
passport.serializeUser((user, done) => {
done(null, user.id);
});

passport.deserializeUser(async (id, done) => {
const user = await User.findById(id);
done(null, user);
});

// Express 설정
app.use(passport.initialize());
app.use(passport.session());

// ========== 라우트 ==========

// Google 로그인 시작
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email']
})
);

// Google 콜백
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login',
successRedirect: '/dashboard'
})
);

// 로그아웃
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) console.error(err);
res.redirect('/');
});
});

// 인증 미들웨어
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}

// 보호된 라우트
app.get('/dashboard', isAuthenticated, (req, res) => {
res.send(`안녕하세요, ${req.user.name}님!`);
});

GitHub OAuth 2.0

// ========== GitHub OAuth 설정 ==========
/*
1. https://github.com/settings/developers 접속
2. "New OAuth App" 클릭
3. 정보 입력:
- Application name: My App
- Homepage URL: http://localhost:3000
- Authorization callback URL: http://localhost:3000/auth/github/callback
4. Client ID와 Client Secret 받기
*/

const express = require('express');
const axios = require('axios');
const app = express();

const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/auth/github/callback';

// ========== GitHub 로그인 ==========
app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';

const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email', // 요청할 권한
state: Math.random().toString(36) // CSRF 방지
});

res.redirect(`${authUrl}?${params}`);
});

// ========== 콜백 처리 ==========
app.get('/auth/github/callback', async (req, res) => {
const { code, state } = req.query;

try {
// 1. Access Token 받기
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI
},
{
headers: { Accept: 'application/json' }
}
);

const { access_token } = tokenResponse.data;

// 2. 사용자 정보 가져오기
const userResponse = await axios.get('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`
}
});

const user = userResponse.data;
/*
{
login: "username",
id: 12345,
avatar_url: "https://avatars.githubusercontent.com/...",
name: "홍길동",
email: "user@example.com",
bio: "개발자입니다",
public_repos: 50
}
*/

// 3. 이메일 가져오기 (별도 요청)
const emailResponse = await axios.get('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${access_token}`
}
});

const primaryEmail = emailResponse.data.find(email => email.primary);

res.json({
user,
email: primaryEmail.email
});
} catch (error) {
console.error('GitHub OAuth 에러:', error.message);
res.status(500).send('인증 실패');
}
});

app.listen(3000);

React에서 OAuth 사용

// ========== React 클라이언트 ==========

import React, { useEffect, useState } from 'react';
import axios from 'axios';

function App() {
const [user, setUser] = useState(null);

// 페이지 로드 시 사용자 정보 확인
useEffect(() => {
checkAuth();
}, []);

async function checkAuth() {
try {
const response = await axios.get('/api/me', {
withCredentials: true // 쿠키 포함
});
setUser(response.data);
} catch (error) {
console.log('로그인하지 않음');
}
}

function handleGoogleLogin() {
// 서버의 OAuth 엔드포인트로 이동
window.location.href = 'http://localhost:3000/auth/google';
}

function handleGitHubLogin() {
window.location.href = 'http://localhost:3000/auth/github';
}

function handleLogout() {
window.location.href = 'http://localhost:3000/logout';
}

if (user) {
return (
<div>
<h1>안녕하세요, {user.name}!</h1>
<img src={user.picture} alt="프로필" />
<p>{user.email}</p>
<button onClick={handleLogout}>로그아웃</button>
</div>
);
}

return (
<div>
<h1>로그인</h1>
<button onClick={handleGoogleLogin}>
🔵 Google로 로그인
</button>
<button onClick={handleGitHubLogin}>
GitHub로 로그인
</button>
</div>
);
}

export default App;

PKCE로 보안 강화 (SPA/모바일)

// ========== PKCE (Proof Key for Code Exchange) ==========
// 모바일 앱이나 SPA에서 사용

const crypto = require('crypto');

// ========== 1. Code Verifier 생성 ==========
function generateCodeVerifier() {
return base64URLEncode(crypto.randomBytes(32));
}

// ========== 2. Code Challenge 생성 ==========
function generateCodeChallenge(verifier) {
const hash = crypto.createHash('sha256')
.update(verifier)
.digest();
return base64URLEncode(hash);
}

function base64URLEncode(buffer) {
return buffer.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}

// ========== 3. 로그인 시작 (클라이언트) ==========
async function startPKCELogin() {
// Verifier 생성 및 저장
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);

// Challenge 생성
const codeChallenge = generateCodeChallenge(codeVerifier);

// Google 인증 URL
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'openid email profile',
code_challenge: codeChallenge, // PKCE
code_challenge_method: 'S256' // SHA-256
});

window.location.href = `${authUrl}?${params}`;
}

// ========== 4. 콜백 처리 ==========
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');

// 저장된 Verifier 가져오기
const codeVerifier = sessionStorage.getItem('code_verifier');

// Token 교환
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // PKCE Verifier
})
});

const { access_token } = await response.json();

// Verifier 삭제
sessionStorage.removeItem('code_verifier');

return access_token;
}

🤔 자주 묻는 질문

Q1. OAuth vs JWT vs 세션?

A:

// ========== 1. 세션 (Session) ==========
// 전통적인 방식

// 로그인
app.post('/login', (req, res) => {
const { username, password } = req.body;

// 인증 확인
if (isValidUser(username, password)) {
// 세션에 저장
req.session.userId = user.id;
res.json({ message: '로그인 성공' });
}
});

// 인증 확인
app.get('/profile', (req, res) => {
if (!req.session.userId) {
return res.status(401).json({ error: '로그인 필요' });
}

const user = getUserById(req.session.userId);
res.json(user);
});

장점: 간단, 서버에서 완전 제어
단점: 서버 메모리/저장소 필요, 확장성 낮음

// ========== 2. JWT (JSON Web Token) ==========
// Stateless 인증

// 로그인
app.post('/login', (req, res) => {
const { username, password } = req.body;

if (isValidUser(username, password)) {
// JWT 생성
const token = jwt.sign(
{ userId: user.id, username: user.username },
SECRET_KEY,
{ expiresIn: '1h' }
);

res.json({ token });
}
});

// 인증 확인
app.get('/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, SECRET_KEY);
const user = getUserById(decoded.userId);
res.json(user);
} catch (error) {
res.status(401).json({ error: '유효하지 않은 토큰' });
}
});

장점: Stateless, 확장성 좋음
단점: 무효화 어려움, 크기 큼

// ========== 3. OAuth 2.0 ==========
// 제3자 인증 및 권한 부여

// 사용자가 Google에서 로그인
// → Google이 Access Token 발급
// → 우리 앱은 Token으로 Google API 호출

장점:
- 비밀번호 공유 안 함
- 권한 세밀하게 제어
- 표준 프로토콜
- 소셜 로그인 가능

단점:
- 복잡함
- 외부 서비스 의존

// ========== 비교 ==========
특성 | 세션 | JWT | OAuth
-----------|---------|----------|-------
저장 위치 | 서버 | 클라이언트| 서버
확장성 | 낮음 | 높음 | 높음
보안 | 높음 | 보통 | 높음
복잡도 | 낮음 | 보통 | 높음
3자 인증 | 불가 | 불가 | 가능

// ========== 조합 사용 ==========
// OAuth로 로그인 후 JWT 발급

app.get('/auth/google/callback', async (req, res) => {
// 1. Google OAuth로 사용자 인증
const googleUser = await getGoogleUser(req.query.code);

// 2. 우리 데이터베이스에 사용자 저장
const user = await saveOrUpdateUser(googleUser);

// 3. JWT 생성 및 반환
const token = jwt.sign(
{ userId: user.id, email: user.email },
SECRET_KEY,
{ expiresIn: '7d' }
);

res.json({ token });
});

// 이후 요청은 JWT로 인증
app.get('/api/profile', authenticateJWT, (req, res) => {
res.json(req.user);
});

Q2. Access Token과 Refresh Token의 차이는?

A:

// ========== Access Token ==========
// 짧은 수명 (15분~1시간)
// API 요청 시 사용

// ========== Refresh Token ==========
// 긴 수명 (7일~30일)
// Access Token 갱신 시 사용

// ========== 구현 예시 ==========

// 로그인 시 둘 다 발급
app.post('/login', async (req, res) => {
const user = await authenticateUser(req.body);

// Access Token (15분)
const accessToken = jwt.sign(
{ userId: user.id, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);

// Refresh Token (7일)
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: '7d' }
);

// Refresh Token은 DB에 저장 (무효화 위해)
await saveRefreshToken(user.id, refreshToken);

res.json({ accessToken, refreshToken });
});

// ========== API 요청 (Access Token) ==========
app.get('/api/data', authenticateAccess, (req, res) => {
res.json({ data: '중요한 데이터' });
});

function authenticateAccess(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, ACCESS_SECRET);

if (decoded.type !== 'access') {
throw new Error('잘못된 토큰 타입');
}

req.user = decoded;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
error: 'access_token_expired',
message: 'Refresh Token으로 갱신하세요'
});
}

res.status(401).json({ error: '유효하지 않은 토큰' });
}
}

// ========== Access Token 갱신 ==========
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;

if (!refreshToken) {
return res.status(401).json({ error: 'Refresh Token이 필요합니다' });
}

try {
// 1. Refresh Token 검증
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);

if (decoded.type !== 'refresh') {
throw new Error('잘못된 토큰 타입');
}

// 2. DB에서 확인 (무효화 여부)
const isValid = await isRefreshTokenValid(decoded.userId, refreshToken);
if (!isValid) {
throw new Error('무효화된 Refresh Token');
}

// 3. 새 Access Token 발급
const newAccessToken = jwt.sign(
{ userId: decoded.userId, type: 'access' },
ACCESS_SECRET,
{ expiresIn: '15m' }
);

res.json({ accessToken: newAccessToken });
} catch (error) {
res.status(401).json({ error: '유효하지 않은 Refresh Token' });
}
});

// ========== 프론트엔드 자동 갱신 ==========
import axios from 'axios';

let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');

// Axios 인터셉터
axios.interceptors.request.use(
config => {
config.headers.Authorization = `Bearer ${accessToken}`;
return config;
}
);

axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;

// Access Token 만료
if (error.response?.data?.error === 'access_token_expired' &&
!originalRequest._retry) {
originalRequest._retry = true;

try {
// Refresh Token으로 갱신
const response = await axios.post('/auth/refresh', {
refreshToken
});

accessToken = response.data.accessToken;
localStorage.setItem('accessToken', accessToken);

// 원래 요청 재시도
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// Refresh Token도 만료 → 로그아웃
localStorage.clear();
window.location.href = '/login';
}
}

return Promise.reject(error);
}
);

// ========== 보안 팁 ==========

// ✅ 좋은 예
1. Access Token: 짧은 수명 (15)
2. Refresh Token: DB에 저장하여 무효화 가능
3. Refresh Token은 HttpOnly 쿠키에 저장
4. HTTPS 필수
5. Refresh Token Rotation (재발급 시 새 토큰 발급)

// ❌ 나쁜 예
1. Access Token 수명 너무 길게
2. Refresh Token을 localStorage에 저장
3. Refresh Token 무효화 불가
4. HTTP 사용

Q3. Scope(권한 범위)란?

A:

// ========== Scope 개념 ==========
// 애플리케이션이 요청하는 권한의 범위

// ========== Google OAuth Scopes ==========
const scopes = [
'openid', // 기본 정보
'email', // 이메일
'profile', // 프로필 (이름, 사진)
'https://www.googleapis.com/auth/drive.readonly', // Drive 읽기
'https://www.googleapis.com/auth/gmail.send' // Gmail 전송
];

// 로그인 시 권한 요청
app.get('/auth/google', (req, res) => {
const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: scopes.join(' ') // 공백으로 구분
});

res.redirect(`${authUrl}?${params}`);
});

// 사용자에게 표시되는 화면:
/*
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
My App이 다음을 요청합니다:

✓ 이메일 주소 확인
✓ 기본 프로필 정보 확인
✓ Google Drive 파일 보기
✓ Gmail 이메일 전송

[허용] [거부]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
*/

// ========== GitHub OAuth Scopes ==========
const githubScopes = [
'user', // 사용자 정보
'user:email', // 이메일 (비공개 포함)
'repo', // 저장소 전체 접근
'public_repo', // 공개 저장소만
'read:org', // 조직 정보 읽기
'write:org' // 조직 정보 쓰기
];

app.get('/auth/github', (req, res) => {
const authUrl = 'https://github.com/login/oauth/authorize';
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'user:email public_repo' // 필요한 권한만
});

res.redirect(`${authUrl}?${params}`);
});

// ========== 최소 권한 원칙 ==========

// ❌ 나쁜 예: 필요 이상의 권한 요청
scope: 'user repo admin:org' // 너무 많은 권한!

// ✅ 좋은 예: 필요한 권한만
scope: 'user:email' // 이메일만 필요

// ========== 동적 Scope 요청 ==========

// 초기 로그인: 기본 정보만
app.get('/auth/google', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile' // 기본 정보만
});

res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// 나중에 추가 권한 필요 시
app.get('/auth/google/drive', (req, res) => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/drive.readonly',
prompt: 'consent' // 재동의 요청
});

res.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`);
});

// ========== Incremental Authorization ==========
// 필요할 때마다 점진적으로 권한 추가

// 1단계: 로그인 (기본 정보)
scope: 'email profile'

// 2단계: Google Drive 연동 (사용자가 원할 때)
scope: 'https://www.googleapis.com/auth/drive'

// 3단계: Gmail 연동 (사용자가 원할 때)
scope: 'https://www.googleapis.com/auth/gmail.send'

// ========== Scope 검증 ==========

// Access Token에 포함된 scope 확인
async function checkScope(accessToken, requiredScope) {
const response = await axios.get(
'https://www.googleapis.com/oauth2/v1/tokeninfo',
{ params: { access_token: accessToken } }
);

const grantedScopes = response.data.scope.split(' ');

if (!grantedScopes.includes(requiredScope)) {
throw new Error('권한 부족');
}
}

// 사용 예시
app.get('/api/drive/files', async (req, res) => {
const accessToken = req.headers.authorization?.split(' ')[1];

try {
// Drive 권한 확인
await checkScope(accessToken, 'https://www.googleapis.com/auth/drive.readonly');

// Drive API 호출
const files = await listDriveFiles(accessToken);
res.json(files);
} catch (error) {
res.status(403).json({
error: '권한 부족',
message: 'Google Drive 접근 권한이 필요합니다'
});
}
});

Q4. OAuth 보안 모범 사례는?

A:

// ========== 1. HTTPS 필수 ==========
// ✅ 항상 HTTPS 사용
const REDIRECT_URI = 'https://myapp.com/callback';

// ❌ HTTP는 절대 금지
const REDIRECT_URI = 'http://myapp.com/callback'; // 토큰 탈취 위험!

// ========== 2. State 매개변수 (CSRF 방지) ==========

// 로그인 시작
app.get('/auth/google', (req, res) => {
// 랜덤 state 생성 및 저장
const state = crypto.randomBytes(16).toString('hex');
req.session.oauthState = state;

const authUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'email profile',
state // CSRF 방지
});

res.redirect(`${authUrl}?${params}`);
});

// 콜백에서 검증
app.get('/auth/google/callback', (req, res) => {
const { state, code } = req.query;

// state 확인
if (state !== req.session.oauthState) {
return res.status(403).send('CSRF 공격 감지');
}

// state 삭제
delete req.session.oauthState;

// ... 계속 처리
});

// ========== 3. Client Secret 보호 ==========

// ✅ 환경변수 사용
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET;

// ✅ 서버에서만 사용
// 프론트엔드에 절대 노출 금지!

// ❌ 코드에 하드코딩 금지
const CLIENT_SECRET = 'abc123xyz'; // 절대 금지!

// ========== 4. Redirect URI 검증 ==========

// Google Cloud Console에 등록된 URI만 허용
// ✅ 정확히 일치하는 URI
https://myapp.com/auth/callback

// ❌ 와일드카드 사용 금지
https://myapp.com/*

// ❌ 오픈 리다이렉터 취약점
https://myapp.com/callback?redirect=evil.com

// ========== 5. Token 저장 ==========

// ✅ 서버: 환경변수 또는 암호화된 DB
await saveToken(userId, encryptToken(accessToken));

// ✅ 클라이언트: HttpOnly 쿠키 (XSS 방지)
res.cookie('access_token', accessToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 3600000 // 1시간
});

// ❌ localStorage (XSS 취약)
localStorage.setItem('token', accessToken); // 위험!

// ========== 6. Token 만료 시간 ==========

// ✅ 짧은 수명
const accessToken = jwt.sign(payload, secret, {
expiresIn: '15m' // 15분
});

// ✅ Refresh Token 사용
const refreshToken = jwt.sign(payload, secret, {
expiresIn: '7d' // 7일
});

// ❌ 만료 시간 없음
const token = jwt.sign(payload, secret); // 위험!

// ========== 7. Scope 최소화 ==========

// ✅ 필요한 권한만
scope: 'email profile'

// ❌ 모든 권한 요청
scope: 'https://www.googleapis.com/auth/drive' // 불필요

// ========== 8. Token 취소 구현 ==========

// 로그아웃 시 Token 취소
app.get('/logout', async (req, res) => {
const accessToken = req.cookies.access_token;

// Google Token 취소
await axios.post(
`https://oauth2.googleapis.com/revoke?token=${accessToken}`
);

// DB에서 Refresh Token 삭제
await deleteRefreshToken(req.user.id);

// 쿠키 삭제
res.clearCookie('access_token');

res.redirect('/');
});

// ========== 9. PKCE 사용 (모바일/SPA) ==========

// Client Secret 없이 보안 강화
const codeVerifier = generateRandomString(128);
const codeChallenge = base64URLEncode(sha256(codeVerifier));

// 인증 요청 시 challenge 전송
const params = {
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
code_challenge: codeChallenge,
code_challenge_method: 'S256'
};

// Token 교환 시 verifier 전송
const tokenParams = {
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
code_verifier: codeVerifier // Secret 대신
};

// ========== 10. 정기적인 Token 갱신 ==========

// 백그라운드에서 자동 갱신
setInterval(async () => {
try {
const newAccessToken = await refreshAccessToken(refreshToken);
updateToken(newAccessToken);
} catch (error) {
console.error('Token 갱신 실패:', error);
redirectToLogin();
}
}, 14 * 60 * 1000); // 14분마다 (15분 만료 전에)

Q5. 인증(Authentication) vs 권한 부여(Authorization)?

A:

// ========== 인증 (Authentication) ==========
// "당신은 누구인가?" - 신원 확인

// 예시: 로그인
const user = await authenticateUser(username, password);
if (user) {
console.log('당신은 김철수입니다 ✓');
}

// ========== 권한 부여 (Authorization) ==========
// "당신이 무엇을 할 수 있는가?" - 권한 확인

// 예시: 관리자만 접근
if (user.role === 'admin') {
console.log('관리자 기능 사용 가능 ✓');
} else {
console.log('권한 없음 ✗');
}

// ========== OAuth 2.0의 역할 ==========

// OAuth는 주로 Authorization (권한 부여)
// - 사용자가 앱에게 권한을 부여
// - "이 앱이 내 Google Drive를 볼 수 있게 허용"

// 하지만 인증(Authentication)에도 사용됨
// - OpenID Connect (OAuth 2.0 + 인증)
// - "Google로 로그인"

// ========== 실제 예시 ==========

// 1단계: 인증 (Authentication)
app.post('/login', async (req, res) => {
const { username, password } = req.body;

// 사용자 신원 확인
const user = await User.findOne({ username });

if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: '인증 실패' });
}

// 인증 성공 → 토큰 발급
const token = jwt.sign(
{
userId: user.id,
role: user.role // 권한 정보 포함
},
SECRET_KEY
);

res.json({ token });
});

// 2단계: 권한 부여 (Authorization)
app.get('/admin/users', authenticateToken, authorizeAdmin, (req, res) => {
// 관리자만 접근 가능
const users = await User.findAll();
res.json(users);
});

function authenticateToken(req, res, next) {
// 인증: 토큰 검증
const token = req.headers.authorization?.split(' ')[1];

try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: '인증 필요' });
}
}

function authorizeAdmin(req, res, next) {
// 권한: 관리자 확인
if (req.user.role !== 'admin') {
return res.status(403).json({ error: '권한 없음' });
}
next();
}

// ========== OAuth로 인증 + 권한 부여 ==========

// 1. 인증 (OpenID Connect)
app.get('/auth/google/callback', async (req, res) => {
// Google OAuth로 사용자 확인
const googleUser = await getGoogleUser(req.query.code);

// 인증 완료 → 사용자 신원 확인됨
console.log('사용자:', googleUser.email);

// 우리 시스템에 로그인
const user = await findOrCreateUser(googleUser);
req.session.userId = user.id;
});

// 2. 권한 부여 (Scope)
app.get('/api/drive/files', async (req, res) => {
// Google Drive 권한 확인
const hasPermission = await checkGoogleScope(
req.user.accessToken,
'https://www.googleapis.com/auth/drive.readonly'
);

if (!hasPermission) {
return res.status(403).json({
error: '권한 필요',
message: 'Google Drive 접근 권한을 부여해주세요'
});
}

// 권한 있음 → API 호출
const files = await listDriveFiles(req.user.accessToken);
res.json(files);
});

// ========== 비교 정리 ==========

인증 (Authentication)
- 질문: "당신은 누구?"
- 목적: 신원 확인
- 예시:
* 비밀번호 입력
* 지문 인식
* Google로 로그인
- HTTP 상태 코드: 401 Unauthorized

권한 부여 (Authorization)
- 질문: "당신이 뭘 할 수 있나?"
- 목적: 접근 권한 확인
- 예시:
* 관리자 권한
* Google Drive 읽기 권한
* OAuth Scope
- HTTP 상태 코드: 403 Forbidden

// 둘 다 필요!
// 1. 먼저 인증 (로그인)
// 2. 그 다음 권한 확인 (접근 허용)

🎓 다음 단계

OAuth 2.0을 이해했다면, 다음을 학습해보세요:

  1. JWT 토큰 (문서 작성 예정) - 토큰 기반 인증
  2. HTTPS란? (문서 작성 예정) - 보안 통신
  3. CORS란? (문서 작성 예정) - API 보안

실습해보기

# ========== 1. Google OAuth 실습 ==========

# 프로젝트 생성
mkdir oauth-demo
cd oauth-demo
npm init -y

# 패키지 설치
npm install express axios express-session dotenv

# .env 파일 생성
cat > .env << EOF
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
SESSION_SECRET=your_session_secret
EOF

# server.js 작성 후 실행
node server.js

# http://localhost:3000 접속

# ========== 2. Passport.js 실습 ==========

npm install passport passport-google-oauth20

# ========== 3. 여러 소셜 로그인 구현 ==========

npm install passport-github2 passport-facebook

# Google, GitHub, Facebook 모두 지원

🎬 마무리

OAuth 2.0은 현대 웹의 표준 인증 프로토콜입니다:

  • 보안: 비밀번호 공유 없이 안전한 인증
  • 편의성: 소셜 로그인으로 빠른 가입
  • 권한 제어: 필요한 권한만 부여
  • 표준: 모든 주요 서비스가 지원

안전하고 편리한 인증 시스템을 구축하세요! 🔐