- 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.
265 lines
No EOL
7.9 KiB
TypeScript
265 lines
No EOL
7.9 KiB
TypeScript
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,
|
|
}
|