Spaces:
Running
Running
| """ | |
| 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"]), | |
| ] | |