lovelymango commited on
Commit
2310db1
ยท
verified ยท
1 Parent(s): e275e70

Upload 12 files

Browse files
Dockerfile CHANGED
@@ -30,7 +30,8 @@ RUN pip install --no-cache-dir \
30
  python-frontmatter \
31
  pyyaml \
32
  click \
33
- aiohttp
 
34
 
35
  # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ
36
  COPY . .
 
30
  python-frontmatter \
31
  pyyaml \
32
  click \
33
+ aiohttp \
34
+ PyJWT
35
 
36
  # ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ฝ”๋“œ ๋ณต์‚ฌ
37
  COPY . .
pyproject.toml CHANGED
@@ -26,6 +26,7 @@ uvicorn = "^0.27.0"
26
  aiohttp = "^3.9.0"
27
  sentence-transformers = "^2.2.0"
28
  torch = "^2.0.0"
 
29
 
30
 
31
  [tool.poetry.group.dev.dependencies]
 
26
  aiohttp = "^3.9.0"
27
  sentence-transformers = "^2.2.0"
28
  torch = "^2.0.0"
29
+ PyJWT = "^2.8.0"
30
 
31
 
32
  [tool.poetry.group.dev.dependencies]
src/auth/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Authentication Module
3
+ ==========================
4
+
5
+ Supabase Auth ๊ธฐ๋ฐ˜ ์‚ฌ์šฉ์ž ์ธ์ฆ ๋ฐ ์„ธ์…˜ ๊ด€๋ฆฌ.
6
+
7
+ ์ง€์› ์ธ์ฆ ๋ฐฉ์‹:
8
+ - OAuth2 Authorization Code Flow (GPTs Actions์šฉ)
9
+ - Magic Link + ์ธ์ฆ์ฝ”๋“œ (Claude Desktop/MCP ํด๋ผ์ด์–ธํŠธ์šฉ)
10
+
11
+ ์ฃผ์š” ์ปดํฌ๋„ŒํŠธ:
12
+ - OAuthProvider: GPTs์šฉ OAuth2 ์—”๋“œํฌ์ธํŠธ ์ œ๊ณต
13
+ - MagicLinkAuth: MCP ํด๋ผ์ด์–ธํŠธ์šฉ ์ด๋ฉ”์ผ ์ธ์ฆ
14
+ - UserSessionManager: ์‚ฌ์šฉ์ž ์„ธ์…˜ ํ† ํฐ ๊ด€๋ฆฌ (MCP SessionManager์™€ ๋ณ„๊ฐœ)
15
+ - TokenValidator: JWT ํ† ํฐ ๊ฒ€์ฆ
16
+ """
17
+
18
+ from .config import SUPPORTED_CHAINS, AUTH_CODE_TTL_SECONDS
19
+
20
+ __all__ = [
21
+ "SUPPORTED_CHAINS",
22
+ "AUTH_CODE_TTL_SECONDS",
23
+ ]
src/auth/config.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication Configuration
3
+ =============================
4
+
5
+ ์ธ์ฆ ๊ด€๋ จ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋ฐ ์ƒ์ˆ˜ ์ •์˜.
6
+ """
7
+
8
+ import os
9
+ import logging
10
+
11
+ logger = logging.getLogger("eodi.auth.config")
12
+
13
+ # =============================================================================
14
+ # ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๋กœ๋“œ
15
+ # =============================================================================
16
+
17
+ # Supabase ์„ค์ • (๊ธฐ์กด - ์žฌ์‚ฌ์šฉ)
18
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
19
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
20
+
21
+ # Supabase JWT Secret (ํ† ํฐ ๊ฒ€์ฆ์šฉ)
22
+ # ์œ„์น˜: Supabase Dashboard > Settings > API > JWT Settings > JWT Secret
23
+ SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
24
+
25
+ # OAuth2 Provider ์„ค์ • (GPTs์šฉ)
26
+ OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "eodi-gpts")
27
+ OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET")
28
+
29
+ # ์„œ๋ฒ„ URL (์ฝœ๋ฐฑ์šฉ)
30
+ SERVER_BASE_URL = os.getenv(
31
+ "SERVER_BASE_URL",
32
+ "https://lovelymango-eodi-mcp.hf.space"
33
+ )
34
+
35
+ # Magic Link ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ URL
36
+ MAGIC_LINK_REDIRECT_URL = f"{SERVER_BASE_URL}/auth/callback"
37
+
38
+ # =============================================================================
39
+ # ์ƒ์ˆ˜
40
+ # =============================================================================
41
+
42
+ # ์ธ์ฆ ์ฝ”๋“œ ์„ค์ •
43
+ AUTH_CODE_TTL_SECONDS = 180 # 3๋ถ„
44
+ AUTH_CODE_LENGTH = 6
45
+ AUTH_CODE_MAX_ATTEMPTS = 5 # ์ตœ๋Œ€ ์‹œ๋„ ํšŸ์ˆ˜ (brute force ๋ฐฉ์ง€)
46
+
47
+ # OAuth State TTL
48
+ OAUTH_STATE_TTL_SECONDS = 600 # 10๋ถ„
49
+
50
+ # ์ง€์› ์ฒด์ธ ๋ฐ ๋“ฑ๊ธ‰
51
+ SUPPORTED_CHAINS = {
52
+ "HILTON": ["Member", "Silver", "Gold", "Diamond"],
53
+ "MARRIOTT": ["Member", "Silver", "Gold", "Platinum", "Titanium", "Ambassador"],
54
+ "IHG": ["Member", "Silver", "Gold", "Platinum", "Diamond"],
55
+ "ACCOR": ["Member", "Silver", "Gold", "Platinum", "Diamond"],
56
+ "HYATT": ["Member", "Discoverist", "Explorist", "Globalist"],
57
+ }
58
+
59
+ # ๋“ฑ๊ธ‰ ์ •๊ทœํ™” ๋งคํ•‘ (์‚ฌ์šฉ์ž ์ž…๋ ฅ โ†’ ์ •์‹ ๋ช…์นญ)
60
+ TIER_ALIASES = {
61
+ # ํžํŠผ
62
+ "๊ณจ๋“œ": "Gold",
63
+ "๋‹ค์ด์•„": "Diamond",
64
+ "๋‹ค์ด์•„๋ชฌ๋“œ": "Diamond",
65
+ "์‹ค๋ฒ„": "Silver",
66
+ # ๋ฉ”๋ฆฌ์–ดํŠธ
67
+ "ํ”Œ๋ž˜ํ‹ฐ๋„˜": "Platinum",
68
+ "ํ”Œ๋ž˜": "Platinum",
69
+ "ํ‹ฐํƒ€๋Š„": "Titanium",
70
+ "์•ฐ๋ฒ„์„œ๋”": "Ambassador",
71
+ # IHG
72
+ "๋‹ค์ด์•„": "Diamond",
73
+ # ํ•˜์–ํŠธ
74
+ "๊ธ€๋กœ๋ฒŒ๋ฆฌ์ŠคํŠธ": "Globalist",
75
+ "์ต์Šคํ”Œ๋กœ๋ฆฌ์ŠคํŠธ": "Explorist",
76
+ }
77
+
78
+
79
+ # =============================================================================
80
+ # ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ฒ€์ฆ
81
+ # =============================================================================
82
+
83
+ def validate_config():
84
+ """ํ•„์ˆ˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ฒ€์ฆ. ์„œ๋ฒ„ ์‹œ์ž‘ ์‹œ ํ˜ธ์ถœ."""
85
+ missing = []
86
+
87
+ if not SUPABASE_URL:
88
+ missing.append("SUPABASE_URL")
89
+ if not SUPABASE_KEY:
90
+ missing.append("SUPABASE_KEY")
91
+
92
+ # JWT Secret์€ ํ† ํฐ ๊ฒ€์ฆ์—๋งŒ ํ•„์š”, ์—†์œผ๋ฉด ๊ฒฝ๊ณ ๋งŒ
93
+ if not SUPABASE_JWT_SECRET:
94
+ logger.warning(
95
+ "SUPABASE_JWT_SECRET์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. "
96
+ "์„œ๋ฒ„ ์ธก JWT ๊ฒ€์ฆ์ด ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค. "
97
+ "์„ค์ • ์œ„์น˜: Supabase Dashboard > Settings > API > JWT Settings"
98
+ )
99
+
100
+ # OAuth Secret์€ GPTs ์—ฐ๋™์—๋งŒ ํ•„์š”, ์—†์œผ๋ฉด ๊ฒฝ๊ณ ๋งŒ
101
+ if not OAUTH_CLIENT_SECRET:
102
+ logger.warning(
103
+ "OAUTH_CLIENT_SECRET์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. "
104
+ "GPTs OAuth ์—ฐ๋™์ด ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค."
105
+ )
106
+
107
+ if missing:
108
+ raise ValueError(
109
+ f"ํ•„์ˆ˜ ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ๋ˆ„๋ฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {', '.join(missing)}. "
110
+ "HF Space: Settings > Repository secrets์—์„œ ์„ค์ •ํ•˜์„ธ์š”."
111
+ )
112
+
113
+ logger.info("์ธ์ฆ ์„ค์ • ๊ฒ€์ฆ ์™„๋ฃŒ")
src/auth/magic_link.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Magic Link Authentication
3
+ =========================
4
+
5
+ Claude Desktop ๋“ฑ OAuth๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” MCP ํด๋ผ์ด์–ธํŠธ์šฉ.
6
+
7
+ ํ๋ฆ„:
8
+ 1. ์‚ฌ์šฉ์ž๊ฐ€ ์ด๋ฉ”์ผ ์ž…๋ ฅ โ†’ user_request_auth Tool ํ˜ธ์ถœ
9
+ 2. Supabase Magic Link ๋ฐœ์†ก
10
+ 3. ์‚ฌ์šฉ์ž๊ฐ€ ์ด๋ฉ”์ผ์—์„œ ๋งํฌ ํด๋ฆญ โ†’ /auth/callback ํŽ˜์ด์ง€
11
+ 4. ํŽ˜์ด์ง€์—์„œ 6์ž๋ฆฌ ์ฝ”๋“œ ํ‘œ์‹œ
12
+ 5. ์‚ฌ์šฉ์ž๊ฐ€ GPT/Claude๋กœ ๋Œ์•„์™€์„œ ์ฝ”๋“œ ์ž…๋ ฅ โ†’ user_verify_code Tool ํ˜ธ์ถœ
13
+ 6. ์ฝ”๋“œ ๊ฒ€์ฆ โ†’ ์„ธ์…˜ ํ† ํฐ ๋ฐ˜ํ™˜
14
+ """
15
+
16
+ import secrets
17
+ import time
18
+ import logging
19
+ from typing import Dict, Any, Optional, Tuple
20
+ from collections import defaultdict
21
+
22
+ import httpx
23
+
24
+ from .config import (
25
+ SUPABASE_URL,
26
+ SUPABASE_KEY,
27
+ MAGIC_LINK_REDIRECT_URL,
28
+ AUTH_CODE_TTL_SECONDS,
29
+ AUTH_CODE_LENGTH,
30
+ AUTH_CODE_MAX_ATTEMPTS,
31
+ )
32
+
33
+ logger = logging.getLogger("eodi.auth.magic_link")
34
+
35
+
36
+ class AuthCodeStore:
37
+ """
38
+ ์ธ์ฆ ์ฝ”๋“œ ์ž„์‹œ ์ €์žฅ์†Œ.
39
+
40
+ Magic Link ํด๋ฆญ ํ›„ ํ‘œ์‹œ๋˜๋Š” 6์ž๋ฆฌ ์ฝ”๋“œ ๊ด€๋ฆฌ.
41
+ Rate limiting ๋ฐ brute force ๋ฐฉ์ง€ ํฌํ•จ.
42
+ """
43
+
44
+ def __init__(self, ttl_seconds: int = AUTH_CODE_TTL_SECONDS):
45
+ self.ttl = ttl_seconds
46
+ self._codes: Dict[str, Dict[str, Any]] = {}
47
+ self._attempts: Dict[str, int] = defaultdict(int) # IP๋ณ„ ์‹œ๋„ ํšŸ์ˆ˜
48
+ self._last_cleanup = time.time()
49
+
50
+ def _cleanup(self):
51
+ """๋งŒ๋ฃŒ๋œ ์ฝ”๋“œ ๋ฐ ์‹œ๋„ ํšŸ์ˆ˜ ์ •๋ฆฌ"""
52
+ now = time.time()
53
+ if now - self._last_cleanup < 60:
54
+ return
55
+
56
+ self._last_cleanup = now
57
+
58
+ # ๋งŒ๋ฃŒ๋œ ์ฝ”๋“œ ์‚ญ์ œ
59
+ expired = [
60
+ c for c, data in self._codes.items()
61
+ if now - data["created_at"] > self.ttl
62
+ ]
63
+ for c in expired:
64
+ del self._codes[c]
65
+
66
+ # ์˜ค๋ž˜๋œ ์‹œ๋„ ํšŸ์ˆ˜ ๋ฆฌ์…‹
67
+ self._attempts.clear()
68
+
69
+ def generate_code(
70
+ self,
71
+ user_id: str,
72
+ email: str,
73
+ access_token: str,
74
+ refresh_token: str
75
+ ) -> str:
76
+ """
77
+ 6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ ์ƒ์„ฑ.
78
+
79
+ Args:
80
+ user_id: Supabase Auth UUID
81
+ email: ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ
82
+ access_token: Supabase access_token
83
+ refresh_token: Supabase refresh_token
84
+
85
+ Returns:
86
+ 6์ž๋ฆฌ ์ˆซ์ž ์ฝ”๋“œ
87
+ """
88
+ self._cleanup()
89
+
90
+ # ๊ฐ™์€ user_id์˜ ๊ธฐ์กด ์ฝ”๋“œ ์‚ญ์ œ (์ค‘๋ณต ๋ฐฉ์ง€)
91
+ existing = [
92
+ c for c, data in self._codes.items()
93
+ if data["user_id"] == user_id
94
+ ]
95
+ for c in existing:
96
+ del self._codes[c]
97
+
98
+ # 6์ž๋ฆฌ ์ˆซ์ž ์ฝ”๋“œ ์ƒ์„ฑ
99
+ code = "".join([str(secrets.randbelow(10)) for _ in range(AUTH_CODE_LENGTH)])
100
+
101
+ self._codes[code] = {
102
+ "user_id": user_id,
103
+ "email": email,
104
+ "access_token": access_token,
105
+ "refresh_token": refresh_token,
106
+ "created_at": time.time(),
107
+ }
108
+
109
+ return code
110
+
111
+ def verify_code(
112
+ self,
113
+ code: str,
114
+ client_ip: str = "unknown"
115
+ ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
116
+ """
117
+ ์ฝ”๋“œ ๊ฒ€์ฆ ๋ฐ ์†Œ๋น„.
118
+
119
+ Args:
120
+ code: 6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ
121
+ client_ip: ํด๋ผ์ด์–ธํŠธ IP (rate limiting์šฉ)
122
+
123
+ Returns:
124
+ (์œ ํšจ์—ฌ๋ถ€, code_data, error_message)
125
+ """
126
+ self._cleanup()
127
+
128
+ # Rate limiting ์ฒดํฌ
129
+ if self._attempts[client_ip] >= AUTH_CODE_MAX_ATTEMPTS:
130
+ logger.warning(f"Rate limit exceeded for IP: {client_ip[:20]}...")
131
+ return False, None, f"๋„ˆ๋ฌด ๋งŽ์€ ์‹œ๋„์ž…๋‹ˆ๋‹ค. {self.ttl}์ดˆ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”."
132
+
133
+ self._attempts[client_ip] += 1
134
+
135
+ # ์ฝ”๋“œ ์ •๊ทœํ™” (๊ณต๋ฐฑ ์ œ๊ฑฐ ๋“ฑ)
136
+ code = code.strip().replace(" ", "").replace("-", "")
137
+
138
+ if code not in self._codes:
139
+ return False, None, "์ฝ”๋“œ๊ฐ€ ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ํ™•์ธํ•ด์ฃผ์„ธ์š”."
140
+
141
+ code_data = self._codes.pop(code)
142
+
143
+ if time.time() - code_data["created_at"] > self.ttl:
144
+ return False, None, "์ฝ”๋“œ๊ฐ€ ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ƒˆ ์ฝ”๋“œ๋ฅผ ์š”์ฒญํ•ด์ฃผ์„ธ์š”."
145
+
146
+ # ์„ฑ๊ณต ์‹œ ์‹œ๋„ ํšŸ์ˆ˜ ๋ฆฌ์…‹
147
+ self._attempts[client_ip] = 0
148
+
149
+ return True, code_data, None
150
+
151
+
152
+ class MagicLinkAuth:
153
+ """
154
+ Magic Link ์ธ์ฆ ํ•ธ๋“ค๋Ÿฌ.
155
+ """
156
+
157
+ def __init__(self):
158
+ self.code_store = AuthCodeStore()
159
+ self._http_client = None
160
+
161
+ async def _get_client(self) -> httpx.AsyncClient:
162
+ """HTTP ํด๋ผ์ด์–ธํŠธ (์žฌ์‚ฌ์šฉ)"""
163
+ if self._http_client is None:
164
+ self._http_client = httpx.AsyncClient(timeout=30)
165
+ return self._http_client
166
+
167
+ async def send_magic_link(self, email: str) -> Tuple[bool, Optional[str]]:
168
+ """
169
+ Magic Link ๏ฟฝ๏ฟฝ๏ฟฝ๋ฉ”์ผ ๋ฐœ์†ก.
170
+
171
+ Args:
172
+ email: ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ
173
+
174
+ Returns:
175
+ (์„ฑ๊ณต์—ฌ๋ถ€, error_message)
176
+ """
177
+ try:
178
+ client = await self._get_client()
179
+
180
+ response = await client.post(
181
+ f"{SUPABASE_URL}/auth/v1/magiclink",
182
+ json={
183
+ "email": email,
184
+ "options": {
185
+ "redirectTo": MAGIC_LINK_REDIRECT_URL
186
+ }
187
+ },
188
+ headers={
189
+ "apikey": SUPABASE_KEY,
190
+ "Content-Type": "application/json"
191
+ }
192
+ )
193
+
194
+ if response.status_code == 200:
195
+ logger.info(f"Magic Link ๋ฐœ์†ก ์„ฑ๊ณต: {email[:3]}***")
196
+ return True, None
197
+ else:
198
+ error_text = response.text[:200]
199
+ logger.error(f"Magic Link ๋ฐœ์†ก ์‹คํŒจ: {response.status_code} - {error_text}")
200
+ return False, f"์ด๋ฉ”์ผ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: {error_text}"
201
+
202
+ except Exception as e:
203
+ logger.error(f"Magic Link ๋ฐœ์†ก ์˜ค๋ฅ˜: {e}")
204
+ return False, f"์ด๋ฉ”์ผ ๋ฐœ์†ก ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
205
+
206
+ def generate_auth_code(
207
+ self,
208
+ user_id: str,
209
+ email: str,
210
+ access_token: str,
211
+ refresh_token: str
212
+ ) -> str:
213
+ """
214
+ ์ธ์ฆ ์„ฑ๊ณต ํ›„ 6์ž๋ฆฌ ์ฝ”๋“œ ์ƒ์„ฑ.
215
+
216
+ Magic Link ํด๋ฆญ โ†’ /auth/callback์—์„œ ํ˜ธ์ถœ.
217
+ """
218
+ return self.code_store.generate_code(user_id, email, access_token, refresh_token)
219
+
220
+ def verify_auth_code(
221
+ self,
222
+ code: str,
223
+ client_ip: str = "unknown"
224
+ ) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]:
225
+ """
226
+ ์ธ์ฆ ์ฝ”๋“œ ๊ฒ€์ฆ.
227
+
228
+ Args:
229
+ code: 6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ
230
+ client_ip: ํด๋ผ์ด์–ธํŠธ IP
231
+
232
+ Returns:
233
+ (์œ ํšจ์—ฌ๋ถ€, session_token, email_masked, error_message)
234
+ """
235
+ is_valid, code_data, error = self.code_store.verify_code(code, client_ip)
236
+
237
+ if not is_valid:
238
+ return False, None, None, error
239
+
240
+ # ์„ธ์…˜ ์ƒ์„ฑ
241
+ from .session_manager import get_user_session_manager
242
+
243
+ session_manager = get_user_session_manager()
244
+ session_token = session_manager.create_session(
245
+ user_id=code_data["user_id"],
246
+ email=code_data["email"],
247
+ access_token=code_data["access_token"],
248
+ refresh_token=code_data["refresh_token"],
249
+ expires_in=3600
250
+ )
251
+
252
+ # ์ด๋ฉ”์ผ ๋งˆ์Šคํ‚น
253
+ email = code_data["email"]
254
+ if "@" in email:
255
+ local, domain = email.split("@", 1)
256
+ if len(local) <= 2:
257
+ masked = local[0] + "*"
258
+ else:
259
+ masked = local[0] + "*" * (len(local) - 2) + local[-1]
260
+ email_masked = f"{masked}@{domain}"
261
+ else:
262
+ email_masked = email
263
+
264
+ return True, session_token, email_masked, None
265
+
266
+
267
+ # ์ „์—ญ ์ธ์Šคํ„ด์Šค (Lazy loading)
268
+ _magic_link_auth = None
269
+
270
+
271
+ def get_magic_link_auth() -> MagicLinkAuth:
272
+ """MagicLinkAuth ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜"""
273
+ global _magic_link_auth
274
+ if _magic_link_auth is None:
275
+ _magic_link_auth = MagicLinkAuth()
276
+ return _magic_link_auth
src/auth/oauth_provider.py ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OAuth2 Provider for GPTs Actions
3
+ ================================
4
+
5
+ Supabase Auth๋ฅผ ๊ฐ์‹ธ๋Š” OAuth2 Provider ๋ ˆ์ด์–ด.
6
+ GPTs Actions๊ฐ€ ์š”๊ตฌํ•˜๋Š” /authorize, /token ์—”๋“œํฌ์ธํŠธ ์ œ๊ณต.
7
+ """
8
+
9
+ import secrets
10
+ import time
11
+ import logging
12
+ from typing import Dict, Any, Optional, Tuple
13
+ from urllib.parse import urlencode
14
+
15
+ import httpx
16
+
17
+ from .config import (
18
+ SUPABASE_URL,
19
+ SUPABASE_KEY,
20
+ OAUTH_CLIENT_ID,
21
+ OAUTH_CLIENT_SECRET,
22
+ SERVER_BASE_URL,
23
+ OAUTH_STATE_TTL_SECONDS,
24
+ )
25
+
26
+ logger = logging.getLogger("eodi.auth.oauth")
27
+
28
+
29
+ class OAuthStateManager:
30
+ """
31
+ OAuth state ํŒŒ๋ผ๋ฏธํ„ฐ ๊ด€๋ฆฌ (CSRF ๋ฐฉ์ง€).
32
+ """
33
+
34
+ MAX_STATES = 1000
35
+
36
+ def __init__(self, ttl_seconds: int = OAUTH_STATE_TTL_SECONDS):
37
+ self.ttl = ttl_seconds
38
+ self._states: Dict[str, Dict[str, Any]] = {}
39
+ self._last_cleanup = time.time()
40
+
41
+ def _cleanup_expired(self):
42
+ """๋งŒ๋ฃŒ๋œ state ์ •๋ฆฌ"""
43
+ now = time.time()
44
+ if now - self._last_cleanup < 60:
45
+ return
46
+
47
+ self._last_cleanup = now
48
+ expired = [
49
+ s for s, data in self._states.items()
50
+ if now - data["created_at"] > self.ttl
51
+ ]
52
+ for s in expired:
53
+ del self._states[s]
54
+
55
+ if expired:
56
+ logger.debug(f"๋งŒ๋ฃŒ๋œ state {len(expired)}๊ฐœ ์ •๋ฆฌ๋จ")
57
+
58
+ def create_state(self, redirect_uri: str, scope: str = "profile") -> str:
59
+ """
60
+ ์ƒˆ state ์ƒ์„ฑ.
61
+
62
+ Args:
63
+ redirect_uri: GPTs ์ฝœ๋ฐฑ URL
64
+ scope: ์š”์ฒญ ์Šค์ฝ”ํ”„
65
+
66
+ Returns:
67
+ state ๋ฌธ์ž์—ด
68
+ """
69
+ self._cleanup_expired()
70
+
71
+ # ๋ฉ”๋ชจ๋ฆฌ ๋ณดํ˜ธ
72
+ if len(self._states) >= self.MAX_STATES:
73
+ oldest = min(self._states.items(), key=lambda x: x[1]["created_at"])
74
+ del self._states[oldest[0]]
75
+
76
+ state = secrets.token_urlsafe(32)
77
+ self._states[state] = {
78
+ "redirect_uri": redirect_uri,
79
+ "scope": scope,
80
+ "created_at": time.time(),
81
+ }
82
+ return state
83
+
84
+ def validate_and_consume(self, state: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
85
+ """
86
+ state ๊ฒ€์ฆ ๋ฐ ์†Œ๋น„ (์ผํšŒ์šฉ).
87
+
88
+ Args:
89
+ state: ๊ฒ€์ฆํ•  state
90
+
91
+ Returns:
92
+ (์œ ํšจ์—ฌ๋ถ€, state_data)
93
+ """
94
+ self._cleanup_expired()
95
+
96
+ if state not in self._states:
97
+ logger.warning(f"State not found: {state[:10]}...")
98
+ return False, None
99
+
100
+ state_data = self._states.pop(state)
101
+
102
+ if time.time() - state_data["created_at"] > self.ttl:
103
+ logger.warning(f"State expired: {state[:10]}...")
104
+ return False, None
105
+
106
+ return True, state_data
107
+
108
+
109
+ class AuthCodeStore:
110
+ """
111
+ OAuth Authorization Code ์ž„์‹œ ์ €์žฅ์†Œ.
112
+
113
+ Supabase ์ธ์ฆ ์™„๋ฃŒ ํ›„ โ†’ GPTs์— ์ „๋‹ฌํ•  code ๊ด€๋ฆฌ.
114
+ """
115
+
116
+ CODE_TTL_SECONDS = 60 # 1๋ถ„ (๋งค์šฐ ์งง๊ฒŒ)
117
+ MAX_CODES = 500
118
+
119
+ def __init__(self):
120
+ self._codes: Dict[str, Dict[str, Any]] = {}
121
+ self._last_cleanup = time.time()
122
+
123
+ def _cleanup(self):
124
+ now = time.time()
125
+ if now - self._last_cleanup < 30:
126
+ return
127
+ self._last_cleanup = now
128
+
129
+ expired = [
130
+ c for c, data in self._codes.items()
131
+ if now - data["created_at"] > self.CODE_TTL_SECONDS
132
+ ]
133
+ for c in expired:
134
+ del self._codes[c]
135
+
136
+ def create_code(
137
+ self,
138
+ user_id: str,
139
+ email: str,
140
+ access_token: str,
141
+ refresh_token: str,
142
+ redirect_uri: str
143
+ ) -> str:
144
+ """Authorization code ์ƒ์„ฑ"""
145
+ self._cleanup()
146
+
147
+ if len(self._codes) >= self.MAX_CODES:
148
+ oldest = min(self._codes.items(), key=lambda x: x[1]["created_at"])
149
+ del self._codes[oldest[0]]
150
+
151
+ code = secrets.token_urlsafe(32)
152
+ self._codes[code] = {
153
+ "user_id": user_id,
154
+ "email": email,
155
+ "access_token": access_token,
156
+ "refresh_token": refresh_token,
157
+ "redirect_uri": redirect_uri,
158
+ "created_at": time.time(),
159
+ }
160
+ return code
161
+
162
+ def consume_code(
163
+ self,
164
+ code: str,
165
+ redirect_uri: str
166
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
167
+ """
168
+ Code ๊ฒ€์ฆ ๋ฐ ์†Œ๋น„.
169
+
170
+ Returns:
171
+ (์œ ํšจ์—ฌ๋ถ€, code_data)
172
+ """
173
+ self._cleanup()
174
+
175
+ if code not in self._codes:
176
+ return False, None
177
+
178
+ code_data = self._codes.pop(code)
179
+
180
+ # redirect_uri ๊ฒ€์ฆ
181
+ if code_data["redirect_uri"] != redirect_uri:
182
+ logger.warning("redirect_uri mismatch")
183
+ return False, None
184
+
185
+ if time.time() - code_data["created_at"] > self.CODE_TTL_SECONDS:
186
+ return False, None
187
+
188
+ return True, code_data
189
+
190
+
191
+ class OAuthProvider:
192
+ """
193
+ OAuth2 Authorization Code Flow Provider.
194
+
195
+ GPTs Actions ์—ฐ๋™์šฉ.
196
+
197
+ ํ๋ฆ„:
198
+ 1. GPTs โ†’ /oauth/authorize โ†’ Supabase ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€
199
+ 2. ์‚ฌ์šฉ์ž ๋กœ๊ทธ์ธ โ†’ Supabase โ†’ /oauth/callback
200
+ 3. /oauth/callback โ†’ GPTs callback URL๋กœ code ์ „๋‹ฌ
201
+ 4. GPTs โ†’ /oauth/token โ†’ access_token ๋ฐœ๊ธ‰
202
+ """
203
+
204
+ def __init__(self):
205
+ self.state_manager = OAuthStateManager()
206
+ self.code_store = AuthCodeStore()
207
+ self._http_client = None
208
+
209
+ @property
210
+ def is_configured(self) -> bool:
211
+ """OAuth ์„ค์ • ์™„๋ฃŒ ์—ฌ๋ถ€"""
212
+ return bool(OAUTH_CLIENT_SECRET)
213
+
214
+ async def _get_client(self) -> httpx.AsyncClient:
215
+ """HTTP ํด๋ผ์ด์–ธํŠธ (์žฌ์‚ฌ์šฉ)"""
216
+ if self._http_client is None:
217
+ self._http_client = httpx.AsyncClient(timeout=30)
218
+ return self._http_client
219
+
220
+ def get_authorization_url(
221
+ self,
222
+ redirect_uri: str,
223
+ scope: str = "profile"
224
+ ) -> Tuple[str, str]:
225
+ """
226
+ ๋‚ด๋ถ€ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ URL ์ƒ์„ฑ.
227
+
228
+ Args:
229
+ redirect_uri: GPTs ์ฝœ๋ฐฑ URL
230
+ scope: ์š”์ฒญ ์Šค์ฝ”ํ”„
231
+
232
+ Returns:
233
+ (authorization_url, state)
234
+ """
235
+ state = self.state_manager.create_state(redirect_uri, scope)
236
+
237
+ # ์ž์ฒด ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
238
+ params = {
239
+ "redirect_uri": redirect_uri,
240
+ "state": state,
241
+ "scope": scope,
242
+ }
243
+
244
+ auth_url = f"{SERVER_BASE_URL}/auth/login?{urlencode(params)}"
245
+ return auth_url, state
246
+
247
+ async def handle_supabase_callback(
248
+ self,
249
+ access_token: str,
250
+ refresh_token: str,
251
+ state: str
252
+ ) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]:
253
+ """
254
+ Supabase ๋กœ๊ทธ์ธ ์™„๋ฃŒ ํ›„ ์ฒ˜๋ฆฌ.
255
+
256
+ Args:
257
+ access_token: Supabase access_token
258
+ refresh_token: Supabase refresh_token
259
+ state: OAuth state
260
+
261
+ Returns:
262
+ (์„ฑ๊ณต์—ฌ๋ถ€, authorization_code, redirect_uri, error_message)
263
+ """
264
+ # state ๊ฒ€์ฆ
265
+ is_valid, state_data = self.state_manager.validate_and_consume(state)
266
+ if not is_valid:
267
+ return False, None, None, "Invalid or expired state"
268
+
269
+ redirect_uri = state_data["redirect_uri"]
270
+
271
+ # ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ
272
+ try:
273
+ client = await self._get_client()
274
+ response = await client.get(
275
+ f"{SUPABASE_URL}/auth/v1/user",
276
+ headers={"Authorization": f"Bearer {access_token}"}
277
+ )
278
+
279
+ if response.status_code != 200:
280
+ return False, None, None, "Failed to get user info"
281
+
282
+ user_data = response.json()
283
+ user_id = user_data.get("id")
284
+ email = user_data.get("email")
285
+
286
+ except Exception as e:
287
+ logger.error(f"User info ์กฐํšŒ ์‹คํŒจ: {e}")
288
+ return False, None, None, str(e)
289
+
290
+ # Authorization code ์ƒ์„ฑ
291
+ code = self.code_store.create_code(
292
+ user_id=user_id,
293
+ email=email,
294
+ access_token=access_token,
295
+ refresh_token=refresh_token,
296
+ redirect_uri=redirect_uri
297
+ )
298
+
299
+ return True, code, redirect_uri, None
300
+
301
+ async def exchange_code_for_token(
302
+ self,
303
+ code: str,
304
+ redirect_uri: str,
305
+ client_id: str,
306
+ client_secret: str
307
+ ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
308
+ """
309
+ Authorization code๋ฅผ access_token์œผ๋กœ ๊ตํ™˜.
310
+
311
+ Args:
312
+ code: Authorization code
313
+ redirect_uri: ์›๋ž˜ redirect_uri
314
+ client_id: OAuth ํด๋ผ์ด์–ธํŠธ ID
315
+ client_secret: OAuth ํด๋ผ์ด์–ธํŠธ Secret
316
+
317
+ Returns:
318
+ (์„ฑ๊ณต์—ฌ๋ถ€, token_response, error_message)
319
+ """
320
+ # ํด๋ผ์ด์–ธํŠธ ์ธ์ฆ
321
+ if client_id != OAUTH_CLIENT_ID:
322
+ return False, None, "Invalid client_id"
323
+ if client_secret != OAUTH_CLIENT_SECRET:
324
+ return False, None, "Invalid client_secret"
325
+
326
+ # code ๊ฒ€์ฆ ๋ฐ ์†Œ๋น„
327
+ is_valid, code_data = self.code_store.consume_code(code, redirect_uri)
328
+ if not is_valid:
329
+ return False, None, "Invalid or expired code"
330
+
331
+ # ์„ธ์…˜ ์ƒ์„ฑ
332
+ from .session_manager import get_user_session_manager
333
+
334
+ session_manager = get_user_session_manager()
335
+ session_token = session_manager.create_session(
336
+ user_id=code_data["user_id"],
337
+ email=code_data["email"],
338
+ access_token=code_data["access_token"],
339
+ refresh_token=code_data["refresh_token"],
340
+ expires_in=3600
341
+ )
342
+
343
+ # GPTs์— ๋ฐ˜ํ™˜ํ•  ํ† ํฐ ์‘๋‹ต
344
+ # ์ฃผ์˜: ์—ฌ๊ธฐ์„œ ๋ฐ˜ํ™˜ํ•˜๋Š” access_token์€ ์šฐ๋ฆฌ์˜ session_token
345
+ token_response = {
346
+ "access_token": session_token,
347
+ "token_type": "Bearer",
348
+ "expires_in": 3600,
349
+ "refresh_token": secrets.token_urlsafe(32), # ๋”๋ฏธ
350
+ "scope": "profile"
351
+ }
352
+
353
+ return True, token_response, None
354
+
355
+ async def refresh_token(
356
+ self,
357
+ refresh_token: str,
358
+ client_id: str,
359
+ client_secret: str
360
+ ) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
361
+ """
362
+ Refresh token์œผ๋กœ ์ƒˆ access_token ๋ฐœ๊ธ‰.
363
+
364
+ ํ˜„์žฌ ๊ตฌํ˜„์—์„œ๋Š” ๋ฏธ์ง€์› (MVP).
365
+ """
366
+ # TODO: Supabase refresh token์œผ๋กœ ์ƒˆ ํ† ํฐ ๋ฐœ๊ธ‰ ๊ตฌํ˜„
367
+ return False, None, "Token refresh not implemented yet"
368
+
369
+
370
+ # ์ „์—ญ ์ธ์Šคํ„ด์Šค (Lazy loading)
371
+ _oauth_provider = None
372
+
373
+
374
+ def get_oauth_provider() -> OAuthProvider:
375
+ """OAuthProvider ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜"""
376
+ global _oauth_provider
377
+ if _oauth_provider is None:
378
+ _oauth_provider = OAuthProvider()
379
+ return _oauth_provider
src/auth/routes.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication HTTP Routes
3
+ ==========================
4
+
5
+ OAuth2 ๋ฐ Magic Link ์ธ์ฆ ์—”๋“œํฌ์ธํŠธ.
6
+ server_streamable.py์˜ routes์— ํ™•์žฅ๋ฉ๋‹ˆ๋‹ค.
7
+ """
8
+
9
+ import logging
10
+ from urllib.parse import urlencode
11
+
12
+ from starlette.routing import Route
13
+ from starlette.requests import Request
14
+ from starlette.responses import JSONResponse, RedirectResponse, HTMLResponse
15
+
16
+ from .oauth_provider import get_oauth_provider
17
+ from .magic_link import get_magic_link_auth
18
+ from .config import SERVER_BASE_URL, SUPABASE_URL
19
+
20
+ logger = logging.getLogger("eodi.auth.routes")
21
+
22
+
23
+ # =============================================================================
24
+ # OAuth2 ์—”๋“œํฌ์ธํŠธ (GPTs์šฉ)
25
+ # =============================================================================
26
+
27
+ async def oauth_authorize(request: Request) -> RedirectResponse:
28
+ """
29
+ GET /oauth/authorize
30
+
31
+ GPTs OAuth ์‹œ์ž‘์ . ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ.
32
+ """
33
+ oauth_provider = get_oauth_provider()
34
+
35
+ if not oauth_provider.is_configured:
36
+ return JSONResponse({
37
+ "error": "OAuth not configured",
38
+ "message": "OAUTH_CLIENT_SECRET ํ™˜๊ฒฝ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."
39
+ }, status_code=503)
40
+
41
+ redirect_uri = request.query_params.get("redirect_uri")
42
+ scope = request.query_params.get("scope", "profile")
43
+ gpts_state = request.query_params.get("state", "")
44
+
45
+ if not redirect_uri:
46
+ return JSONResponse({"error": "redirect_uri is required"}, status_code=400)
47
+
48
+ # ๋‚ด๋ถ€ state ์ƒ์„ฑ (redirect_uri ์ €์žฅ์šฉ)
49
+ auth_url, internal_state = oauth_provider.get_authorization_url(redirect_uri, scope)
50
+
51
+ # GPTs state๋„ ํ•จ๊ป˜ ์ „๋‹ฌ
52
+ if gpts_state:
53
+ auth_url += f"&gpts_state={gpts_state}"
54
+
55
+ logger.info("OAuth authorize: redirect to login page")
56
+ return RedirectResponse(auth_url, status_code=302)
57
+
58
+
59
+ async def oauth_callback(request: Request):
60
+ """
61
+ GET /oauth/callback
62
+
63
+ (๋‚ด๋ถ€ ์—”๋“œํฌ์ธํŠธ - ์ง์ ‘ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Œ)
64
+ """
65
+ return JSONResponse({"message": "Use /auth/callback instead"})
66
+
67
+
68
+ async def oauth_token(request: Request) -> JSONResponse:
69
+ """
70
+ POST /oauth/token
71
+
72
+ Authorization code โ†’ Access token ๊ตํ™˜.
73
+ """
74
+ oauth_provider = get_oauth_provider()
75
+
76
+ if not oauth_provider.is_configured:
77
+ return JSONResponse({"error": "OAuth not configured"}, status_code=503)
78
+
79
+ # form-urlencoded ๋˜๋Š” JSON ๋ฐ”๋”” ์ฒ˜๋ฆฌ
80
+ content_type = request.headers.get("content-type", "")
81
+
82
+ if "application/x-www-form-urlencoded" in content_type:
83
+ form = await request.form()
84
+ grant_type = form.get("grant_type")
85
+ code = form.get("code")
86
+ redirect_uri = form.get("redirect_uri")
87
+ client_id = form.get("client_id")
88
+ client_secret = form.get("client_secret")
89
+ refresh_token = form.get("refresh_token")
90
+ else:
91
+ try:
92
+ body = await request.json()
93
+ grant_type = body.get("grant_type")
94
+ code = body.get("code")
95
+ redirect_uri = body.get("redirect_uri")
96
+ client_id = body.get("client_id")
97
+ client_secret = body.get("client_secret")
98
+ refresh_token = body.get("refresh_token")
99
+ except Exception:
100
+ return JSONResponse({"error": "invalid_request"}, status_code=400)
101
+
102
+ if grant_type == "authorization_code":
103
+ if not code or not redirect_uri:
104
+ return JSONResponse({"error": "invalid_request"}, status_code=400)
105
+
106
+ success, token_response, error = await oauth_provider.exchange_code_for_token(
107
+ code=code,
108
+ redirect_uri=redirect_uri,
109
+ client_id=client_id,
110
+ client_secret=client_secret
111
+ )
112
+
113
+ if success:
114
+ return JSONResponse(token_response)
115
+ else:
116
+ return JSONResponse(
117
+ {"error": "invalid_grant", "error_description": error},
118
+ status_code=400
119
+ )
120
+
121
+ elif grant_type == "refresh_token":
122
+ success, token_response, error = await oauth_provider.refresh_token(
123
+ refresh_token=refresh_token,
124
+ client_id=client_id,
125
+ client_secret=client_secret
126
+ )
127
+
128
+ if success:
129
+ return JSONResponse(token_response)
130
+ else:
131
+ return JSONResponse(
132
+ {"error": "invalid_grant", "error_description": error},
133
+ status_code=400
134
+ )
135
+
136
+ else:
137
+ return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
138
+
139
+
140
+ # =============================================================================
141
+ # Magic Link ์—”๋“œํฌ์ธํŠธ (MCP ํด๋ผ์ด์–ธํŠธ์šฉ)
142
+ # =============================================================================
143
+
144
+ async def auth_login_page(request: Request) -> HTMLResponse:
145
+ """
146
+ GET /auth/login
147
+
148
+ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€. ์ด๋ฉ”์ผ ์ž…๋ ฅ โ†’ Magic Link ๋ฐœ๏ฟฝ๏ฟฝ.
149
+ """
150
+ redirect_uri = request.query_params.get("redirect_uri", "")
151
+ state = request.query_params.get("state", "")
152
+ gpts_state = request.query_params.get("gpts_state", "")
153
+
154
+ html = f"""
155
+ <!DOCTYPE html>
156
+ <html>
157
+ <head>
158
+ <title>Eodi ๋กœ๊ทธ์ธ</title>
159
+ <meta charset="utf-8">
160
+ <meta name="viewport" content="width=device-width, initial-scale=1">
161
+ <style>
162
+ body {{
163
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
164
+ max-width: 400px;
165
+ margin: 50px auto;
166
+ padding: 20px;
167
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
168
+ min-height: 100vh;
169
+ box-sizing: border-box;
170
+ }}
171
+ .container {{
172
+ background: white;
173
+ padding: 40px 30px;
174
+ border-radius: 16px;
175
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
176
+ }}
177
+ h1 {{
178
+ color: #333;
179
+ font-size: 28px;
180
+ margin-bottom: 8px;
181
+ text-align: center;
182
+ }}
183
+ .subtitle {{
184
+ color: #666;
185
+ text-align: center;
186
+ margin-bottom: 30px;
187
+ }}
188
+ input[type="email"] {{
189
+ width: 100%;
190
+ padding: 14px;
191
+ border: 2px solid #e0e0e0;
192
+ border-radius: 10px;
193
+ font-size: 16px;
194
+ margin-bottom: 16px;
195
+ box-sizing: border-box;
196
+ transition: border-color 0.2s;
197
+ }}
198
+ input[type="email"]:focus {{
199
+ outline: none;
200
+ border-color: #667eea;
201
+ }}
202
+ button {{
203
+ width: 100%;
204
+ padding: 14px;
205
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
206
+ color: white;
207
+ border: none;
208
+ border-radius: 10px;
209
+ font-size: 16px;
210
+ font-weight: 600;
211
+ cursor: pointer;
212
+ transition: transform 0.2s, box-shadow 0.2s;
213
+ }}
214
+ button:hover {{
215
+ transform: translateY(-2px);
216
+ box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
217
+ }}
218
+ button:disabled {{
219
+ background: #ccc;
220
+ transform: none;
221
+ box-shadow: none;
222
+ }}
223
+ .message {{ margin-top: 16px; text-align: center; }}
224
+ .success {{ color: #10b981; }}
225
+ .error {{ color: #ef4444; }}
226
+ .info {{
227
+ color: #6b7280;
228
+ font-size: 13px;
229
+ margin-top: 20px;
230
+ text-align: center;
231
+ }}
232
+ </style>
233
+ </head>
234
+ <body>
235
+ <div class="container">
236
+ <h1>๐Ÿจ Eodi</h1>
237
+ <p class="subtitle">์—ฌํ–‰ ํ˜œํƒ์˜ ๋ชจ๋“  ๊ฒƒ</p>
238
+
239
+ <form id="loginForm">
240
+ <input type="hidden" name="redirect_uri" value="{redirect_uri}">
241
+ <input type="hidden" name="state" value="{state}">
242
+ <input type="hidden" name="gpts_state" value="{gpts_state}">
243
+ <input type="email" name="email" placeholder="์ด๋ฉ”์ผ ์ฃผ์†Œ" required>
244
+ <button type="submit" id="submitBtn">๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ</button>
245
+ </form>
246
+
247
+ <div id="message"></div>
248
+ <p class="info">๋กœ๊ทธ์ธ ๋งํฌ๋Š” 10๋ถ„๊ฐ„ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค.</p>
249
+ </div>
250
+
251
+ <script>
252
+ document.getElementById('loginForm').addEventListener('submit', async (e) => {{
253
+ e.preventDefault();
254
+ const form = e.target;
255
+ const email = form.email.value;
256
+ const button = document.getElementById('submitBtn');
257
+ const messageDiv = document.getElementById('message');
258
+
259
+ button.disabled = true;
260
+ button.textContent = '์ „์†ก ์ค‘...';
261
+ messageDiv.innerHTML = '';
262
+
263
+ try {{
264
+ const response = await fetch('/auth/send-link', {{
265
+ method: 'POST',
266
+ headers: {{'Content-Type': 'application/json'}},
267
+ body: JSON.stringify({{
268
+ email: email,
269
+ redirect_uri: form.redirect_uri.value,
270
+ state: form.state.value,
271
+ gpts_state: form.gpts_state.value
272
+ }})
273
+ }});
274
+
275
+ const data = await response.json();
276
+
277
+ if (data.success) {{
278
+ messageDiv.innerHTML = '<p class="message success">โœ… ์ด๋ฉ”์ผ์„ ํ™•์ธํ•ด์ฃผ์„ธ์š”!</p>';
279
+ button.textContent = '์ „์†ก ์™„๋ฃŒ';
280
+ }} else {{
281
+ messageDiv.innerHTML = '<p class="message error">โŒ ' + data.error + '</p>';
282
+ button.disabled = false;
283
+ button.textContent = '๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ';
284
+ }}
285
+ }} catch (err) {{
286
+ messageDiv.innerHTML = '<p class="message error">โŒ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</p>';
287
+ button.disabled = false;
288
+ button.textContent = '๋กœ๊ทธ์ธ ๋งํฌ ๋ฐ›๊ธฐ';
289
+ }}
290
+ }});
291
+ </script>
292
+ </body>
293
+ </html>
294
+ """
295
+ return HTMLResponse(html)
296
+
297
+
298
+ async def auth_send_link(request: Request) -> JSONResponse:
299
+ """
300
+ POST /auth/send-link
301
+
302
+ Magic Link ๋ฐœ์†ก API.
303
+ """
304
+ magic_link_auth = get_magic_link_auth()
305
+
306
+ try:
307
+ body = await request.json()
308
+ email = body.get("email")
309
+
310
+ if not email:
311
+ return JSONResponse(
312
+ {"success": False, "error": "์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."},
313
+ status_code=400
314
+ )
315
+
316
+ success, error = await magic_link_auth.send_magic_link(email)
317
+
318
+ if success:
319
+ return JSONResponse({"success": True})
320
+ else:
321
+ return JSONResponse({"success": False, "error": error}, status_code=400)
322
+
323
+ except Exception as e:
324
+ logger.error(f"Magic Link ๋ฐœ์†ก ์˜ค๋ฅ˜: {e}")
325
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
326
+
327
+
328
+ async def auth_callback_page(request: Request) -> HTMLResponse:
329
+ """
330
+ GET /auth/callback
331
+
332
+ Magic Link ํด๋ฆญ ํ›„ ๋žœ๋”ฉ ํŽ˜์ด์ง€.
333
+ Supabase๊ฐ€ URL fragment์— ํ† ํฐ์„ ํฌํ•จ.
334
+ """
335
+ html = """
336
+ <!DOCTYPE html>
337
+ <html>
338
+ <head>
339
+ <title>Eodi ์ธ์ฆ ์™„๋ฃŒ</title>
340
+ <meta charset="utf-8">
341
+ <meta name="viewport" content="width=device-width, initial-scale=1">
342
+ <style>
343
+ body {
344
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
345
+ max-width: 400px;
346
+ margin: 50px auto;
347
+ padding: 20px;
348
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
349
+ min-height: 100vh;
350
+ box-sizing: border-box;
351
+ }
352
+ .container {
353
+ background: white;
354
+ padding: 40px 30px;
355
+ border-radius: 16px;
356
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2);
357
+ text-align: center;
358
+ }
359
+ h1 { color: #333; font-size: 28px; margin-bottom: 10px; }
360
+ h2 { color: #333; font-size: 22px; margin-bottom: 10px; }
361
+ .code {
362
+ font-size: 42px;
363
+ font-weight: bold;
364
+ letter-spacing: 10px;
365
+ color: #667eea;
366
+ margin: 24px 0;
367
+ padding: 20px;
368
+ background: linear-gradient(135deg, #f0f4ff 0%, #e8ecff 100%);
369
+ border-radius: 12px;
370
+ }
371
+ .instruction {
372
+ color: #4b5563;
373
+ margin: 16px 0;
374
+ line-height: 1.6;
375
+ }
376
+ .timer {
377
+ color: #9ca3af;
378
+ font-size: 14px;
379
+ margin-top: 16px;
380
+ }
381
+ .error { color: #ef4444; }
382
+ .loading { color: #6b7280; }
383
+ </style>
384
+ </head>
385
+ <body>
386
+ <div class="container">
387
+ <h1>๐Ÿจ Eodi</h1>
388
+ <div id="content">
389
+ <p class="loading">์ธ์ฆ ์ฒ˜๋ฆฌ ์ค‘...</p>
390
+ </div>
391
+ </div>
392
+
393
+ <script>
394
+ async function processAuth() {
395
+ const contentDiv = document.getElementById('content');
396
+
397
+ // URL fragment์—์„œ ํ† ํฐ ์ถ”์ถœ
398
+ const hash = window.location.hash.substring(1);
399
+ const params = new URLSearchParams(hash);
400
+
401
+ const accessToken = params.get('access_token');
402
+ const refreshToken = params.get('refresh_token') || '';
403
+
404
+ if (!accessToken) {
405
+ contentDiv.innerHTML = `
406
+ <h2 class="error">โŒ ์ธ์ฆ ์‹คํŒจ</h2>
407
+ <p class="instruction">๋งํฌ๊ฐ€ ๋งŒ๋ฃŒ๋˜์—ˆ๊ฑฐ๋‚˜ ์ž˜๋ชป๋˜์—ˆ์Šต๋‹ˆ๋‹ค.<br>๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.</p>
408
+ `;
409
+ return;
410
+ }
411
+
412
+ try {
413
+ const response = await fetch('/auth/process-token', {
414
+ method: 'POST',
415
+ headers: {'Content-Type': 'application/json'},
416
+ body: JSON.stringify({
417
+ access_token: accessToken,
418
+ refresh_token: refreshToken
419
+ })
420
+ });
421
+
422
+ const data = await response.json();
423
+
424
+ if (data.success) {
425
+ contentDiv.innerHTML = `
426
+ <h2>โœ… ์ธ์ฆ ์™„๋ฃŒ!</h2>
427
+ <p class="instruction">์•„๋ž˜ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”</p>
428
+ <div class="code">${data.code}</div>
429
+ <p class="instruction">GPT ๋˜๋Š” Claude๋กœ ๋Œ์•„๊ฐ€์„œ<br>์ด ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”.</p>
430
+ <p class="timer">โฑ๏ธ ์ฝ”๋“œ๋Š” 3๋ถ„๊ฐ„ ์œ ํšจํ•ฉ๋‹ˆ๋‹ค</p>
431
+ `;
432
+ } else {
433
+ contentDiv.innerHTML = `
434
+ <h2 class="error">โŒ ์˜ค๋ฅ˜ ๋ฐœ์ƒ</h2>
435
+ <p class="instruction">${data.error}</p>
436
+ `;
437
+ }
438
+ } catch (err) {
439
+ contentDiv.innerHTML = `
440
+ <h2 class="error">โŒ ์˜ค๋ฅ˜ ๋ฐœ์ƒ</h2>
441
+ <p class="instruction">์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.</p>
442
+ `;
443
+ }
444
+ }
445
+
446
+ processAuth();
447
+ </script>
448
+ </body>
449
+ </html>
450
+ """
451
+ return HTMLResponse(html)
452
+
453
+
454
+ async def auth_process_token(request: Request) -> JSONResponse:
455
+ """
456
+ POST /auth/process-token
457
+
458
+ Supabase ํ† ํฐ์œผ๋กœ ์ธ์ฆ ์ฝ”๋“œ ์ƒ์„ฑ.
459
+ """
460
+ magic_link_auth = get_magic_link_auth()
461
+
462
+ try:
463
+ body = await request.json()
464
+ access_token = body.get("access_token")
465
+ refresh_token = body.get("refresh_token", "")
466
+
467
+ if not access_token:
468
+ return JSONResponse(
469
+ {"success": False, "error": "ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค."},
470
+ status_code=400
471
+ )
472
+
473
+ # ํ† ํฐ์—์„œ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ
474
+ import httpx
475
+
476
+ async with httpx.AsyncClient(timeout=30) as client:
477
+ response = await client.get(
478
+ f"{SUPABASE_URL}/auth/v1/user",
479
+ headers={"Authorization": f"Bearer {access_token}"}
480
+ )
481
+
482
+ if response.status_code != 200:
483
+ return JSONResponse(
484
+ {"success": False, "error": "์‚ฌ์šฉ์ž ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."},
485
+ status_code=400
486
+ )
487
+
488
+ user_data = response.json()
489
+ user_id = user_data.get("id")
490
+ email = user_data.get("email")
491
+
492
+ # ์ธ์ฆ ์ฝ”๋“œ ์ƒ์„ฑ
493
+ code = magic_link_auth.generate_auth_code(
494
+ user_id=user_id,
495
+ email=email,
496
+ access_token=access_token,
497
+ refresh_token=refresh_token
498
+ )
499
+
500
+ return JSONResponse({"success": True, "code": code})
501
+
502
+ except Exception as e:
503
+ logger.error(f"ํ† ํฐ ์ฒ˜๋ฆฌ ์˜ค๋ฅ˜: {e}")
504
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
505
+
506
+
507
+ async def auth_verify_code_api(request: Request) -> JSONResponse:
508
+ """
509
+ POST /auth/verify-code
510
+
511
+ ์ธ์ฆ ์ฝ”๋“œ ๊ฒ€์ฆ API (REST์šฉ).
512
+ """
513
+ magic_link_auth = get_magic_link_auth()
514
+
515
+ try:
516
+ body = await request.json()
517
+ code = body.get("code")
518
+
519
+ if not code:
520
+ return JSONResponse(
521
+ {"success": False, "error": "์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."},
522
+ status_code=400
523
+ )
524
+
525
+ # ํด๋ผ์ด์–ธํŠธ IP
526
+ client_ip = request.headers.get(
527
+ "X-Forwarded-For",
528
+ request.client.host if request.client else "unknown"
529
+ )
530
+ if "," in client_ip:
531
+ client_ip = client_ip.split(",")[0].strip()
532
+
533
+ success, session_token, email_masked, error = magic_link_auth.verify_auth_code(
534
+ code, client_ip
535
+ )
536
+
537
+ if success:
538
+ return JSONResponse({
539
+ "success": True,
540
+ "session_token": session_token,
541
+ "email_masked": email_masked,
542
+ "message": f"์ธ์ฆ ์™„๋ฃŒ! {email_masked}"
543
+ })
544
+ else:
545
+ return JSONResponse({"success": False, "error": error}, status_code=400)
546
+
547
+ except Exception as e:
548
+ logger.error(f"์ฝ”๋“œ ๊ฒ€์ฆ ์˜ค๋ฅ˜: {e}")
549
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
550
+
551
+
552
+ # =============================================================================
553
+ # ๋ผ์šฐํŠธ ๋ชฉ๋ก
554
+ # =============================================================================
555
+
556
+ auth_routes = [
557
+ # OAuth2 (GPTs์šฉ)
558
+ Route("/oauth/authorize", oauth_authorize, methods=["GET"]),
559
+ Route("/oauth/callback", oauth_callback, methods=["GET"]),
560
+ Route("/oauth/token", oauth_token, methods=["POST"]),
561
+
562
+ # Magic Link (MCP ํด๋ผ์ด์–ธํŠธ์šฉ)
563
+ Route("/auth/login", auth_login_page, methods=["GET"]),
564
+ Route("/auth/send-link", auth_send_link, methods=["POST"]),
565
+ Route("/auth/callback", auth_callback_page, methods=["GET"]),
566
+ Route("/auth/process-token", auth_process_token, methods=["POST"]),
567
+ Route("/auth/verify-code", auth_verify_code_api, methods=["POST"]),
568
+ ]
src/auth/session_manager.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Session Manager
3
+ ====================
4
+
5
+ ์‚ฌ์šฉ์ž ์ธ์ฆ ์„ธ์…˜ ๊ด€๋ฆฌ.
6
+
7
+ ์ฃผ์˜: ์ด ๋ชจ๋“ˆ์€ MCP ํ”„๋กœํ† ์ฝœ ์„ธ์…˜(server_streamable.py์˜ SessionManager)๊ณผ ๋ณ„๊ฐœ์ž…๋‹ˆ๋‹ค.
8
+ - MCP SessionManager: MCP ํ”„๋กœํ† ์ฝœ ํ†ต์‹ ์šฉ ์„ธ์…˜ ID ๊ด€๋ฆฌ
9
+ - UserSessionManager: ์‚ฌ์šฉ์ž ์ธ์ฆ ์ƒํƒœ ๋ฐ JWT ํ† ํฐ ๊ด€๋ฆฌ
10
+ """
11
+
12
+ import secrets
13
+ import time
14
+ import logging
15
+ from typing import Dict, Any, Optional
16
+ from dataclasses import dataclass, field
17
+
18
+ logger = logging.getLogger("eodi.auth.session")
19
+
20
+
21
+ @dataclass
22
+ class UserSession:
23
+ """์‚ฌ์šฉ์ž ์ธ์ฆ ์„ธ์…˜ ์ •๋ณด"""
24
+ user_id: str
25
+ email: str
26
+ access_token: str # Supabase access_token
27
+ refresh_token: str # Supabase refresh_token
28
+ expires_at: float # Unix timestamp
29
+ created_at: float = field(default_factory=time.time)
30
+
31
+ @property
32
+ def is_expired(self) -> bool:
33
+ """ํ† ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ (30์ดˆ ๋ฒ„ํผ)"""
34
+ return time.time() > (self.expires_at - 30)
35
+
36
+ @property
37
+ def email_masked(self) -> str:
38
+ """์ด๋ฉ”์ผ ๋งˆ์Šคํ‚น (u***@example.com)"""
39
+ if not self.email or "@" not in self.email:
40
+ return self.email or "unknown"
41
+
42
+ local, domain = self.email.split("@", 1)
43
+ if len(local) <= 2:
44
+ masked_local = local[0] + "*"
45
+ else:
46
+ masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
47
+ return f"{masked_local}@{domain}"
48
+
49
+ def to_dict(self) -> Dict[str, Any]:
50
+ """API ์‘๋‹ต์šฉ ๋”•์…”๋„ˆ๋ฆฌ (๋ฏผ๊ฐ์ •๋ณด ์ œ์™ธ)"""
51
+ return {
52
+ "user_id": self.user_id,
53
+ "email_masked": self.email_masked,
54
+ "is_expired": self.is_expired,
55
+ "created_at": self.created_at,
56
+ }
57
+
58
+
59
+ class UserSessionManager:
60
+ """
61
+ ์‚ฌ์šฉ์ž ์ธ์ฆ ์„ธ์…˜ ๊ด€๋ฆฌ์ž.
62
+
63
+ MCP Tool ํ˜ธ์ถœ์—์„œ ์‚ฌ์šฉ์ž๋ฅผ ์‹๋ณ„ํ•˜๊ธฐ ์œ„ํ•œ ์„ธ์…˜ ํ† ํฐ์„ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
64
+
65
+ ํ๋ฆ„:
66
+ 1. ์‚ฌ์šฉ์ž๊ฐ€ OAuth/Magic Link๋กœ ์ธ์ฆ
67
+ 2. Supabase ํ† ํฐ์„ ๋ฐ›์•„ ์„ธ์…˜ ์ƒ์„ฑ
68
+ 3. ์„ธ์…˜ ํ† ํฐ์„ LLM์—๊ฒŒ ๋ฐ˜ํ™˜
69
+ 4. ์ดํ›„ Tool ํ˜ธ์ถœ ์‹œ ์„ธ์…˜ ํ† ํฐ์„ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ
70
+ 5. ์„ธ์…˜ ํ† ํฐ์œผ๋กœ user_id ์กฐํšŒ โ†’ DB ์ ‘๊ทผ
71
+
72
+ ์ฃผ์˜: ๋ฉ”๋ชจ๋ฆฌ ๊ธฐ๋ฐ˜ ์ €์žฅ์ด๋ฏ€๋กœ ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์‹œ ์„ธ์…˜ ์†Œ์‹ค๋ฉ๋‹ˆ๋‹ค.
73
+ """
74
+
75
+ SESSION_TTL_SECONDS = 3600 # 1์‹œ๊ฐ„
76
+ MAX_SESSIONS = 1000 # ๋ฉ”๋ชจ๋ฆฌ ๋ณดํ˜ธ
77
+
78
+ def __init__(self):
79
+ self._sessions: Dict[str, UserSession] = {}
80
+ self._user_to_session: Dict[str, str] = {} # user_id -> session_token (์ตœ์‹ )
81
+ self._last_cleanup = time.time()
82
+
83
+ def _cleanup_expired(self):
84
+ """๋งŒ๋ฃŒ๋œ ์„ธ์…˜ ์ •๋ฆฌ"""
85
+ now = time.time()
86
+ if now - self._last_cleanup < 300: # 5๋ถ„๋งˆ๋‹ค
87
+ return
88
+
89
+ self._last_cleanup = now
90
+ expired = [
91
+ token for token, session in self._sessions.items()
92
+ if session.is_expired
93
+ ]
94
+
95
+ for token in expired:
96
+ session = self._sessions.pop(token, None)
97
+ if session:
98
+ # user_to_session ๋งคํ•‘๋„ ์ •๋ฆฌ
99
+ if self._user_to_session.get(session.user_id) == token:
100
+ del self._user_to_session[session.user_id]
101
+
102
+ if expired:
103
+ logger.debug(f"๋งŒ๋ฃŒ๋œ ์„ธ์…˜ {len(expired)}๊ฐœ ์ •๋ฆฌ๋จ")
104
+
105
+ def create_session(
106
+ self,
107
+ user_id: str,
108
+ email: str,
109
+ access_token: str,
110
+ refresh_token: str,
111
+ expires_in: int = 3600
112
+ ) -> str:
113
+ """
114
+ ์ƒˆ ์‚ฌ์šฉ์ž ์„ธ์…˜ ์ƒ์„ฑ.
115
+
116
+ Args:
117
+ user_id: Supabase Auth UUID
118
+ email: ์‚ฌ์šฉ์ž ์ด๋ฉ”์ผ
119
+ access_token: Supabase access_token
120
+ refresh_token: Supabase refresh_token
121
+ expires_in: ํ† ํฐ ๋งŒ๋ฃŒ๊นŒ์ง€ ์ดˆ
122
+
123
+ Returns:
124
+ session_token (LLM์ด ํ›„์† ํ˜ธ์ถœ์—์„œ ์‚ฌ์šฉ)
125
+ """
126
+ self._cleanup_expired()
127
+
128
+ # ๋ฉ”๋ชจ๋ฆฌ ๋ณดํ˜ธ
129
+ if len(self._sessions) >= self.MAX_SESSIONS:
130
+ # ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์„ธ์…˜ ์‚ญ์ œ
131
+ oldest_token = min(
132
+ self._sessions.keys(),
133
+ key=lambda t: self._sessions[t].created_at
134
+ )
135
+ old_session = self._sessions.pop(oldest_token)
136
+ if self._user_to_session.get(old_session.user_id) == oldest_token:
137
+ del self._user_to_session[old_session.user_id]
138
+ logger.warning("์„ธ์…˜ ํ•œ๋„ ์ดˆ๊ณผ, ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์„ธ์…˜ ์‚ญ์ œ")
139
+
140
+ # ๊ธฐ์กด ์„ธ์…˜์ด ์žˆ์œผ๋ฉด ๊ฐฑ์‹ 
141
+ existing_token = self._user_to_session.get(user_id)
142
+ if existing_token and existing_token in self._sessions:
143
+ session = self._sessions[existing_token]
144
+ session.access_token = access_token
145
+ session.refresh_token = refresh_token
146
+ session.expires_at = time.time() + expires_in
147
+ logger.info(f"์„ธ์…˜ ๊ฐฑ์‹ : {session.email_masked}")
148
+ return existing_token
149
+
150
+ # ์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ
151
+ session_token = secrets.token_urlsafe(32)
152
+ session = UserSession(
153
+ user_id=user_id,
154
+ email=email,
155
+ access_token=access_token,
156
+ refresh_token=refresh_token,
157
+ expires_at=time.time() + expires_in
158
+ )
159
+
160
+ self._sessions[session_token] = session
161
+ self._user_to_session[user_id] = session_token
162
+
163
+ logger.info(f"์ƒˆ ์„ธ์…˜ ์ƒ์„ฑ: {session.email_masked}")
164
+ return session_token
165
+
166
+ def get_session(self, session_token: str) -> Optional[UserSession]:
167
+ """
168
+ ์„ธ์…˜ ํ† ํฐ์œผ๋กœ ์„ธ์…˜ ์กฐํšŒ.
169
+
170
+ Args:
171
+ session_token: ์„ธ์…˜ ํ† ํฐ
172
+
173
+ Returns:
174
+ UserSession ๋˜๋Š” None (๋งŒ๋ฃŒ/๋ฏธ์กด์žฌ)
175
+ """
176
+ self._cleanup_expired()
177
+
178
+ session = self._sessions.get(session_token)
179
+ if session and not session.is_expired:
180
+ return session
181
+ return None
182
+
183
+ def get_user_id(self, session_token: str) -> Optional[str]:
184
+ """
185
+ ์„ธ์…˜ ํ† ํฐ์—์„œ user_id ์ถ”์ถœ.
186
+
187
+ Args:
188
+ session_token: ์„ธ์…˜ ํ† ํฐ
189
+
190
+ Returns:
191
+ user_id ๋˜๋Š” None
192
+ """
193
+ session = self.get_session(session_token)
194
+ return session.user_id if session else None
195
+
196
+ def invalidate_session(self, session_token: str) -> bool:
197
+ """
198
+ ์„ธ์…˜ ๋ฌดํšจํ™” (๋กœ๊ทธ์•„์›ƒ).
199
+
200
+ Args:
201
+ session_token: ์„ธ์…˜ ํ† ํฐ
202
+
203
+ Returns:
204
+ ์„ฑ๊ณต ์—ฌ๋ถ€
205
+ """
206
+ session = self._sessions.pop(session_token, None)
207
+ if session:
208
+ if self._user_to_session.get(session.user_id) == session_token:
209
+ del self._user_to_session[session.user_id]
210
+ logger.info(f"์„ธ์…˜ ๋ฌดํšจํ™”: {session.email_masked}")
211
+ return True
212
+ return False
213
+
214
+ def get_stats(self) -> Dict[str, Any]:
215
+ """์„ธ์…˜ ํ†ต๊ณ„"""
216
+ self._cleanup_expired()
217
+ return {
218
+ "total_sessions": len(self._sessions),
219
+ "unique_users": len(self._user_to_session),
220
+ }
221
+
222
+
223
+ # ์ „์—ญ ์ธ์Šคํ„ด์Šค (Lazy loading)
224
+ _user_session_manager = None
225
+
226
+
227
+ def get_user_session_manager() -> UserSessionManager:
228
+ """UserSessionManager ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜"""
229
+ global _user_session_manager
230
+ if _user_session_manager is None:
231
+ _user_session_manager = UserSessionManager()
232
+ return _user_session_manager
src/auth/token_validator.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JWT Token Validator
3
+ ==================
4
+
5
+ Supabase Auth๊ฐ€ ๋ฐœ๊ธ‰ํ•œ JWT ํ† ํฐ ๊ฒ€์ฆ.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, Any, Optional, Tuple
10
+ from datetime import datetime, timezone
11
+
12
+ try:
13
+ import jwt
14
+ except ImportError:
15
+ jwt = None # PyJWT ๋ฏธ์„ค์น˜ ์‹œ graceful ์ฒ˜๋ฆฌ
16
+
17
+ from .config import SUPABASE_JWT_SECRET
18
+
19
+ logger = logging.getLogger("eodi.auth.token_validator")
20
+
21
+
22
+ class TokenValidator:
23
+ """
24
+ JWT ํ† ํฐ ๊ฒ€์ฆ๊ธฐ.
25
+
26
+ Supabase Auth๊ฐ€ ๋ฐœ๊ธ‰ํ•œ access_token์˜ ์„œ๋ช… ๋ฐ ๋งŒ๋ฃŒ ๊ฒ€์ฆ.
27
+ ์„œ๋ฒ„ ์ธก์—์„œ ํ† ํฐ ์œ ํšจ์„ฑ์„ ํ™•์ธํ•  ๋•Œ ์‚ฌ์šฉ.
28
+ """
29
+
30
+ def __init__(self, jwt_secret: str = None):
31
+ """
32
+ Args:
33
+ jwt_secret: Supabase JWT Secret (์—†์œผ๋ฉด ํ™˜๊ฒฝ๋ณ€์ˆ˜์—์„œ ๋กœ๋“œ)
34
+ """
35
+ self.jwt_secret = jwt_secret or SUPABASE_JWT_SECRET
36
+ self._enabled = bool(self.jwt_secret) and jwt is not None
37
+
38
+ if jwt is None:
39
+ logger.warning("PyJWT๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. pip install PyJWT")
40
+ elif not self._enabled:
41
+ logger.warning("JWT Secret ๋ฏธ์„ค์ • - ํ† ํฐ ๊ฒ€์ฆ์ด ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค")
42
+
43
+ @property
44
+ def is_enabled(self) -> bool:
45
+ """ํ† ํฐ ๊ฒ€์ฆ ํ™œ์„ฑํ™” ์—ฌ๋ถ€"""
46
+ return self._enabled
47
+
48
+ def validate(self, token: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
49
+ """
50
+ JWT ํ† ํฐ ๊ฒ€์ฆ.
51
+
52
+ Args:
53
+ token: JWT access_token
54
+
55
+ Returns:
56
+ (์œ ํšจ์—ฌ๋ถ€, payload, error_message)
57
+ """
58
+ if not self._enabled:
59
+ # JWT Secret ๋ฏธ์„ค์ • ์‹œ ๊ฒ€์ฆ ์Šคํ‚ต (๊ฐœ๋ฐœ ํ™˜๊ฒฝ์šฉ)
60
+ logger.warning("JWT ๊ฒ€์ฆ ์Šคํ‚ต (Secret ๋ฏธ์„ค์ • ๋˜๋Š” PyJWT ๋ฏธ์„ค์น˜)")
61
+ return True, {"sub": "unknown"}, None
62
+
63
+ try:
64
+ payload = jwt.decode(
65
+ token,
66
+ self.jwt_secret,
67
+ algorithms=["HS256"],
68
+ options={"verify_aud": False} # Supabase๋Š” aud ํด๋ ˆ์ž„์ด ๋‹ค์–‘ํ•จ
69
+ )
70
+ return True, payload, None
71
+
72
+ except jwt.ExpiredSignatureError:
73
+ return False, None, "ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค"
74
+ except jwt.InvalidTokenError as e:
75
+ logger.warning(f"JWT ๊ฒ€์ฆ ์‹คํŒจ: {e}")
76
+ return False, None, f"์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค: {e}"
77
+
78
+ def extract_user_id(self, token: str) -> Optional[str]:
79
+ """
80
+ ํ† ํฐ์—์„œ user_id(sub ํด๋ ˆ์ž„) ์ถ”์ถœ.
81
+
82
+ Args:
83
+ token: JWT access_token
84
+
85
+ Returns:
86
+ user_id ๋˜๋Š” None
87
+ """
88
+ is_valid, payload, _ = self.validate(token)
89
+ if is_valid and payload:
90
+ return payload.get("sub")
91
+ return None
92
+
93
+ def is_expired(self, token: str) -> bool:
94
+ """
95
+ ํ† ํฐ ๋งŒ๋ฃŒ ์—ฌ๋ถ€ ํ™•์ธ.
96
+
97
+ Args:
98
+ token: JWT access_token
99
+
100
+ Returns:
101
+ ๋งŒ๋ฃŒ ์—ฌ๋ถ€
102
+ """
103
+ is_valid, _, error = self.validate(token)
104
+ return not is_valid and error and "๋งŒ๋ฃŒ" in error
105
+
106
+ def get_expiry_time(self, token: str) -> Optional[datetime]:
107
+ """
108
+ ํ† ํฐ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์ถ”์ถœ.
109
+
110
+ Args:
111
+ token: JWT access_token
112
+
113
+ Returns:
114
+ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ๋˜๋Š” None
115
+ """
116
+ if jwt is None:
117
+ return None
118
+
119
+ try:
120
+ # ๊ฒ€์ฆ ์—†์ด ํŽ˜์ด๋กœ๋“œ๋งŒ ๋””์ฝ”๋”ฉ
121
+ payload = jwt.decode(
122
+ token,
123
+ options={"verify_signature": False}
124
+ )
125
+ exp = payload.get("exp")
126
+ if exp:
127
+ return datetime.fromtimestamp(exp, tz=timezone.utc)
128
+ return None
129
+ except Exception:
130
+ return None
131
+
132
+
133
+ # ์ „์—ญ ์ธ์Šคํ„ด์Šค (Lazy loading)
134
+ _token_validator = None
135
+
136
+
137
+ def get_token_validator() -> TokenValidator:
138
+ """TokenValidator ์‹ฑ๊ธ€ํ†ค ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜"""
139
+ global _token_validator
140
+ if _token_validator is None:
141
+ _token_validator = TokenValidator()
142
+ return _token_validator
src/auth/tool_handlers.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User Tool Handlers
3
+ ==================
4
+
5
+ user_* MCP Tool์˜ ์‹ค์ œ ์‹คํ–‰ ๋กœ์ง.
6
+ server_streamable.py์˜ execute_tool_async์—์„œ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค.
7
+ """
8
+
9
+ import logging
10
+ from typing import Dict, Any
11
+
12
+ from .session_manager import get_user_session_manager
13
+ from .magic_link import get_magic_link_auth
14
+ from .config import SUPPORTED_CHAINS, TIER_ALIASES, SERVER_BASE_URL
15
+
16
+ logger = logging.getLogger("eodi.auth.tool_handlers")
17
+
18
+
19
+ async def execute_user_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
20
+ """
21
+ User Tool ์‹คํ–‰ ๋ผ์šฐํ„ฐ.
22
+
23
+ Args:
24
+ name: Tool ์ด๋ฆ„ (user_*)
25
+ arguments: Tool ์ธ์ž
26
+
27
+ Returns:
28
+ Tool ์‹คํ–‰ ๊ฒฐ๊ณผ
29
+ """
30
+ if name == "user_get_profile":
31
+ return await handle_get_profile(arguments)
32
+ elif name == "user_update_membership":
33
+ return await handle_update_membership(arguments)
34
+ elif name == "user_delete_membership":
35
+ return await handle_delete_membership(arguments)
36
+ elif name == "user_request_auth":
37
+ return await handle_request_auth(arguments)
38
+ elif name == "user_verify_code":
39
+ return await handle_verify_code(arguments)
40
+ else:
41
+ return {"success": False, "error": f"Unknown user tool: {name}"}
42
+
43
+
44
+ async def handle_get_profile(arguments: Dict[str, Any]) -> Dict[str, Any]:
45
+ """
46
+ user_get_profile Tool ํ•ธ๋“ค๋Ÿฌ.
47
+
48
+ ์„ธ์…˜ ํ† ํฐ์ด ์žˆ์œผ๋ฉด ํ”„๋กœํ•„ ๋ฐ˜ํ™˜, ์—†์œผ๋ฉด ์ธ์ฆ URL ๋ฐ˜ํ™˜.
49
+ """
50
+ session_token = arguments.get("session_token")
51
+ user_session_manager = get_user_session_manager()
52
+
53
+ if not session_token:
54
+ # ์ธ์ฆ๋˜์ง€ ์•Š์Œ โ†’ ์ธ์ฆ URL ์•ˆ๋‚ด
55
+ return {
56
+ "success": True,
57
+ "connected": False,
58
+ "auth_url": f"{SERVER_BASE_URL}/auth/login",
59
+ "message": "Eodi์— ์—ฐ๊ฒฐ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. auth_url์—์„œ ๋กœ๊ทธ์ธํ•˜๊ฑฐ๋‚˜, user_request_auth๋กœ ์ด๋ฉ”์ผ ์ธ์ฆ์„ ์‹œ์ž‘ํ•˜์„ธ์š”."
60
+ }
61
+
62
+ # ์„ธ์…˜ ๊ฒ€์ฆ
63
+ session = user_session_manager.get_session(session_token)
64
+ if not session:
65
+ return {
66
+ "success": True,
67
+ "connected": False,
68
+ "auth_url": f"{SERVER_BASE_URL}/auth/login",
69
+ "message": "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”."
70
+ }
71
+
72
+ # ๋ฉค๋ฒ„์‹ญ ์กฐํšŒ
73
+ try:
74
+ from src.db.supabase_adapter import SupabaseAdapter
75
+ adapter = SupabaseAdapter()
76
+
77
+ memberships = adapter.get_user_memberships(session.user_id)
78
+ profile = adapter.get_user_profile(session.user_id)
79
+ except Exception as e:
80
+ logger.error(f"ํ”„๋กœํ•„ ์กฐํšŒ ์˜ค๋ฅ˜: {e}")
81
+ memberships = []
82
+ profile = None
83
+
84
+ return {
85
+ "success": True,
86
+ "connected": True,
87
+ "user_id": session.user_id,
88
+ "profile": {
89
+ "email_masked": session.email_masked,
90
+ "preferred_airports": profile.get("preferred_airports", []) if profile else [],
91
+ "created_at": profile.get("created_at") if profile else None,
92
+ },
93
+ "memberships": [
94
+ {
95
+ "chain": m["chain"],
96
+ "tier": m["tier"],
97
+ "expires_at": m.get("expires_at"),
98
+ }
99
+ for m in memberships
100
+ ],
101
+ "memberships_count": len(memberships)
102
+ }
103
+
104
+
105
+ async def handle_update_membership(arguments: Dict[str, Any]) -> Dict[str, Any]:
106
+ """
107
+ user_update_membership Tool ํ•ธ๋“ค๋Ÿฌ.
108
+ """
109
+ session_token = arguments.get("session_token")
110
+ chain = arguments.get("chain", "").upper()
111
+ tier = arguments.get("tier", "")
112
+ user_session_manager = get_user_session_manager()
113
+
114
+ # ์ธ์ฆ ํ™•์ธ
115
+ if not session_token:
116
+ return {
117
+ "success": False,
118
+ "error": "session_token์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € user_get_profile์„ ํ˜ธ์ถœํ•˜์—ฌ ์ธ์ฆ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์„ธ์š”."
119
+ }
120
+
121
+ session = user_session_manager.get_session(session_token)
122
+ if not session:
123
+ return {
124
+ "success": False,
125
+ "error": "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”.",
126
+ "auth_url": f"{SERVER_BASE_URL}/auth/login"
127
+ }
128
+
129
+ # ์ฒด์ธ ์œ ํšจ์„ฑ
130
+ if chain not in SUPPORTED_CHAINS:
131
+ return {
132
+ "success": False,
133
+ "error": f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ฒด์ธ์ž…๋‹ˆ๋‹ค: {chain}",
134
+ "supported_chains": list(SUPPORTED_CHAINS.keys())
135
+ }
136
+
137
+ # ๋“ฑ๊ธ‰ ์ •๊ทœํ™”
138
+ tier_normalized = TIER_ALIASES.get(tier, tier)
139
+
140
+ # ๋“ฑ๊ธ‰ ์œ ํšจ์„ฑ
141
+ valid_tiers = SUPPORTED_CHAINS[chain]
142
+ tier_lower_map = {t.lower(): t for t in valid_tiers}
143
+
144
+ if tier_normalized.lower() not in tier_lower_map:
145
+ return {
146
+ "success": False,
147
+ "error": f"{chain}์˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ๋“ฑ๊ธ‰์ž…๋‹ˆ๋‹ค: {tier}",
148
+ "valid_tiers": valid_tiers
149
+ }
150
+
151
+ tier_final = tier_lower_map[tier_normalized.lower()]
152
+
153
+ # ๋ฉค๋ฒ„์‹ญ ์ €์žฅ
154
+ try:
155
+ from src.db.supabase_adapter import SupabaseAdapter
156
+ adapter = SupabaseAdapter()
157
+
158
+ # ๊ธฐ์กด ๋ฉค๋ฒ„์‹ญ ํ™•์ธ
159
+ existing = adapter.get_user_memberships(session.user_id)
160
+ is_update = any(m["chain"] == chain for m in existing)
161
+
162
+ result = adapter.upsert_membership(session.user_id, chain, tier_final)
163
+
164
+ if result:
165
+ action = "updated" if is_update else "created"
166
+ chain_names = {
167
+ "HILTON": "ํžํŠผ",
168
+ "MARRIOTT": "๋ฉ”๋ฆฌ์–ดํŠธ",
169
+ "IHG": "IHG",
170
+ "ACCOR": "์•„์ฝ”๋ฅด",
171
+ "HYATT": "ํ•˜์–ํŠธ",
172
+ }
173
+ chain_name = chain_names.get(chain, chain)
174
+
175
+ return {
176
+ "success": True,
177
+ "action": action,
178
+ "membership": {
179
+ "chain": chain,
180
+ "tier": tier_final,
181
+ },
182
+ "message": f"{chain_name} {tier_final} ๋“ฑ๊ธ‰์ด {'์ˆ˜์ •' if is_update else '๋“ฑ๋ก'}๋˜์—ˆ์Šต๋‹ˆ๋‹ค."
183
+ }
184
+ else:
185
+ return {
186
+ "success": False,
187
+ "error": "๋ฉค๋ฒ„์‹ญ ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”."
188
+ }
189
+ except Exception as e:
190
+ logger.error(f"๋ฉค๋ฒ„์‹ญ ์ €์žฅ ์˜ค๋ฅ˜: {e}")
191
+ return {
192
+ "success": False,
193
+ "error": f"๋ฉค๋ฒ„์‹ญ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
194
+ }
195
+
196
+
197
+ async def handle_delete_membership(arguments: Dict[str, Any]) -> Dict[str, Any]:
198
+ """
199
+ user_delete_membership Tool ํ•ธ๋“ค๋Ÿฌ.
200
+ """
201
+ session_token = arguments.get("session_token")
202
+ chain = arguments.get("chain", "").upper()
203
+ user_session_manager = get_user_session_manager()
204
+
205
+ # ์ธ์ฆ ํ™•์ธ
206
+ if not session_token:
207
+ return {
208
+ "success": False,
209
+ "error": "session_token์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
210
+ }
211
+
212
+ session = user_session_manager.get_session(session_token)
213
+ if not session:
214
+ return {
215
+ "success": False,
216
+ "error": "์„ธ์…˜์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ๋กœ๊ทธ์ธํ•ด์ฃผ์„ธ์š”."
217
+ }
218
+
219
+ # ์ฒด์ธ ์œ ํšจ์„ฑ
220
+ if chain not in SUPPORTED_CHAINS:
221
+ return {
222
+ "success": False,
223
+ "error": f"์ง€์›ํ•˜์ง€ ์•Š๋Š” ์ฒด์ธ์ž…๋‹ˆ๋‹ค: {chain}"
224
+ }
225
+
226
+ # ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ
227
+ try:
228
+ from src.db.supabase_adapter import SupabaseAdapter
229
+ adapter = SupabaseAdapter()
230
+
231
+ if adapter.delete_membership(session.user_id, chain):
232
+ chain_names = {
233
+ "HILTON": "ํžํŠผ",
234
+ "MARRIOTT": "๋ฉ”๋ฆฌ์–ดํŠธ",
235
+ "IHG": "IHG",
236
+ "ACCOR": "์•„์ฝ”๋ฅด",
237
+ "HYATT": "ํ•˜์–ํŠธ",
238
+ }
239
+ chain_name = chain_names.get(chain, chain)
240
+
241
+ return {
242
+ "success": True,
243
+ "message": f"{chain_name} ๋ฉค๋ฒ„์‹ญ์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."
244
+ }
245
+ else:
246
+ return {
247
+ "success": False,
248
+ "error": "๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
249
+ }
250
+ except Exception as e:
251
+ logger.error(f"๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์˜ค๋ฅ˜: {e}")
252
+ return {
253
+ "success": False,
254
+ "error": f"๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {e}"
255
+ }
256
+
257
+
258
+ async def handle_request_auth(arguments: Dict[str, Any]) -> Dict[str, Any]:
259
+ """
260
+ user_request_auth Tool ํ•ธ๋“ค๋Ÿฌ.
261
+
262
+ ์ด๋ฉ”์ผ๋กœ Magic Link ๋ฐœ์†ก.
263
+ """
264
+ email = arguments.get("email", "").strip().lower()
265
+ magic_link_auth = get_magic_link_auth()
266
+
267
+ if not email:
268
+ return {
269
+ "success": False,
270
+ "error": "์ด๋ฉ”์ผ ์ฃผ์†Œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."
271
+ }
272
+
273
+ # ๊ฐ„๋‹จํ•œ ์ด๋ฉ”์ผ ํ˜•์‹ ๊ฒ€์ฆ
274
+ if "@" not in email or "." not in email:
275
+ return {
276
+ "success": False,
277
+ "error": "์˜ฌ๋ฐ”๋ฅธ ์ด๋ฉ”์ผ ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค."
278
+ }
279
+
280
+ success, error = await magic_link_auth.send_magic_link(email)
281
+
282
+ if success:
283
+ # ์ด๋ฉ”์ผ ๋งˆ์Šคํ‚น
284
+ local, domain = email.split("@", 1)
285
+ if len(local) <= 2:
286
+ masked = local[0] + "*"
287
+ else:
288
+ masked = local[0] + "*" * (len(local) - 2) + local[-1]
289
+ email_masked = f"{masked}@{domain}"
290
+
291
+ return {
292
+ "success": True,
293
+ "message": f"{email_masked}์œผ๋กœ ๋กœ๊ทธ์ธ ๋งํฌ๋ฅผ ๋ณด๋ƒˆ์Šต๋‹ˆ๋‹ค.\n\n๐Ÿ“ง ์ด๋ฉ”์ผ์—์„œ ๋งํฌ๋ฅผ ํด๋ฆญํ•œ ํ›„,\nํ™”๋ฉด์— ํ‘œ์‹œ๋˜๋Š” 6์ž๋ฆฌ ์ฝ”๋“œ๋ฅผ user_verify_code๋กœ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.",
294
+ "next_step": "user_verify_code"
295
+ }
296
+ else:
297
+ return {
298
+ "success": False,
299
+ "error": error or "์ด๋ฉ”์ผ ๋ฐœ์†ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."
300
+ }
301
+
302
+
303
+ async def handle_verify_code(arguments: Dict[str, Any]) -> Dict[str, Any]:
304
+ """
305
+ user_verify_code Tool ํ•ธ๋“ค๋Ÿฌ.
306
+
307
+ 6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ ๊ฒ€์ฆ.
308
+ """
309
+ code = arguments.get("code", "").strip()
310
+ magic_link_auth = get_magic_link_auth()
311
+
312
+ if not code:
313
+ return {
314
+ "success": False,
315
+ "error": "์ธ์ฆ ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."
316
+ }
317
+
318
+ # ์ฝ”๋“œ ๊ธธ์ด ๊ฒ€์ฆ
319
+ code_clean = code.replace(" ", "").replace("-", "")
320
+ if len(code_clean) != 6 or not code_clean.isdigit():
321
+ return {
322
+ "success": False,
323
+ "error": "6์ž๋ฆฌ ์ˆซ์ž ์ฝ”๋“œ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”."
324
+ }
325
+
326
+ # client_ip๋Š” MCP Tool์—์„œ ์ง์ ‘ ์ ‘๊ทผ ๋ถˆ๊ฐ€ โ†’ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ
327
+ success, session_token, email_masked, error = magic_link_auth.verify_auth_code(
328
+ code_clean, "mcp_client"
329
+ )
330
+
331
+ if success:
332
+ return {
333
+ "success": True,
334
+ "connected": True,
335
+ "session_token": session_token,
336
+ "email_masked": email_masked,
337
+ "message": f"โœ… ์ธ์ฆ ์™„๋ฃŒ! {email_masked}\n\n์ด์ œ ๋ฉค๋ฒ„์‹ญ์„ ๋“ฑ๋กํ•ด๋ณผ๊นŒ์š”?\n์–ด๋–ค ํ˜ธํ…” ์ฒด์ธ ๋ฉค๋ฒ„์‹ญ์„ ๊ฐ€์ง€๊ณ  ๊ณ„์„ธ์š”?"
338
+ }
339
+ else:
340
+ return {
341
+ "success": False,
342
+ "error": error
343
+ }
src/db/supabase_adapter.py CHANGED
@@ -303,6 +303,158 @@ class SupabaseAdapter:
303
  }
304
  except Exception as e:
305
  return {"error": str(e)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
 
308
  # =============================================================================
 
303
  }
304
  except Exception as e:
305
  return {"error": str(e)}
306
+
307
+ # =========================================================================
308
+ # ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ (User Gate)
309
+ # =========================================================================
310
+
311
+ def get_user_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
312
+ """
313
+ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์กฐํšŒ.
314
+
315
+ Args:
316
+ user_id: Supabase Auth UUID
317
+
318
+ Returns:
319
+ ํ”„๋กœํ•„ ์ •๋ณด ๋˜๋Š” None
320
+ """
321
+ try:
322
+ result = self.client.table("user_profiles")\
323
+ .select("*")\
324
+ .eq("user_id", user_id)\
325
+ .execute()
326
+
327
+ return result.data[0] if result.data else None
328
+ except Exception as e:
329
+ print(f"โš ๏ธ ํ”„๋กœํ•„ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
330
+ return None
331
+
332
+ def upsert_user_profile(
333
+ self,
334
+ user_id: str,
335
+ preferred_airports: List[str] = None,
336
+ display_name: str = None
337
+ ) -> Optional[Dict[str, Any]]:
338
+ """
339
+ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ƒ์„ฑ/์ˆ˜์ •.
340
+
341
+ Args:
342
+ user_id: Supabase Auth UUID
343
+ preferred_airports: ์„ ํ˜ธ ๊ณตํ•ญ ์ฝ”๋“œ ๋ฆฌ์ŠคํŠธ
344
+ display_name: ํ‘œ์‹œ ์ด๋ฆ„
345
+
346
+ Returns:
347
+ ์ €์žฅ๋œ ํ”„๋กœํ•„ ๋˜๋Š” None
348
+ """
349
+ try:
350
+ data = {"user_id": user_id}
351
+ if preferred_airports is not None:
352
+ data["preferred_airports"] = preferred_airports
353
+ if display_name is not None:
354
+ data["display_name"] = display_name
355
+
356
+ result = self.client.table("user_profiles")\
357
+ .upsert(data, on_conflict="user_id")\
358
+ .execute()
359
+
360
+ return result.data[0] if result.data else None
361
+ except Exception as e:
362
+ print(f"โš ๏ธ ํ”„๋กœํ•„ ์ €์žฅ ์‹คํŒจ ({user_id}): {e}")
363
+ return None
364
+
365
+ # =========================================================================
366
+ # ๋ฉค๋ฒ„์‹ญ (User Gate)
367
+ # =========================================================================
368
+
369
+ def get_user_memberships(self, user_id: str) -> List[Dict[str, Any]]:
370
+ """
371
+ ์‚ฌ์šฉ์ž์˜ ๋ชจ๋“  ๋ฉค๋ฒ„์‹ญ ์กฐํšŒ.
372
+
373
+ Args:
374
+ user_id: Supabase Auth UUID
375
+
376
+ Returns:
377
+ ๋ฉค๋ฒ„์‹ญ ๋ฆฌ์ŠคํŠธ
378
+ """
379
+ try:
380
+ result = self.client.table("user_memberships")\
381
+ .select("*")\
382
+ .eq("user_id", user_id)\
383
+ .execute()
384
+
385
+ return result.data or []
386
+ except Exception as e:
387
+ print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์กฐํšŒ ์‹คํŒจ ({user_id}): {e}")
388
+ return []
389
+
390
+ def upsert_membership(
391
+ self,
392
+ user_id: str,
393
+ chain: str,
394
+ tier: str,
395
+ expires_at: str = None
396
+ ) -> Optional[Dict[str, Any]]:
397
+ """
398
+ ๋ฉค๋ฒ„์‹ญ ๋“ฑ๋ก/์ˆ˜์ •.
399
+
400
+ Args:
401
+ user_id: Supabase Auth UUID
402
+ chain: ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ (HILTON, MARRIOTT ๋“ฑ)
403
+ tier: ๋“ฑ๊ธ‰ (Gold, Platinum ๋“ฑ)
404
+ expires_at: ๋งŒ๋ฃŒ์ผ (์„ ํƒ, YYYY-MM-DD)
405
+
406
+ Returns:
407
+ ์ €์žฅ๋œ ๋ฉค๋ฒ„์‹ญ ๋˜๋Š” None
408
+ """
409
+ try:
410
+ data = {
411
+ "user_id": user_id,
412
+ "chain": chain.upper(),
413
+ "tier": tier
414
+ }
415
+ if expires_at:
416
+ data["expires_at"] = expires_at
417
+
418
+ # user_id + chain ๋ณตํ•ฉํ‚ค๋กœ upsert
419
+ # Supabase๋Š” on_conflict์— ๋ณตํ•ฉํ‚ค๋ฅผ ์ง์ ‘ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฏ€๋กœ
420
+ # ๋จผ์ € ์‚ญ์ œ ํ›„ ์‚ฝ์ž…ํ•˜๋Š” ๋ฐฉ์‹ ์‚ฌ์šฉ
421
+ self.client.table("user_memberships")\
422
+ .delete()\
423
+ .eq("user_id", user_id)\
424
+ .eq("chain", chain.upper())\
425
+ .execute()
426
+
427
+ result = self.client.table("user_memberships")\
428
+ .insert(data)\
429
+ .execute()
430
+
431
+ return result.data[0] if result.data else None
432
+ except Exception as e:
433
+ print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์ €์žฅ ์‹คํŒจ ({user_id}, {chain}): {e}")
434
+ return None
435
+
436
+ def delete_membership(self, user_id: str, chain: str) -> bool:
437
+ """
438
+ ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ.
439
+
440
+ Args:
441
+ user_id: Supabase Auth UUID
442
+ chain: ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ
443
+
444
+ Returns:
445
+ ์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€
446
+ """
447
+ try:
448
+ self.client.table("user_memberships")\
449
+ .delete()\
450
+ .eq("user_id", user_id)\
451
+ .eq("chain", chain.upper())\
452
+ .execute()
453
+
454
+ return True
455
+ except Exception as e:
456
+ print(f"โš ๏ธ ๋ฉค๋ฒ„์‹ญ ์‚ญ์ œ ์‹คํŒจ ({user_id}, {chain}): {e}")
457
+ return False
458
 
459
 
460
  # =============================================================================
src/mcp/server_streamable.py CHANGED
@@ -405,6 +405,100 @@ TOOLS = [
405
  },
406
  "required": []
407
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  }
409
  ]
410
 
@@ -696,8 +790,13 @@ async def execute_tool_async(name: str, arguments: Dict[str, Any]) -> Dict[str,
696
  return await convert_currency(**arguments)
697
  elif name == "get_travel_info":
698
  return await get_travel_info(**arguments)
699
- else:
700
- return {"success": False, "error": f"Unknown tool: {name}"}
 
 
 
 
 
701
 
702
 
703
  # ============================================================
@@ -1408,6 +1507,14 @@ routes = [
1408
  Route("/messages/", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
1409
  ]
1410
 
 
 
 
 
 
 
 
 
1411
  middleware = [
1412
  Middleware(
1413
  CORSMiddleware,
 
405
  },
406
  "required": []
407
  }
408
+ },
409
+ # ============================================================
410
+ # User Tools (User Gate - ์‚ฌ์šฉ์ž ์ธ์ฆ/๋ฉค๋ฒ„์‹ญ)
411
+ # ============================================================
412
+ {
413
+ "name": "user_get_profile",
414
+ "description": (
415
+ "ํ˜„์žฌ ์‚ฌ์šฉ์ž์˜ ํ”„๋กœํ•„๊ณผ ๋ฉค๋ฒ„์‹ญ ์ •๋ณด๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. "
416
+ "์—ฐ๊ฒฐ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ auth_url์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค."
417
+ ),
418
+ "inputSchema": {
419
+ "type": "object",
420
+ "properties": {
421
+ "session_token": {
422
+ "type": "string",
423
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ (์ด์ „ ์ธ์ฆ์—์„œ ๋ฐ›์€ ๊ฐ’)"
424
+ }
425
+ },
426
+ "required": []
427
+ }
428
+ },
429
+ {
430
+ "name": "user_update_membership",
431
+ "description": "ํ˜ธํ…” ์ฒด์ธ ๋ฉค๋ฒ„์‹ญ์„ ๋“ฑ๋กํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.",
432
+ "inputSchema": {
433
+ "type": "object",
434
+ "properties": {
435
+ "chain": {
436
+ "type": "string",
437
+ "enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
438
+ "description": "ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ"
439
+ },
440
+ "tier": {
441
+ "type": "string",
442
+ "description": "๋ฉค๋ฒ„์‹ญ ๋“ฑ๊ธ‰ (์˜ˆ: Gold, Platinum, Diamond)"
443
+ },
444
+ "session_token": {
445
+ "type": "string",
446
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
447
+ }
448
+ },
449
+ "required": ["chain", "tier", "session_token"]
450
+ }
451
+ },
452
+ {
453
+ "name": "user_delete_membership",
454
+ "description": "ํ˜ธํ…” ์ฒด์ธ ๋ฉค๋ฒ„์‹ญ์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.",
455
+ "inputSchema": {
456
+ "type": "object",
457
+ "properties": {
458
+ "chain": {
459
+ "type": "string",
460
+ "enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
461
+ "description": "์‚ญ์ œํ•  ํ˜ธํ…” ์ฒด์ธ ์ฝ”๋“œ"
462
+ },
463
+ "session_token": {
464
+ "type": "string",
465
+ "description": "์ธ์ฆ ์„ธ์…˜ ํ† ํฐ"
466
+ }
467
+ },
468
+ "required": ["chain", "session_token"]
469
+ }
470
+ },
471
+ {
472
+ "name": "user_request_auth",
473
+ "description": (
474
+ "Magic Link ์ธ์ฆ์„ ์š”์ฒญํ•ฉ๋‹ˆ๋‹ค. "
475
+ "์ด๋ฉ”์ผ๋กœ ๋กœ๊ทธ์ธ ๋งํฌ๊ฐ€ ๋ฐœ์†ก๋ฉ๋‹ˆ๋‹ค."
476
+ ),
477
+ "inputSchema": {
478
+ "type": "object",
479
+ "properties": {
480
+ "email": {
481
+ "type": "string",
482
+ "format": "email",
483
+ "description": "์ธ์ฆ์— ์‚ฌ์šฉํ•  ์ด๋ฉ”์ผ ์ฃผ์†Œ"
484
+ }
485
+ },
486
+ "required": ["email"]
487
+ }
488
+ },
489
+ {
490
+ "name": "user_verify_code",
491
+ "description": "Magic Link ์ธ์ฆ ํ›„ ๋ฐ›์€ 6์ž๋ฆฌ ์ฝ”๋“œ๋ฅผ ๊ฒ€์ฆํ•ฉ๋‹ˆ๋‹ค.",
492
+ "inputSchema": {
493
+ "type": "object",
494
+ "properties": {
495
+ "code": {
496
+ "type": "string",
497
+ "description": "6์ž๋ฆฌ ์ธ์ฆ ์ฝ”๋“œ"
498
+ }
499
+ },
500
+ "required": ["code"]
501
+ }
502
  }
503
  ]
504
 
 
790
  return await convert_currency(**arguments)
791
  elif name == "get_travel_info":
792
  return await get_travel_info(**arguments)
793
+
794
+ # User ๋„๊ตฌ (User Gate)
795
+ if name.startswith("user_"):
796
+ from src.auth.tool_handlers import execute_user_tool
797
+ return await execute_user_tool(name, arguments)
798
+
799
+ return {"success": False, "error": f"Unknown tool: {name}"}
800
 
801
 
802
  # ============================================================
 
1507
  Route("/messages/", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
1508
  ]
1509
 
1510
+ # Auth routes ํ™•์žฅ (User Gate)
1511
+ try:
1512
+ from src.auth.routes import auth_routes
1513
+ routes.extend(auth_routes)
1514
+ logger.info(f"Auth routes loaded: {len(auth_routes)} endpoints")
1515
+ except ImportError as e:
1516
+ logger.warning(f"Auth routes not loaded: {e}")
1517
+
1518
  middleware = [
1519
  Middleware(
1520
  CORSMiddleware,