feat: Add OAuth2 server and client implementation with PKCE support
- Implemented OAuth2 server with client registration, authorization, and token endpoints. - Created HTML templates for client authorization, client creation, and client editing. - Developed an OAuth2 client application using Hono.js and Bun, supporting authorization code grant flow. - Integrated PKCE (Proof Key for Code Exchange) for enhanced security during authorization. - Added session management using cookies for user authentication. - Included detailed README documentation for setup and usage instructions.
This commit is contained in:
commit
7cd05b5c6a
29 changed files with 1962 additions and 0 deletions
34
oauth2-client/.gitignore
vendored
Normal file
34
oauth2-client/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
114
oauth2-client/README.md
Normal file
114
oauth2-client/README.md
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# OAuth2 Client
|
||||
|
||||
이 프로젝트는 OAuth2 서버와 연동하는 클라이언트 애플리케이션입니다. Hono.js와 Bun을 사용하여 구축되었습니다.
|
||||
|
||||
## 기능
|
||||
|
||||
- OAuth2 Authorization Code Grant 플로우 구현
|
||||
- PKCE (Proof Key for Code Exchange) 지원
|
||||
- 사용자 프로필 조회
|
||||
- 쿠키 기반 세션 관리
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. OAuth2 서버에서 클라이언트 등록
|
||||
|
||||
먼저 OAuth2 서버에서 클라이언트를 등록해야 합니다:
|
||||
|
||||
1. OAuth2 서버 실행:
|
||||
```bash
|
||||
cd ../example-oauth2-server
|
||||
python app.py
|
||||
```
|
||||
|
||||
2. 브라우저에서 `http://localhost:5000` 접속
|
||||
|
||||
3. 사용자 생성 (예: 사용자명 "testuser")
|
||||
|
||||
4. "Create Client" 클릭하여 새 클라이언트 생성:
|
||||
- **Client Name**: "OAuth2 Client Demo"
|
||||
- **Client URI**: "http://localhost:3000"
|
||||
- **Grant Type**: "authorization_code"
|
||||
- **Redirect URI**: "http://localhost:3000/callback"
|
||||
- **Response Type**: "code"
|
||||
- **Scope**: "profile"
|
||||
- **Token Endpoint Auth Method**: "none" (PKCE 사용 시)
|
||||
|
||||
5. 생성된 **Client ID**를 복사
|
||||
|
||||
### 2. 클라이언트 설정
|
||||
|
||||
`index.ts` 파일에서 `OAUTH_CONFIG.clientId`를 설정:
|
||||
|
||||
```typescript
|
||||
const OAUTH_CONFIG = {
|
||||
authServerUrl: 'http://localhost:5000',
|
||||
clientId: 'YOUR_CLIENT_ID_HERE', // 복사한 Client ID 입력
|
||||
clientSecret: '', // PKCE 사용 시 불필요
|
||||
redirectUri: 'http://localhost:3000/callback',
|
||||
scope: 'profile'
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 클라이언트 실행
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```bash
|
||||
bun index.ts
|
||||
```
|
||||
|
||||
기본적으로 `http://localhost:3000`에서 실행됩니다.
|
||||
|
||||
## 사용법
|
||||
|
||||
1. 브라우저에서 `http://localhost:3000` 접속
|
||||
2. "OAuth2로 로그인" 버튼 클릭
|
||||
3. OAuth2 서버로 리다이렉트됨
|
||||
4. 권한 승인
|
||||
5. 클라이언트로 다시 리다이렉트되어 로그인 완료
|
||||
6. "내 프로필 보기"로 사용자 정보 확인 가능
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
- `GET /` - 홈페이지
|
||||
- `GET /login` - OAuth2 로그인 시작
|
||||
- `GET /callback` - OAuth2 콜백 처리
|
||||
- `GET /profile` - 사용자 프로필 조회
|
||||
- `GET /logout` - 로그아웃
|
||||
|
||||
## 보안 기능
|
||||
|
||||
- **PKCE**: Code Injection 공격 방지
|
||||
- **State 매개변수**: CSRF 공격 방지
|
||||
- **HTTP-only 쿠키**: XSS 공격 방지
|
||||
- **세션 타임아웃**: 일회용 세션 데이터
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 이 예제는 개발/데모 목적으로 제작되었습니다
|
||||
- 실제 운영환경에서는 다음을 고려하세요:
|
||||
- HTTPS 사용
|
||||
- 안전한 세션 스토리지 (Redis 등)
|
||||
- 적절한 에러 핸들링
|
||||
- 로깅 및 모니터링
|
||||
- 토큰 갱신 로직
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### "Client ID가 설정되지 않았습니다" 오류
|
||||
- `index.ts`에서 `OAUTH_CONFIG.clientId`를 설정했는지 확인
|
||||
- OAuth2 서버에서 클라이언트를 올바르게 등록했는지 확인
|
||||
|
||||
### "유효하지 않은 redirect_uri" 오류
|
||||
- OAuth2 서버에 등록한 Redirect URI가 `http://localhost:3000/callback`인지 확인
|
||||
- 포트 번호가 일치하는지 확인
|
||||
|
||||
### "토큰 교환 실패" 오류
|
||||
- OAuth2 서버가 실행 중인지 확인
|
||||
- Client ID가 올바른지 확인
|
||||
- 네트워크 연결 상태 확인
|
||||
40
oauth2-client/bun.lock
Normal file
40
oauth2-client/bun.lock
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "oauth2-client",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"hono": "^4.8.4",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
|
||||
"@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
|
||||
"crypto-js": ["crypto-js@4.2.0", "", {}, "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"hono": ["hono@4.8.4", "", {}, "sha512-KOIBp1+iUs0HrKztM4EHiB2UtzZDTBihDtOF5K6+WaJjCPeaW4Q92R8j63jOhvJI5+tZSMuKD9REVEXXY9illg=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
}
|
||||
}
|
||||
265
oauth2-client/index.ts
Normal file
265
oauth2-client/index.ts
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { serve } from 'bun'
|
||||
import { Hono } from 'hono'
|
||||
import { getCookie, setCookie } from 'hono/cookie'
|
||||
import pkceChallenge from 'pkce-challenge'
|
||||
|
||||
// 타입 정의
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
id: string
|
||||
username: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
codeVerifier: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
// OAuth2 서버 설정
|
||||
const OAUTH_CONFIG = {
|
||||
authServerUrl: 'http://localhost:3020',
|
||||
clientId: 'gUsp3BiVSz16i03ZU3gGYklc', // 클라이언트 등록 후 설정 필요
|
||||
clientSecret: 'F7wdXoewMeAxm1OSZbpmDdowyZPHd5YL4a3ubYyCyJNB1Us4', // 필요한 경우
|
||||
redirectUri: 'http://localhost:5001/callback',
|
||||
scope: 'profile'
|
||||
}
|
||||
|
||||
// 메모리 기반 세션 스토리지 (실제 운영환경에서는 Redis 등 사용)
|
||||
const sessions = new Map<string, SessionData>()
|
||||
|
||||
app.get('/', async (c) => {
|
||||
const accessToken = getCookie(c, 'access_token')
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const profileResponse = await fetch(`${OAUTH_CONFIG.authServerUrl}/api/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!profileResponse.ok) {
|
||||
if (profileResponse.status === 401) {
|
||||
setCookie(c, 'access_token', '', { maxAge: 0 })
|
||||
setCookie(c, 'refresh_token', '', { maxAge: 0 })
|
||||
return c.redirect('/')
|
||||
}
|
||||
throw new Error(`HTTP ${profileResponse.status}`)
|
||||
}
|
||||
|
||||
const profile = await profileResponse.json() as ProfileResponse
|
||||
|
||||
return c.html(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth2 Client</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
.button { background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 10px 0; }
|
||||
.success { color: green; }
|
||||
.profile { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }
|
||||
.profile-item { margin: 10px 0; }
|
||||
.label { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>OAuth2 Client - 로그인됨</h1>
|
||||
<p class="success">✅ 성공적으로 로그인되었습니다!</p>
|
||||
<p>accessToken: <code>${accessToken}</code></p>
|
||||
<div class="profile">
|
||||
<div class="profile-item">
|
||||
<span class="label">사용자 ID:</span> ${profile.id || 'N/A'}
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<span class="label">사용자명:</span> ${profile.username || 'N/A'}
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<code>
|
||||
${JSON.stringify(profile, null, 2)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/logout" class="button">로그아웃</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
} catch (error: any) {
|
||||
return c.html(`
|
||||
<h1>프로필 조회 실패</h1>
|
||||
<p>오류: ${error?.message || '알 수 없는 오류'}</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// 비로그인 화면
|
||||
return c.html(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth2 Client</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
.container { max-width: 600px; margin: 0 auto; }
|
||||
.button { background: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block; margin: 10px 0; }
|
||||
.info { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>OAuth2 Client 데모</h1>
|
||||
<div class="info">
|
||||
<h3>설정 필요:</h3>
|
||||
<p>1. OAuth2 서버에서 클라이언트를 등록하세요</p>
|
||||
<p>2. Redirect URI: <code>${OAUTH_CONFIG.redirectUri}</code></p>
|
||||
<p>3. 발급받은 Client ID를 코드에 설정하세요</p>
|
||||
</div>
|
||||
<a href="/login" class="button">OAuth2로 로그인</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`)
|
||||
})
|
||||
|
||||
|
||||
// OAuth2 로그인 시작
|
||||
app.get('/login', async (c) => {
|
||||
if (!OAUTH_CONFIG.clientId) {
|
||||
return c.html(`
|
||||
<h1>오류</h1>
|
||||
<p>Client ID가 설정되지 않았습니다. 먼저 OAuth2 서버에서 클라이언트를 등록하고 Client ID를 설정하세요.</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
|
||||
// PKCE 챌린지 생성
|
||||
const pkcePair = await pkceChallenge()
|
||||
const state = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
// 세션에 저장
|
||||
sessions.set(state, {
|
||||
codeVerifier: pkcePair.code_verifier,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// 인증 URL 생성
|
||||
const authUrl = new URL('/oauth/authorize', OAUTH_CONFIG.authServerUrl)
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)
|
||||
authUrl.searchParams.set('redirect_uri', OAUTH_CONFIG.redirectUri)
|
||||
authUrl.searchParams.set('scope', OAUTH_CONFIG.scope)
|
||||
authUrl.searchParams.set('state', state)
|
||||
authUrl.searchParams.set('code_challenge', pkcePair.code_challenge)
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256')
|
||||
|
||||
return c.redirect(authUrl.toString())
|
||||
})
|
||||
|
||||
// OAuth2 콜백 처리
|
||||
app.get('/callback', async (c) => {
|
||||
const code = c.req.query('code')
|
||||
const state = c.req.query('state')
|
||||
const error = c.req.query('error')
|
||||
|
||||
if (error) {
|
||||
return c.html(`
|
||||
<h1>인증 오류</h1>
|
||||
<p>오류: ${error}</p>
|
||||
<p>설명: ${c.req.query('error_description') || '알 수 없는 오류'}</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return c.html(`
|
||||
<h1>잘못된 요청</h1>
|
||||
<p>인증 코드 또는 state가 누락되었습니다.</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
console.log(`Received code: ${code}, state: ${state}`)
|
||||
|
||||
try {
|
||||
// 토큰 교환 - client_secret_basic 방식으로 인증
|
||||
const credentials = btoa(`${OAUTH_CONFIG.clientId}:${OAUTH_CONFIG.clientSecret}`)
|
||||
const tokenResponse = await fetch(`${OAUTH_CONFIG.authServerUrl}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${credentials}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: OAUTH_CONFIG.redirectUri,
|
||||
code_verifier: state ? sessions.get(state)?.codeVerifier || '' : ''
|
||||
})
|
||||
})
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
return c.html(`
|
||||
<h1>토큰 교환 실패</h1>
|
||||
<p>상태 코드: ${tokenResponse.status}</p>
|
||||
<p>오류: ${errorText}</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json() as TokenResponse
|
||||
|
||||
// 토큰을 쿠키에 저장
|
||||
setCookie(c, 'access_token', tokens.access_token, {
|
||||
maxAge: tokens.expires_in || 3600,
|
||||
httpOnly: true,
|
||||
secure: false // HTTPS 환경에서는 true로 설정
|
||||
})
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
setCookie(c, 'refresh_token', tokens.refresh_token, {
|
||||
maxAge: 60 * 60 * 24 * 30, // 30일
|
||||
httpOnly: true,
|
||||
secure: false
|
||||
})
|
||||
}
|
||||
|
||||
// 세션 정리
|
||||
if (state) {
|
||||
sessions.delete(state)
|
||||
}
|
||||
|
||||
return c.redirect('/')
|
||||
|
||||
} catch (error: any) {
|
||||
return c.html(`
|
||||
<h1>인증 처리 실패</h1>
|
||||
<p>오류: ${error?.message || '알 수 없는 오류'}</p>
|
||||
<a href="/">홈으로</a>
|
||||
`)
|
||||
}
|
||||
})
|
||||
|
||||
// 로그아웃
|
||||
app.get('/logout', (c) => {
|
||||
setCookie(c, 'access_token', '', { maxAge: 0 })
|
||||
setCookie(c, 'refresh_token', '', { maxAge: 0 })
|
||||
return c.redirect('/')
|
||||
})
|
||||
|
||||
export default {
|
||||
port: 5001,
|
||||
fetch: app.fetch,
|
||||
}
|
||||
21
oauth2-client/package.json
Normal file
21
oauth2-client/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "oauth2-client",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --hot index.ts",
|
||||
"start": "bun run index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"hono": "^4.8.4",
|
||||
"pkce-challenge": "^5.0.0"
|
||||
}
|
||||
}
|
||||
29
oauth2-client/tsconfig.json
Normal file
29
oauth2-client/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue