@@ -76,6 +76,36 @@ def _get_and_invalidate_mcp_token(token: str) -> Optional[str]:
7676 return user_id
7777
7878
79+ # ═══════════════════════════════════════════════════════════════════════════
80+ # Standard OAuth 2.0 Store (for ChatGPT UI)
81+ # ═══════════════════════════════════════════════════════════════════════════
82+ _oauth_auth_codes : Dict [str , Dict [str , Any ]] = {}
83+
84+ def _generate_auth_code (user_id : str ) -> str :
85+ """Generate a standard OAuth 2.0 authorization code."""
86+ alphabet = string .ascii_letters + string .digits
87+ code = "" .join (secrets .choice (alphabet ) for _ in range (32 ))
88+
89+ _oauth_auth_codes [code ] = {
90+ "user_id" : user_id ,
91+ "expires_at" : datetime .utcnow () + timedelta (minutes = 10 )
92+ }
93+ return code
94+
95+ def _get_and_invalidate_auth_code (code : str ) -> Optional [str ]:
96+ """Validate auth code and return user_id if valid."""
97+ if code not in _oauth_auth_codes :
98+ return None
99+
100+ data = _oauth_auth_codes [code ]
101+ del _oauth_auth_codes [code ] # Single-use
102+
103+ if datetime .utcnow () > data ["expires_at" ]:
104+ return None
105+
106+ return data ["user_id" ]
107+
108+
79109# ═══════════════════════════════════════════════════════════════════════════
80110# Pydantic Models
81111# ═══════════════════════════════════════════════════════════════════════════
@@ -142,6 +172,16 @@ class MCPExchangeResponse(BaseModel):
142172 user : dict
143173
144174
175+ class OAuthApproveRequest (BaseModel ):
176+ """Request from frontend to approve OAuth and get a code."""
177+ client_id : str
178+ redirect_uri : str
179+
180+ class OAuthApproveResponse (BaseModel ):
181+ """Response with the authorization code."""
182+ code : str
183+
184+
145185# ═══════════════════════════════════════════════════════════════════════════
146186# JWT Utilities
147187# ═══════════════════════════════════════════════════════════════════════════
@@ -475,3 +515,59 @@ async def exchange_mcp_token(request: MCPExchangeRequest):
475515 api_key = key_result ["key" ],
476516 user = user_response
477517 )
518+
519+
520+ # ═══════════════════════════════════════════════════════════════════════════
521+ # Standard OAuth 2.0 Routes (For ChatGPT UI)
522+ # ═══════════════════════════════════════════════════════════════════════════
523+
524+ @router .post ("/oauth/approve" , response_model = OAuthApproveResponse )
525+ async def oauth_approve (request : OAuthApproveRequest , current_user : dict = Depends (require_user )):
526+ """
527+ Called by the Next.js frontend when the user clicks 'Approve' on the consent screen.
528+ Generates an authorization code for standard OAuth 2.0 flow.
529+ """
530+ if not current_user :
531+ raise HTTPException (status_code = 401 , detail = "Authentication required" )
532+
533+ user_id = str (current_user .get ("id" ))
534+ code = _generate_auth_code (user_id )
535+ return OAuthApproveResponse (code = code )
536+
537+
538+ from fastapi import Form
539+ from fastapi .responses import JSONResponse
540+
541+ @router .post ("/oauth/token" )
542+ async def oauth_token (
543+ grant_type : str = Form (...),
544+ code : str = Form (None ),
545+ redirect_uri : str = Form (None ),
546+ client_id : str = Form (None )
547+ ):
548+ """
549+ Standard OAuth 2.0 token endpoint.
550+ ChatGPT calls this directly to exchange the authorization code for an access token.
551+ """
552+ if grant_type != "authorization_code" :
553+ return JSONResponse (status_code = 400 , content = {"error" : "unsupported_grant_type" })
554+
555+ if not code :
556+ return JSONResponse (status_code = 400 , content = {"error" : "invalid_request" , "error_description" : "code is required" })
557+
558+ user_id = _get_and_invalidate_auth_code (code )
559+ if not user_id :
560+ return JSONResponse (status_code = 400 , content = {"error" : "invalid_grant" , "error_description" : "Invalid or expired authorization code" })
561+
562+ # Generate a permanent API key acting as the access token
563+ key_result = api_key_store .create_api_key (
564+ user_id = user_id ,
565+ name = f"OAuth Client ({ client_id or 'Unknown' } ) - { datetime .utcnow ().strftime ('%Y-%m-%d' )} "
566+ )
567+
568+ return {
569+ "access_token" : key_result ["key" ],
570+ "token_type" : "Bearer" ,
571+ "expires_in" : 31536000 , # 1 year
572+ "scope" : "all"
573+ }
0 commit comments