Commit e2243ed6 authored by Your Name's avatar Your Name

fix: Split Codex OAuth2 device flow into non-blocking start/poll pattern

- Changed authenticate_with_device_flow() to request_device_code_flow() + poll_device_code_completion()
- /dashboard/codex/auth/start now returns immediately with verification URI and user code
- /dashboard/codex/auth/poll checks for completion status
- Fixed poll_device_code_token to raise exception for 403/404 (pending state)
- Dashboard JavaScript opens popup window with verification URI immediately
parent 4bba04b4
...@@ -241,88 +241,150 @@ class CodexOAuth2: ...@@ -241,88 +241,150 @@ class CodexOAuth2:
user_code: str, user_code: str,
interval: int = 5, interval: int = 5,
) -> dict: ) -> dict:
"""Poll for device code token.""" """
Poll for device code token once (non-blocking).
Returns:
Dict with token response on success
Raises:
Exception on non-403/404 errors
"""
url = f"{self.issuer}/api/accounts/deviceauth/token" url = f"{self.issuer}/api/accounts/deviceauth/token"
max_wait = timedelta(minutes=15)
start = datetime.now()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
while True: response = await client.post(
response = await client.post( url,
url, headers={"Content-Type": "application/json"},
headers={"Content-Type": "application/json"}, json={
json={ "device_auth_id": device_auth_id,
"device_auth_id": device_auth_id, "user_code": user_code,
"user_code": user_code, },
}, timeout=30.0
timeout=30.0 )
)
if response.status_code == 200:
if response.status_code == 200: return response.json()
return response.json()
# 403/404 means still pending - re-raise for caller to handle
if response.status_code in (403, 404): if response.status_code in (403, 404):
if datetime.now() - start > max_wait: raise Exception(f"Authorization pending (status {response.status_code})")
raise TimeoutError("Device auth timed out after 15 minutes")
await asyncio.sleep(interval) raise Exception(f"Device auth failed with status {response.status_code}: {response.text}")
continue
raise Exception(f"Device auth failed with status {response.status_code}")
async def authenticate_with_device_flow(self) -> Dict[str, Any]: async def request_device_code_flow(self) -> Dict[str, Any]:
""" """
Complete device authorization flow. Start device authorization flow - returns immediately with verification info.
Returns: Returns:
Dict with authentication result Dict with verification_uri, user_code, expires_in, interval
""" """
# Step 1: Request device code # Step 1: Request device code
device_resp = await self.request_device_code() device_resp = await self.request_device_code()
device_auth_id = device_resp["device_auth_id"] device_auth_id = device_resp["device_auth_id"]
user_code = device_resp["user_code"] user_code = device_resp["user_code"]
interval = int(device_resp.get("interval", 5)) interval = int(device_resp.get("interval", 5))
expires_in = 900 # 15 minutes
# Store device auth info in instance for polling
self._device_auth_id = device_auth_id
self._device_user_code = user_code
self._device_interval = interval
logger.info(f"CodexOAuth2: Device code initiated - user_code: {user_code}") logger.info(f"CodexOAuth2: Device code initiated - user_code: {user_code}")
# Step 2: Poll for token return {
token_resp = await self.poll_device_code_token(device_auth_id, user_code, interval) "device_auth_id": device_auth_id,
"user_code": user_code,
"verification_uri": f"{self.issuer}/codex/device",
"expires_in": expires_in,
"interval": interval,
}
async def poll_device_code_completion(self) -> Dict[str, Any]:
"""
Poll for device authorization completion.
# Step 3: Exchange for tokens Returns:
redirect_uri = f"{self.issuer}/deviceauth/callback" Dict with status: 'pending', 'approved', 'denied', 'expired'
tokens = await self.exchange_code_for_tokens( """
code=token_resp["authorization_code"], if not hasattr(self, '_device_auth_id') or not self._device_auth_id:
redirect_uri=redirect_uri, return {"status": "error", "error": "No device authorization in progress"}
code_verifier=token_resp["code_verifier"],
)
# Step 4: Optionally obtain API key
api_key = None
try: try:
api_key = await self.obtain_api_key(tokens["id_token"]) token_resp = await self.poll_device_code_token(
device_auth_id=self._device_auth_id,
user_code=self._device_user_code,
interval=1, # We control polling interval from outside
)
# Step 3: Exchange for tokens
redirect_uri = f"{self.issuer}/deviceauth/callback"
tokens = await self.exchange_code_for_tokens(
code=token_resp["authorization_code"],
redirect_uri=redirect_uri,
code_verifier=token_resp["code_verifier"],
)
# Step 4: Optionally obtain API key
api_key = None
try:
api_key = await self.obtain_api_key(tokens["id_token"])
except Exception as e:
logger.warning(f"CodexOAuth2: Failed to obtain API key: {e}")
# Step 5: Save credentials
credentials = {
"auth_mode": "codex",
"tokens": {
"id_token": tokens["id_token"],
"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
"account_id": tokens.get("account_id"),
},
"openai_api_key": api_key,
"last_refresh": datetime.utcnow().isoformat() + "Z",
}
self._save_credentials(credentials)
# Clear device auth info
self._device_auth_id = None
self._device_user_code = None
return {"status": "approved"}
except Exception as e: except Exception as e:
logger.warning(f"CodexOAuth2: Failed to obtain API key: {e}") error_msg = str(e)
# 403/404 means still pending
# Step 5: Save credentials if "403" in error_msg or "404" in error_msg or "pending" in error_msg.lower():
credentials = { return {"status": "pending"}
"auth_mode": "codex", elif "denied" in error_msg.lower():
"tokens": { return {"status": "denied", "error": "User denied authorization"}
"id_token": tokens["id_token"], elif "timed out" in error_msg.lower() or "expired" in error_msg.lower():
"access_token": tokens["access_token"], return {"status": "expired", "error": "Device authorization expired"}
"refresh_token": tokens["refresh_token"], else:
"account_id": tokens.get("account_id"), return {"status": "error", "error": error_msg}
},
"openai_api_key": api_key, async def authenticate_with_device_flow(self) -> Dict[str, Any]:
"last_refresh": datetime.utcnow().isoformat() + "Z", """
} Complete device authorization flow (blocking - waits for completion).
self._save_credentials(credentials)
return { Returns:
"type": "success", Dict with authentication result
"provider": "codex", """
"user_code": user_code, # Start the flow
"verification_uri": f"{self.issuer}/codex/device", device_info = await self.request_device_code_flow()
}
# Poll until completion
max_polls = int(device_info["expires_in"] / device_info["interval"])
for _ in range(max_polls):
await asyncio.sleep(device_info["interval"])
result = await self.poll_device_code_completion()
if result["status"] != "pending":
return result
return {"status": "expired", "error": "Device authorization expired"}
async def authenticate_with_browser_flow(self, port: int = DEFAULT_PORT) -> Dict[str, Any]: async def authenticate_with_browser_flow(self, port: int = DEFAULT_PORT) -> Dict[str, Any]:
""" """
......
...@@ -6571,7 +6571,7 @@ async def dashboard_kilo_auth_logout(request: Request): ...@@ -6571,7 +6571,7 @@ async def dashboard_kilo_auth_logout(request: Request):
# Codex OAuth2 authentication endpoints # Codex OAuth2 authentication endpoints
@app.post("/dashboard/codex/auth/start") @app.post("/dashboard/codex/auth/start")
async def dashboard_codex_auth_start(request: Request): async def dashboard_codex_auth_start(request: Request):
"""Start Codex OAuth2 Device Authorization Grant flow""" """Start Codex OAuth2 Device Authorization Grant flow - returns immediately with verification info"""
auth_check = require_dashboard_auth(request) auth_check = require_dashboard_auth(request)
if auth_check: if auth_check:
return auth_check return auth_check
...@@ -6594,28 +6594,23 @@ async def dashboard_codex_auth_start(request: Request): ...@@ -6594,28 +6594,23 @@ async def dashboard_codex_auth_start(request: Request):
# Create auth instance # Create auth instance
auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer) auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer)
# Initiate device authorization (async method) # Request device code (returns immediately)
device_auth = await auth.authenticate_with_device_flow() device_info = await auth.request_device_code_flow()
if not device_auth:
return JSONResponse(
status_code=500,
content={"success": False, "error": "Failed to initiate device authorization"}
)
# Store device code in session for polling # Store device info in session for polling
request.session['codex_device_code'] = device_auth.get('user_code') request.session['codex_device_auth_id'] = device_info.get('device_auth_id')
request.session['codex_provider'] = provider_key request.session['codex_provider'] = provider_key
request.session['codex_credentials_file'] = credentials_file request.session['codex_credentials_file'] = credentials_file
request.session['codex_issuer'] = issuer request.session['codex_issuer'] = issuer
request.session['codex_expires_at'] = time.time() + device_info.get('expires_in', 900)
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"user_code": device_auth.get('user_code'), "user_code": device_info.get('user_code'),
"verification_uri": device_auth.get('verification_uri', f'{issuer}/codex/device'), "verification_uri": device_info.get('verification_uri'),
"expires_in": 900, # 15 minutes "expires_in": device_info.get('expires_in', 900),
"interval": 5, "interval": device_info.get('interval', 5),
"message": f"Please visit {device_auth.get('verification_uri', f'{issuer}/codex/device')} and enter code: {device_auth.get('user_code')}" "message": f"Please visit {device_info.get('verification_uri')} and enter code: {device_info.get('user_code')}"
}) })
except Exception as e: except Exception as e:
...@@ -6634,7 +6629,21 @@ async def dashboard_codex_auth_poll(request: Request): ...@@ -6634,7 +6629,21 @@ async def dashboard_codex_auth_poll(request: Request):
return auth_check return auth_check
try: try:
# Check if authentication was completed # Check if expired
expires_at = request.session.get('codex_expires_at', 0)
if time.time() > expires_at:
request.session.pop('codex_device_auth_id', None)
request.session.pop('codex_provider', None)
request.session.pop('codex_credentials_file', None)
request.session.pop('codex_issuer', None)
request.session.pop('codex_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
credentials_file = request.session.get('codex_credentials_file', '~/.aisbf/codex_credentials.json') credentials_file = request.session.get('codex_credentials_file', '~/.aisbf/codex_credentials.json')
issuer = request.session.get('codex_issuer', 'https://auth.openai.com') issuer = request.session.get('codex_issuer', 'https://auth.openai.com')
...@@ -6644,25 +6653,58 @@ async def dashboard_codex_auth_poll(request: Request): ...@@ -6644,25 +6653,58 @@ async def dashboard_codex_auth_poll(request: Request):
# Create auth instance # Create auth instance
auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer) auth = CodexOAuth2(credentials_file=credentials_file, issuer=issuer)
# Check if authenticated # Poll for completion
if auth.is_authenticated(): result = await auth.poll_device_code_completion()
if result['status'] == 'approved':
# Clear session # Clear session
request.session.pop('codex_device_code', None) request.session.pop('codex_device_auth_id', None)
request.session.pop('codex_provider', None) request.session.pop('codex_provider', None)
request.session.pop('codex_credentials_file', None) request.session.pop('codex_credentials_file', None)
request.session.pop('codex_issuer', None) request.session.pop('codex_issuer', None)
request.session.pop('codex_expires_at', None)
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"status": "approved", "status": "approved",
"message": "Authentication completed successfully" "message": "Authentication completed successfully"
}) })
else: elif result['status'] == 'pending':
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"status": "pending", "status": "pending",
"message": "Waiting for user authorization" "message": "Waiting for user authorization"
}) })
elif result['status'] == 'denied':
request.session.pop('codex_device_auth_id', None)
request.session.pop('codex_provider', None)
request.session.pop('codex_credentials_file', None)
request.session.pop('codex_issuer', None)
request.session.pop('codex_expires_at', None)
return JSONResponse({
"success": False,
"status": "denied",
"error": "User denied authorization"
})
elif result['status'] == 'expired':
request.session.pop('codex_device_auth_id', None)
request.session.pop('codex_provider', None)
request.session.pop('codex_credentials_file', None)
request.session.pop('codex_issuer', None)
request.session.pop('codex_expires_at', None)
return JSONResponse({
"success": False,
"status": "expired",
"error": "Device authorization expired"
})
else:
return JSONResponse({
"success": False,
"status": "error",
"error": result.get('error', 'Unknown error')
})
except Exception as e: except Exception as e:
logger.error(f"Error polling Codex auth: {e}") logger.error(f"Error polling Codex auth: {e}")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment