11import logging
22
3- from fastapi import APIRouter , Header , HTTPException
3+ from fastapi import APIRouter , Header , HTTPException , Request
44from fastapi .responses import JSONResponse , RedirectResponse
55from http import HTTPStatus
66from typing import Optional
77
8+ from pydantic import ValidationError as PydanticValidationError
9+
10+ from consts .model import OAuthCompleteRequest
811from consts .exceptions import OAuthLinkError , OAuthProviderError , UnauthorizedError
912from consts .oauth_providers import get_all_provider_definitions
1013from database .oauth_account_db import get_oauth_account_by_provider
1114from services .oauth_service import (
15+ complete_pending_oauth_account ,
1216 create_or_update_oauth_account ,
1317 ensure_user_tenant_exists ,
1418 exchange_code_for_provider_token ,
19+ find_supabase_user_id_by_email ,
20+ generate_pending_oauth_token ,
1521 get_authorize_url ,
1622 get_enabled_providers ,
23+ get_pending_oauth_info ,
1724 get_provider_user_info ,
1825 list_linked_accounts ,
19- unlink_account , parse_state ,
26+ parse_state ,
27+ unlink_account ,
2028)
2129from utils .auth_utils import (
2230 calculate_expires_at ,
2331 generate_session_jwt ,
24- get_current_user_id , get_supabase_admin_client ,
32+ get_current_user_id ,
33+ get_supabase_admin_client ,
2534)
2635
2736logger = logging .getLogger (__name__ )
@@ -142,44 +151,37 @@ async def callback(
142151 if existing_binding :
143152 supabase_user_id = existing_binding ["user_id" ]
144153 else :
145- # No binding found, search/create user by email in Supabase
146- admin_client = get_supabase_admin_client ()
147- if not admin_client :
148- raise RuntimeError ("Supabase admin client not available" )
149-
150154 supabase_user_id = None
151- page = 1
152- while True :
153- users_resp = admin_client .auth .admin .list_users (
154- page = page , per_page = 100
155+ if email :
156+ admin_client = get_supabase_admin_client ()
157+ if not admin_client :
158+ raise RuntimeError ("Supabase admin client not available" )
159+ supabase_user_id = find_supabase_user_id_by_email (
160+ admin_client ,
161+ email ,
155162 )
156- users = users_resp if len (users_resp ) > 0 else []
157- if not users :
158- break
159- for u in users :
160- if u .email and u .email .lower () == email .lower ():
161- supabase_user_id = u .id
162- break
163- if supabase_user_id :
164- break
165- if len (users ) < 100 :
166- break
167- page += 1
168163
169164 if not supabase_user_id :
170- if not email :
171- email = f"{ provider } _{ provider_user_id } @oauth.nexent"
172- create_resp = admin_client .auth .admin .create_user (
173- {
174- "email" : email ,
175- "email_confirm" : True ,
176- "user_metadata" : {
177- "full_name" : username ,
165+ pending_token = generate_pending_oauth_token (
166+ provider = provider ,
167+ provider_user_id = provider_user_id ,
168+ provider_email = email ,
169+ provider_username = username ,
170+ )
171+ return JSONResponse (
172+ status_code = HTTPStatus .OK ,
173+ content = {
174+ "message" : "OAuth account information required" ,
175+ "data" : {
176+ "requires_account_completion" : True ,
177+ "pending_token" : pending_token ,
178178 "provider" : provider ,
179+ "provider_username" : username ,
180+ "provider_email" : email ,
181+ "email_required" : not bool (email ),
179182 },
180- }
183+ },
181184 )
182- supabase_user_id = create_resp .user .id
183185
184186 ensure_user_tenant_exists (user_id = supabase_user_id , email = email )
185187
@@ -214,6 +216,18 @@ async def callback(
214216 },
215217 )
216218
219+ except OAuthLinkError as e :
220+ logger .warning (f"OAuth callback link failed for provider={ provider } : { e } " )
221+ return JSONResponse (
222+ status_code = HTTPStatus .BAD_REQUEST ,
223+ content = {
224+ "message" : "OAuth account link failed" ,
225+ "data" : {
226+ "oauth_error" : "oauth_account_already_bound" ,
227+ "oauth_error_description" : "OAuth account is already bound to another user" ,
228+ },
229+ },
230+ )
217231 except Exception as e :
218232 logger .error (f"OAuth callback failed for provider={ provider } : { e } " )
219233 return JSONResponse (
@@ -228,6 +242,67 @@ async def callback(
228242 )
229243
230244
245+ @router .get ("/pending" )
246+ async def get_pending (
247+ pending_token : Optional [str ] = Header (None , alias = "X-OAuth-Pending-Token" ),
248+ ):
249+ try :
250+ pending = get_pending_oauth_info (pending_token or "" )
251+ return JSONResponse (
252+ status_code = HTTPStatus .OK ,
253+ content = {"message" : "success" , "data" : pending },
254+ )
255+ except OAuthLinkError as e :
256+ raise HTTPException (status_code = HTTPStatus .UNAUTHORIZED , detail = str (e ))
257+ except OAuthProviderError as e :
258+ raise HTTPException (status_code = HTTPStatus .INTERNAL_SERVER_ERROR , detail = str (e ))
259+ except Exception as e :
260+ logger .error (f"Failed to get pending OAuth info: { e } " )
261+ raise HTTPException (
262+ status_code = HTTPStatus .INTERNAL_SERVER_ERROR ,
263+ detail = "Failed to get pending OAuth info" ,
264+ )
265+
266+
267+ @router .post ("/complete" )
268+ async def complete (
269+ request : Request ,
270+ pending_token : Optional [str ] = Header (None , alias = "X-OAuth-Pending-Token" ),
271+ ):
272+ try :
273+ request_data = OAuthCompleteRequest (** (await request .json ()))
274+ result = await complete_pending_oauth_account (
275+ pending_token = pending_token or "" ,
276+ email = str (request_data .email ) if request_data .email else None ,
277+ password = request_data .password ,
278+ invite_code = request_data .invite_code ,
279+ )
280+ return JSONResponse (
281+ status_code = HTTPStatus .OK ,
282+ content = {"message" : "OAuth account completed" , "data" : result },
283+ )
284+ except OAuthLinkError as e :
285+ status_code = (
286+ HTTPStatus .CONFLICT
287+ if "Email already exists" in str (e )
288+ else HTTPStatus .BAD_REQUEST
289+ )
290+ raise HTTPException (status_code = status_code , detail = str (e ))
291+ except PydanticValidationError as e :
292+ raise HTTPException (
293+ status_code = HTTPStatus .UNPROCESSABLE_ENTITY ,
294+ detail = e .errors (),
295+ )
296+ except OAuthProviderError as e :
297+ raise HTTPException (status_code = HTTPStatus .INTERNAL_SERVER_ERROR , detail = str (e ))
298+ except Exception as e :
299+ logger .error (f"Failed to complete OAuth account: { e } " )
300+ raise HTTPException (
301+ status_code = HTTPStatus .INTERNAL_SERVER_ERROR ,
302+ detail = "Failed to complete OAuth account" ,
303+ )
304+
305+
231306@router .get ("/accounts" )
232307async def get_accounts (authorization : Optional [str ] = Header (None )):
233308 if not authorization :
@@ -257,20 +332,7 @@ async def delete_account(provider: str, authorization: Optional[str] = Header(No
257332
258333 try :
259334 user_id , _ = get_current_user_id (authorization )
260-
261- has_password_auth = False
262-
263- admin_client = get_supabase_admin_client ()
264- if admin_client :
265- try :
266- user_resp = admin_client .auth .admin .get_user_by_id (user_id )
267- user_metadata = getattr (user_resp .user , "user_metadata" , {}) or {}
268- signup_provider = user_metadata .get ("provider" , "email" )
269- has_password_auth = signup_provider == "email"
270- except Exception as e :
271- logger .warning (f"Failed to check user identities for { user_id } : { e } " )
272-
273- unlink_account (user_id , provider , has_password_auth = has_password_auth )
335+ unlink_account (user_id , provider )
274336 return JSONResponse (
275337 status_code = HTTPStatus .OK ,
276338 content = {
0 commit comments