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() 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(` OAuth2 Client

OAuth2 Client - 로그인됨

✅ 성공적으로 로그인되었습니다!

accessToken: ${accessToken}

사용자 ID: ${profile.id || 'N/A'}
사용자명: ${profile.username || 'N/A'}
${JSON.stringify(profile, null, 2)}
로그아웃
`) } catch (error: any) { return c.html(`

프로필 조회 실패

오류: ${error?.message || '알 수 없는 오류'}

홈으로 `) } } // 비로그인 화면 return c.html(` OAuth2 Client

OAuth2 Client 데모

설정 필요:

1. OAuth2 서버에서 클라이언트를 등록하세요

2. Redirect URI: ${OAUTH_CONFIG.redirectUri}

3. 발급받은 Client ID를 코드에 설정하세요

OAuth2로 로그인
`) }) // OAuth2 로그인 시작 app.get('/login', async (c) => { if (!OAUTH_CONFIG.clientId) { return c.html(`

오류

Client ID가 설정되지 않았습니다. 먼저 OAuth2 서버에서 클라이언트를 등록하고 Client ID를 설정하세요.

홈으로 `) } // 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(`

인증 오류

오류: ${error}

설명: ${c.req.query('error_description') || '알 수 없는 오류'}

홈으로 `) } if (!code || !state) { return c.html(`

잘못된 요청

인증 코드 또는 state가 누락되었습니다.

홈으로 `) } 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(`

토큰 교환 실패

상태 코드: ${tokenResponse.status}

오류: ${errorText}

홈으로 `) } 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(`

인증 처리 실패

오류: ${error?.message || '알 수 없는 오류'}

홈으로 `) } }) // 로그아웃 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, }