Spaces:
Runtime error
Runtime error
Update app/oauth_proxy/routes.py
Browse files- app/oauth_proxy/routes.py +36 -22
app/oauth_proxy/routes.py
CHANGED
|
@@ -3,11 +3,6 @@ GPTs OAuth Proxy Routes
|
|
| 3 |
=======================
|
| 4 |
|
| 5 |
GPTs Actions OAuth 플로우를 Seats.aero OAuth로 프록시
|
| 6 |
-
|
| 7 |
-
플로우:
|
| 8 |
-
1. GPTs -> /oauth2/authorize -> Seats.aero consent
|
| 9 |
-
2. Seats.aero -> /oauth2/callback -> GPTs callback
|
| 10 |
-
3. GPTs -> /oauth2/token -> Seats.aero token (프록시)
|
| 11 |
"""
|
| 12 |
|
| 13 |
import logging
|
|
@@ -35,6 +30,7 @@ from .config import (
|
|
| 35 |
logger = logging.getLogger("seats-proxy.oauth-proxy")
|
| 36 |
|
| 37 |
# State 저장소 (메모리 기반)
|
|
|
|
| 38 |
_state_store: Dict[str, dict] = {}
|
| 39 |
STATE_TTL = 600 # 10분
|
| 40 |
MAX_STATES = 1000
|
|
@@ -69,12 +65,22 @@ def _create_proxy_state(gpts_redirect_uri: str, gpts_state: str) -> str:
|
|
| 69 |
return proxy_state
|
| 70 |
|
| 71 |
|
| 72 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
"""State 조회 및 삭제 (일회용)"""
|
| 74 |
_cleanup_expired_states()
|
| 75 |
return _state_store.pop(proxy_state, None)
|
| 76 |
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
async def oauth2_authorize(request: Request):
|
| 79 |
"""GPTs OAuth 시작점"""
|
| 80 |
if not is_configured():
|
|
@@ -150,7 +156,7 @@ async def oauth2_callback(request: Request):
|
|
| 150 |
if error:
|
| 151 |
logger.error(f"OAuth error from Seats.aero: {error} - {error_description}")
|
| 152 |
if proxy_state:
|
| 153 |
-
state_data =
|
| 154 |
if state_data:
|
| 155 |
error_params = urlencode({
|
| 156 |
"error": error,
|
|
@@ -169,18 +175,21 @@ async def oauth2_callback(request: Request):
|
|
| 169 |
if not proxy_state:
|
| 170 |
return _error_html_response("missing_state", "State parameter not provided")
|
| 171 |
|
| 172 |
-
state_data =
|
| 173 |
if not state_data:
|
| 174 |
logger.warning(f"Invalid or expired proxy_state")
|
| 175 |
return _error_html_response("invalid_state", "State expired or invalid")
|
| 176 |
|
|
|
|
|
|
|
|
|
|
| 177 |
gpts_redirect_uri = state_data["gpts_redirect_uri"]
|
| 178 |
gpts_state = state_data["gpts_state"]
|
| 179 |
|
| 180 |
redirect_params = urlencode({"code": code, "state": gpts_state})
|
| 181 |
redirect_url = f"{gpts_redirect_uri}?{redirect_params}"
|
| 182 |
|
| 183 |
-
logger.info(f"Redirecting to GPTs callback")
|
| 184 |
return RedirectResponse(redirect_url, status_code=302)
|
| 185 |
|
| 186 |
|
|
@@ -197,14 +206,12 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 197 |
content_type = request.headers.get("content-type", "")
|
| 198 |
|
| 199 |
logger.info(f"Token request: content-type={content_type}")
|
| 200 |
-
logger.debug(f"Token request body: {body_bytes[:500]}")
|
| 201 |
|
| 202 |
# 파싱 시도
|
| 203 |
data = {}
|
| 204 |
|
| 205 |
try:
|
| 206 |
if "application/x-www-form-urlencoded" in content_type:
|
| 207 |
-
# Form 데이터 파싱
|
| 208 |
body_str = body_bytes.decode("utf-8")
|
| 209 |
parsed = parse_qs(body_str)
|
| 210 |
data = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
@@ -214,7 +221,6 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 214 |
data = json.loads(body_bytes)
|
| 215 |
logger.info(f"Parsed as JSON: {list(data.keys())}")
|
| 216 |
else:
|
| 217 |
-
# 둘 다 시도
|
| 218 |
body_str = body_bytes.decode("utf-8")
|
| 219 |
try:
|
| 220 |
import json
|
|
@@ -231,14 +237,13 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 231 |
"error_description": f"Could not parse request body: {str(e)}"
|
| 232 |
}, status_code=400)
|
| 233 |
|
| 234 |
-
# 파라미터 추출
|
| 235 |
grant_type = data.get("grant_type")
|
| 236 |
client_id = data.get("client_id")
|
| 237 |
client_secret = data.get("client_secret")
|
| 238 |
|
| 239 |
logger.info(f"Token params: grant_type={grant_type}, client_id={client_id}")
|
| 240 |
|
| 241 |
-
# 클라이언트 인증
|
| 242 |
if client_id and client_id != PROXY_CLIENT_ID:
|
| 243 |
logger.warning(f"Invalid client_id: {client_id}")
|
| 244 |
return JSONResponse({
|
|
@@ -246,7 +251,6 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 246 |
"error_description": "Unknown client_id"
|
| 247 |
}, status_code=401)
|
| 248 |
|
| 249 |
-
# 클라이언트 인증 - client_secret 검증
|
| 250 |
if client_secret and client_secret != PROXY_CLIENT_SECRET:
|
| 251 |
logger.warning("Invalid client_secret")
|
| 252 |
return JSONResponse({
|
|
@@ -254,7 +258,6 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 254 |
"error_description": "Invalid client_secret"
|
| 255 |
}, status_code=401)
|
| 256 |
|
| 257 |
-
# grant_type 검증
|
| 258 |
if not grant_type:
|
| 259 |
return JSONResponse({
|
| 260 |
"error": "invalid_request",
|
|
@@ -276,14 +279,24 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 276 |
"error_description": "code is required"
|
| 277 |
}, status_code=400)
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
seats_data["code"] = code
|
| 280 |
seats_data["redirect_uri"] = PROXY_CALLBACK_URL
|
|
|
|
| 281 |
seats_data["scope"] = "openid"
|
| 282 |
|
| 283 |
-
|
| 284 |
-
state = data.get("state")
|
| 285 |
-
if state:
|
| 286 |
-
seats_data["state"] = state
|
| 287 |
|
| 288 |
elif grant_type == "refresh_token":
|
| 289 |
refresh_token = data.get("refresh_token")
|
|
@@ -301,8 +314,8 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 301 |
"error_description": f"grant_type '{grant_type}' is not supported"
|
| 302 |
}, status_code=400)
|
| 303 |
|
| 304 |
-
logger.info(f"Calling Seats.aero token endpoint
|
| 305 |
-
logger.debug(f"Seats.aero request
|
| 306 |
|
| 307 |
# Seats.aero 토큰 엔드포인트 호출
|
| 308 |
async with httpx.AsyncClient(timeout=30) as client:
|
|
@@ -314,6 +327,7 @@ async def oauth2_token(request: Request) -> JSONResponse:
|
|
| 314 |
)
|
| 315 |
|
| 316 |
logger.info(f"Seats.aero response: {response.status_code}")
|
|
|
|
| 317 |
|
| 318 |
if response.status_code != 200:
|
| 319 |
logger.error(f"Seats.aero token error: {response.status_code} - {response.text[:500]}")
|
|
|
|
| 3 |
=======================
|
| 4 |
|
| 5 |
GPTs Actions OAuth 플로우를 Seats.aero OAuth로 프록시
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import logging
|
|
|
|
| 30 |
logger = logging.getLogger("seats-proxy.oauth-proxy")
|
| 31 |
|
| 32 |
# State 저장소 (메모리 기반)
|
| 33 |
+
# 구조: {proxy_state: {gpts_redirect_uri, gpts_state, seats_state, created_at}}
|
| 34 |
_state_store: Dict[str, dict] = {}
|
| 35 |
STATE_TTL = 600 # 10분
|
| 36 |
MAX_STATES = 1000
|
|
|
|
| 65 |
return proxy_state
|
| 66 |
|
| 67 |
|
| 68 |
+
def _get_state_data(proxy_state: str) -> Optional[dict]:
|
| 69 |
+
"""State 조회 (삭제하지 않음 - 토큰 교환에도 필요)"""
|
| 70 |
+
_cleanup_expired_states()
|
| 71 |
+
return _state_store.get(proxy_state)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _consume_state(proxy_state: str) -> Optional[dict]:
|
| 75 |
"""State 조회 및 삭제 (일회용)"""
|
| 76 |
_cleanup_expired_states()
|
| 77 |
return _state_store.pop(proxy_state, None)
|
| 78 |
|
| 79 |
|
| 80 |
+
# Authorization code와 state 매핑 저장 (토큰 교환 시 state 찾기용)
|
| 81 |
+
_code_to_state: Dict[str, str] = {}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
async def oauth2_authorize(request: Request):
|
| 85 |
"""GPTs OAuth 시작점"""
|
| 86 |
if not is_configured():
|
|
|
|
| 156 |
if error:
|
| 157 |
logger.error(f"OAuth error from Seats.aero: {error} - {error_description}")
|
| 158 |
if proxy_state:
|
| 159 |
+
state_data = _consume_state(proxy_state)
|
| 160 |
if state_data:
|
| 161 |
error_params = urlencode({
|
| 162 |
"error": error,
|
|
|
|
| 175 |
if not proxy_state:
|
| 176 |
return _error_html_response("missing_state", "State parameter not provided")
|
| 177 |
|
| 178 |
+
state_data = _get_state_data(proxy_state)
|
| 179 |
if not state_data:
|
| 180 |
logger.warning(f"Invalid or expired proxy_state")
|
| 181 |
return _error_html_response("invalid_state", "State expired or invalid")
|
| 182 |
|
| 183 |
+
# code와 proxy_state 매핑 저장 (토큰 교환 시 사용)
|
| 184 |
+
_code_to_state[code] = proxy_state
|
| 185 |
+
|
| 186 |
gpts_redirect_uri = state_data["gpts_redirect_uri"]
|
| 187 |
gpts_state = state_data["gpts_state"]
|
| 188 |
|
| 189 |
redirect_params = urlencode({"code": code, "state": gpts_state})
|
| 190 |
redirect_url = f"{gpts_redirect_uri}?{redirect_params}"
|
| 191 |
|
| 192 |
+
logger.info(f"Redirecting to GPTs callback, code mapped to proxy_state")
|
| 193 |
return RedirectResponse(redirect_url, status_code=302)
|
| 194 |
|
| 195 |
|
|
|
|
| 206 |
content_type = request.headers.get("content-type", "")
|
| 207 |
|
| 208 |
logger.info(f"Token request: content-type={content_type}")
|
|
|
|
| 209 |
|
| 210 |
# 파싱 시도
|
| 211 |
data = {}
|
| 212 |
|
| 213 |
try:
|
| 214 |
if "application/x-www-form-urlencoded" in content_type:
|
|
|
|
| 215 |
body_str = body_bytes.decode("utf-8")
|
| 216 |
parsed = parse_qs(body_str)
|
| 217 |
data = {k: v[0] if len(v) == 1 else v for k, v in parsed.items()}
|
|
|
|
| 221 |
data = json.loads(body_bytes)
|
| 222 |
logger.info(f"Parsed as JSON: {list(data.keys())}")
|
| 223 |
else:
|
|
|
|
| 224 |
body_str = body_bytes.decode("utf-8")
|
| 225 |
try:
|
| 226 |
import json
|
|
|
|
| 237 |
"error_description": f"Could not parse request body: {str(e)}"
|
| 238 |
}, status_code=400)
|
| 239 |
|
|
|
|
| 240 |
grant_type = data.get("grant_type")
|
| 241 |
client_id = data.get("client_id")
|
| 242 |
client_secret = data.get("client_secret")
|
| 243 |
|
| 244 |
logger.info(f"Token params: grant_type={grant_type}, client_id={client_id}")
|
| 245 |
|
| 246 |
+
# 클라이언트 인증
|
| 247 |
if client_id and client_id != PROXY_CLIENT_ID:
|
| 248 |
logger.warning(f"Invalid client_id: {client_id}")
|
| 249 |
return JSONResponse({
|
|
|
|
| 251 |
"error_description": "Unknown client_id"
|
| 252 |
}, status_code=401)
|
| 253 |
|
|
|
|
| 254 |
if client_secret and client_secret != PROXY_CLIENT_SECRET:
|
| 255 |
logger.warning("Invalid client_secret")
|
| 256 |
return JSONResponse({
|
|
|
|
| 258 |
"error_description": "Invalid client_secret"
|
| 259 |
}, status_code=401)
|
| 260 |
|
|
|
|
| 261 |
if not grant_type:
|
| 262 |
return JSONResponse({
|
| 263 |
"error": "invalid_request",
|
|
|
|
| 279 |
"error_description": "code is required"
|
| 280 |
}, status_code=400)
|
| 281 |
|
| 282 |
+
# code에서 proxy_state 찾기
|
| 283 |
+
proxy_state = _code_to_state.pop(code, None)
|
| 284 |
+
if not proxy_state:
|
| 285 |
+
logger.warning(f"No proxy_state found for code")
|
| 286 |
+
# proxy_state 없이도 시도해봄
|
| 287 |
+
proxy_state = ""
|
| 288 |
+
|
| 289 |
+
# state_data에서 정보 가져오기 (있으면)
|
| 290 |
+
if proxy_state:
|
| 291 |
+
state_data = _consume_state(proxy_state)
|
| 292 |
+
logger.info(f"Found state_data for token exchange")
|
| 293 |
+
|
| 294 |
seats_data["code"] = code
|
| 295 |
seats_data["redirect_uri"] = PROXY_CALLBACK_URL
|
| 296 |
+
seats_data["state"] = proxy_state # Seats.aero에 보냈던 원래 state
|
| 297 |
seats_data["scope"] = "openid"
|
| 298 |
|
| 299 |
+
logger.info(f"Token exchange: code={code[:20]}..., state={proxy_state[:10] if proxy_state else 'none'}...")
|
|
|
|
|
|
|
|
|
|
| 300 |
|
| 301 |
elif grant_type == "refresh_token":
|
| 302 |
refresh_token = data.get("refresh_token")
|
|
|
|
| 314 |
"error_description": f"grant_type '{grant_type}' is not supported"
|
| 315 |
}, status_code=400)
|
| 316 |
|
| 317 |
+
logger.info(f"Calling Seats.aero token endpoint")
|
| 318 |
+
logger.debug(f"Seats.aero request: {seats_data}")
|
| 319 |
|
| 320 |
# Seats.aero 토큰 엔드포인트 호출
|
| 321 |
async with httpx.AsyncClient(timeout=30) as client:
|
|
|
|
| 327 |
)
|
| 328 |
|
| 329 |
logger.info(f"Seats.aero response: {response.status_code}")
|
| 330 |
+
logger.debug(f"Seats.aero response body: {response.text[:500]}")
|
| 331 |
|
| 332 |
if response.status_code != 200:
|
| 333 |
logger.error(f"Seats.aero token error: {response.status_code} - {response.text[:500]}")
|