eodi-mcp / src /auth /routes.py
lovelymango's picture
Upload 12 files
2310db1 verified
"""
Authentication HTTP Routes
==========================
OAuth2 ๋ฐ Magic Link ์ธ์ฆ ์—”๋“œํฌ์ธํŠธ.
server_streamable.py์˜ routes์— ํ™•์žฅ๋ฉ๋‹ˆ๋‹ค.
"""
import logging
from urllib.parse import urlencode
from starlette.routing import Route
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse, HTMLResponse
from .oauth_provider import get_oauth_provider
from .magic_link import get_magic_link_auth
from .config import SERVER_BASE_URL, SUPABASE_URL
logger = logging.getLogger("eodi.auth.routes")
# =============================================================================
# OAuth2 ์—”๋“œํฌ์ธํŠธ (GPTs์šฉ)
# =============================================================================
async def oauth_authorize(request: Request) -> RedirectResponse:
"""
GET /oauth/authorize
GPTs OAuth ์‹œ์ž‘์ . ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ.
"""
oauth_provider = get_oauth_provider()
if not oauth_provider.is_configured:
return JSONResponse({
"error": "OAuth not configured",
"message": "OAUTH_CLIENT_SECRET ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."
}, status_code=503)
redirect_uri = request.query_params.get("redirect_uri")
scope = request.query_params.get("scope", "profile")
gpts_state = request.query_params.get("state", "")
if not redirect_uri:
return JSONResponse({"error": "redirect_uri is required"}, status_code=400)
# ๋‚ด๋ถ€ state ์ƒ์„ฑ (redirect_uri ์ €์žฅ์šฉ)
auth_url, internal_state = oauth_provider.get_authorization_url(redirect_uri, scope)
# GPTs state๋„ ํ•จ๊ป˜ ์ „๋‹ฌ
if gpts_state:
auth_url += f"&gpts_state={gpts_state}"
logger.info("OAuth authorize: redirect to login page")
return RedirectResponse(auth_url, status_code=302)
async def oauth_callback(request: Request):
"""
GET /oauth/callback
(๋‚ด๋ถ€ ์—”๋“œํฌ์ธํŠธ - ์ง์ ‘ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Œ)
"""
return JSONResponse({"message": "Use /auth/callback instead"})
async def oauth_token(request: Request) -> JSONResponse:
"""
POST /oauth/token
Authorization code โ†’ Access token ๊ตํ™˜.
"""
oauth_provider = get_oauth_provider()
if not oauth_provider.is_configured:
return JSONResponse({"error": "OAuth not configured"}, status_code=503)
# form-urlencoded ๋˜๋Š” JSON ๋ฐ”๋”” ์ฒ˜๋ฆฌ
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type:
form = await request.form()
grant_type = form.get("grant_type")
code = form.get("code")
redirect_uri = form.get("redirect_uri")
client_id = form.get("client_id")
client_secret = form.get("client_secret")
refresh_token = form.get("refresh_token")
else:
try:
body = await request.json()
grant_type = body.get("grant_type")
code = body.get("code")
redirect_uri = body.get("redirect_uri")
client_id = body.get("client_id")
client_secret = body.get("client_secret")
refresh_token = body.get("refresh_token")
except Exception:
return JSONResponse({"error": "invalid_request"}, status_code=400)
if grant_type == "authorization_code":
if not code or not redirect_uri:
return JSONResponse({"error": "invalid_request"}, status_code=400)
success, token_response, error = await oauth_provider.exchange_code_for_token(
code=code,
redirect_uri=redirect_uri,
client_id=client_id,
client_secret=client_secret
)
if success:
return JSONResponse(token_response)
else:
return JSONResponse(
{"error": "invalid_grant", "error_description": error},
status_code=400
)
elif grant_type == "refresh_token":
success, token_response, error = await oauth_provider.refresh_token(
refresh_token=refresh_token,
client_id=client_id,
client_secret=client_secret
)
if success:
return JSONResponse(token_response)
else:
return JSONResponse(
{"error": "invalid_grant", "error_description": error},
status_code=400
)
else:
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
# =============================================================================
# Magic Link ์—”๋“œํฌ์ธํŠธ (MCP ํด๋ผ์ด์–ธํŠธ์šฉ)
# =============================================================================
async def auth_login_page(request: Request) -> HTMLResponse:
"""
GET /auth/login
๋กœ๊ทธ์ธ ํŽ˜์ด์ง€. ์ด๋ฉ”์ผ ์ž…๋ ฅ โ†’ Magic Link ๋ฐœ์†ก.
"""
redirect_uri = request.query_params.get("redirect_uri", "")
state = request.query_params.get("state", "")
gpts_state = request.query_params.get("gpts_state", "")
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Eodi ๋กœ๊ทธ์ธ</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
box-sizing: border-box;
}}
.container {{
background: white;
padding: 40px 30px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}}
h1 {{
color: #333;
font-size: 28px;
margin-bottom: 8px;
text-align: center;
}}
.subtitle {{
color: #666;
text-align: center;
margin-bottom: 30px;
}}
input[type="email"] {{
width: 100%;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 10px;
font-size: 16px;
margin-bottom: 16px;
box-sizing: border-box;
transition: border-color 0.2s;
}}
input[type="email"]:focus {{
outline: none;
border-color: #667eea;
}}
button {{
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}}
button:hover {{
transform: translateY(-2px);
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
}}
button:disabled {{
background: #ccc;
transform: none;
box-shadow: none;
}}
.message {{ margin-top: 16px; text-align: center; }}
.success {{ color: #10b981; }}
.error {{ color: #ef4444; }}
.info {{
color: #6b7280;
font-size: 13px;
margin-top: 20px;
text-align: center;
}}
</style>
</head>
<body>
<div class="container">
<h1>๐Ÿจ Eodi</h1>
<p class="subtitle">์—ฌํ–‰ ํ˜œํƒ์˜ ๋ชจ๋“  ๊ฒƒ</p>
<form id="loginForm">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="gpts_state" value="{gpts_state}">
<input type="email" name="email" placeholder="์ด๋ฉ”์ผ ์ฃผ์†Œ" required>
<button type="submit" id="submitBtn">๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ</button>
</form>
<div id="message"></div>
<p class="info">๋กœ๊ทธ์ธ ๋งํฌ๋Š” 10๋ถ„๊ฐ„ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค.</p>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {{
e.preventDefault();
const form = e.target;
const email = form.email.value;
const button = document.getElementById('submitBtn');
const messageDiv = document.getElementById('message');
button.disabled = true;
button.textContent = '์ „์†ก ์ค‘...';
messageDiv.innerHTML = '';
try {{
const response = await fetch('/auth/send-link', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
email: email,
redirect_uri: form.redirect_uri.value,
state: form.state.value,
gpts_state: form.gpts_state.value
}})
}});
const data = await response.json();
if (data.success) {{
messageDiv.innerHTML = '<p class="message success">โœ… ์ด๋ฉ”์ผ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”!</p>';
button.textContent = '์ „์†ก ์™„๋ฃŒ';
}} else {{
messageDiv.innerHTML = '<p class="message error">โŒ ' + data.error + '</p>';
button.disabled = false;
button.textContent = '๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ';
}}
}} catch (err) {{
messageDiv.innerHTML = '<p class="message error">โŒ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</p>';
button.disabled = false;
button.textContent = '๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ';
}}
}});
</script>
</body>
</html>
"""
return HTMLResponse(html)
async def auth_send_link(request: Request) -> JSONResponse:
"""
POST /auth/send-link
Magic Link ๋ฐœ์†ก API.
"""
magic_link_auth = get_magic_link_auth()
try:
body = await request.json()
email = body.get("email")
if not email:
return JSONResponse(
{"success": False, "error": "์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."},
status_code=400
)
success, error = await magic_link_auth.send_magic_link(email)
if success:
return JSONResponse({"success": True})
else:
return JSONResponse({"success": False, "error": error}, status_code=400)
except Exception as e:
logger.error(f"Magic Link ๋ฐœ์†ก ์˜ค๋ฅ˜: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def auth_callback_page(request: Request) -> HTMLResponse:
"""
GET /auth/callback
Magic Link ํด๋ฆญ ํ›„ ๋žœ๋”ฉ ํŽ˜์ด์ง€.
Supabase๊ฐ€ URL fragment์— ํ† ํฐ์„ ํฌํ•จ.
"""
html = """
<!DOCTYPE html>
<html>
<head>
<title>Eodi ์ธ์ฆ ์™„๋ฃŒ</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 400px;
margin: 50px auto;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
box-sizing: border-box;
}
.container {
background: white;
padding: 40px 30px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
text-align: center;
}
h1 { color: #333; font-size: 28px; margin-bottom: 10px; }
h2 { color: #333; font-size: 22px; margin-bottom: 10px; }
.code {
font-size: 42px;
font-weight: bold;
letter-spacing: 10px;
color: #667eea;
margin: 24px 0;
padding: 20px;
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecff 100%);
border-radius: 12px;
}
.instruction {
color: #4b5563;
margin: 16px 0;
line-height: 1.6;
}
.timer {
color: #9ca3af;
font-size: 14px;
margin-top: 16px;
}
.error { color: #ef4444; }
.loading { color: #6b7280; }
</style>
</head>
<body>
<div class="container">
<h1>๐Ÿจ Eodi</h1>
<div id="content">
<p class="loading">์ธ์ฆ ์ฒ˜๋ฆฌ ์ค‘...</p>
</div>
</div>
<script>
async function processAuth() {
const contentDiv = document.getElementById('content');
// URL fragment์—์„œ ํ† ํฐ ์ถ”์ถœ
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const refreshToken = params.get('refresh_token') || '';
if (!accessToken) {
contentDiv.innerHTML = `
<h2 class="error">โŒ ์ธ์ฆ ์‹คํŒจ</h2>
<p class="instruction">๋งํฌ๊ฐ€ ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.<br>๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.</p>
`;
return;
}
try {
const response = await fetch('/auth/process-token', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
access_token: accessToken,
refresh_token: refreshToken
})
});
const data = await response.json();
if (data.success) {
contentDiv.innerHTML = `
<h2>โœ… ์ธ์ฆ ์™„๋ฃŒ!</h2>
<p class="instruction">์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”</p>
<div class="code">${data.code}</div>
<p class="instruction">GPT ๋˜๋Š” Claude๋กœ ๋Œ์•„๊ฐ€์„œ<br>์ด ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.</p>
<p class="timer">โฑ๏ธ ์ฝ”๋“œ๋Š” 3๋ถ„๊ฐ„ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค</p>
`;
} else {
contentDiv.innerHTML = `
<h2 class="error">โŒ ์˜ค๋ฅ˜ ๋ฐœ์ƒ</h2>
<p class="instruction">${data.error}</p>
`;
}
} catch (err) {
contentDiv.innerHTML = `
<h2 class="error">โŒ ์˜ค๋ฅ˜ ๋ฐœ์ƒ</h2>
<p class="instruction">์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.</p>
`;
}
}
processAuth();
</script>
</body>
</html>
"""
return HTMLResponse(html)
async def auth_process_token(request: Request) -> JSONResponse:
"""
POST /auth/process-token
Supabase ํ† ํฐ์œผ๋กœ ์ธ์ฆ ์ฝ”๋“œ ์ƒ์„ฑ.
"""
magic_link_auth = get_magic_link_auth()
try:
body = await request.json()
access_token = body.get("access_token")
refresh_token = body.get("refresh_token", "")
if not access_token:
return JSONResponse(
{"success": False, "error": "ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค."},
status_code=400
)
# ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ
import httpx
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
f"{SUPABASE_URL}/auth/v1/user",
headers={"Authorization": f"Bearer {access_token}"}
)
if response.status_code != 200:
return JSONResponse(
{"success": False, "error": "์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."},
status_code=400
)
user_data = response.json()
user_id = user_data.get("id")
email = user_data.get("email")
# ์ธ์ฆ ์ฝ”๋“œ ์ƒ์„ฑ
code = magic_link_auth.generate_auth_code(
user_id=user_id,
email=email,
access_token=access_token,
refresh_token=refresh_token
)
return JSONResponse({"success": True, "code": code})
except Exception as e:
logger.error(f"ํ† ํฐ ์ฒ˜๋ฆฌ ์˜ค๋ฅ˜: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
async def auth_verify_code_api(request: Request) -> JSONResponse:
"""
POST /auth/verify-code
์ธ์ฆ ์ฝ”๋“œ ๊ฒ€์ฆ API (REST์šฉ).
"""
magic_link_auth = get_magic_link_auth()
try:
body = await request.json()
code = body.get("code")
if not code:
return JSONResponse(
{"success": False, "error": "์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."},
status_code=400
)
# ํด๋ผ์ด์–ธํŠธ IP
client_ip = request.headers.get(
"X-Forwarded-For",
request.client.host if request.client else "unknown"
)
if "," in client_ip:
client_ip = client_ip.split(",")[0].strip()
success, session_token, email_masked, error = magic_link_auth.verify_auth_code(
code, client_ip
)
if success:
return JSONResponse({
"success": True,
"session_token": session_token,
"email_masked": email_masked,
"message": f"์ธ์ฆ ์™„๋ฃŒ! {email_masked}"
})
else:
return JSONResponse({"success": False, "error": error}, status_code=400)
except Exception as e:
logger.error(f"์ฝ”๋“œ ๊ฒ€์ฆ ์˜ค๋ฅ˜: {e}")
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
# =============================================================================
# ๋ผ์šฐํŠธ ๋ชฉ๋ก
# =============================================================================
auth_routes = [
# OAuth2 (GPTs์šฉ)
Route("/oauth/authorize", oauth_authorize, methods=["GET"]),
Route("/oauth/callback", oauth_callback, methods=["GET"]),
Route("/oauth/token", oauth_token, methods=["POST"]),
# Magic Link (MCP ํด๋ผ์ด์–ธํŠธ์šฉ)
Route("/auth/login", auth_login_page, methods=["GET"]),
Route("/auth/send-link", auth_send_link, methods=["POST"]),
Route("/auth/callback", auth_callback_page, methods=["GET"]),
Route("/auth/process-token", auth_process_token, methods=["POST"]),
Route("/auth/verify-code", auth_verify_code_api, methods=["POST"]),
]