- Updated docker-compose.yml to allow disabling embedded Ollama via environment variable. - Refactored Dockerfile and Dockerfile.dev for improved dependency management and installation process. - Enhanced FastAPI migration scripts to handle orphaned Alembic revisions and added new database migration logic. - Improved error handling in background tasks and Codex authentication endpoints. - Added support for font file uploads with better validation and extraction of font names. - Introduced new image search functionality with support for Pexels and Pixabay APIs.
336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""
|
|
OpenAI Codex OAuth endpoints.
|
|
|
|
Flow:
|
|
1. POST /codex/auth/initiate — start the flow, get back an auth URL + session_id
|
|
2. Browser opens the URL, user authenticates with OpenAI
|
|
3. OpenAI redirects to http://localhost:1455/auth/callback (captured by local server)
|
|
4. GET /codex/auth/status/{session_id} — poll until code captured; exchanges and stores tokens
|
|
5. POST /codex/auth/exchange — manual fallback if browser callback didn't fire
|
|
6. POST /codex/auth/refresh — refresh a stored token
|
|
"""
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from utils.oauth.openai_codex import (
|
|
CodexAccountProfile,
|
|
OAuthCallbackServer,
|
|
TokenSuccess,
|
|
create_authorization_flow,
|
|
exchange_authorization_code,
|
|
get_account_profile,
|
|
parse_authorization_input,
|
|
refresh_access_token,
|
|
)
|
|
from utils.get_env import (
|
|
get_codex_access_token_env,
|
|
get_codex_email_env,
|
|
get_codex_is_pro_env,
|
|
get_codex_refresh_token_env,
|
|
get_codex_token_expires_env,
|
|
get_codex_username_env,
|
|
)
|
|
from utils.set_env import (
|
|
set_codex_access_token_env,
|
|
set_codex_account_id_env,
|
|
set_codex_email_env,
|
|
set_codex_is_pro_env,
|
|
set_codex_refresh_token_env,
|
|
set_codex_token_expires_env,
|
|
set_codex_model_env,
|
|
set_codex_username_env,
|
|
)
|
|
from utils.user_config import save_codex_tokens_to_user_config
|
|
|
|
CODEX_AUTH_ROUTER = APIRouter(prefix="/codex/auth", tags=["Codex OAuth"])
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory session store {session_id: {"verifier": str, "state": str, "server": OAuthCallbackServer}}
|
|
# Sessions are short-lived; garbage-collected when consumed.
|
|
# ---------------------------------------------------------------------------
|
|
_sessions: dict[str, dict] = {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request / Response models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class InitiateResponse(BaseModel):
|
|
session_id: str
|
|
url: str
|
|
instructions: str
|
|
|
|
|
|
class StatusResponse(BaseModel):
|
|
status: str # "pending" | "success" | "failed"
|
|
account_id: Optional[str] = None
|
|
username: Optional[str] = None
|
|
email: Optional[str] = None
|
|
is_pro: Optional[bool] = None
|
|
detail: Optional[str] = None
|
|
|
|
|
|
class ExchangeRequest(BaseModel):
|
|
session_id: str
|
|
code: str # raw code OR full redirect URL OR code#state shorthand
|
|
|
|
|
|
class ExchangeResponse(BaseModel):
|
|
account_id: Optional[str] = None
|
|
username: Optional[str] = None
|
|
email: Optional[str] = None
|
|
is_pro: Optional[bool] = None
|
|
|
|
|
|
class RefreshResponse(BaseModel):
|
|
account_id: Optional[str]
|
|
username: Optional[str] = None
|
|
email: Optional[str] = None
|
|
is_pro: Optional[bool] = None
|
|
detail: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_optional_bool(value: Optional[str]) -> Optional[bool]:
|
|
if value is None:
|
|
return None
|
|
normalized = value.strip().lower()
|
|
if normalized in {"true", "1", "yes", "y"}:
|
|
return True
|
|
if normalized in {"false", "0", "no", "n"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _store_token(result: TokenSuccess) -> CodexAccountProfile:
|
|
"""Persist token fields in env vars and userConfig.json. Returns parsed profile."""
|
|
set_codex_access_token_env(result.access)
|
|
set_codex_refresh_token_env(result.refresh)
|
|
set_codex_token_expires_env(str(result.expires))
|
|
|
|
profile = get_account_profile(result.access, result.id_token)
|
|
set_codex_account_id_env(profile.account_id or "")
|
|
set_codex_username_env(profile.username or "")
|
|
set_codex_email_env(profile.email or "")
|
|
set_codex_is_pro_env("" if profile.is_pro is None else str(profile.is_pro))
|
|
|
|
save_codex_tokens_to_user_config()
|
|
return profile
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@CODEX_AUTH_ROUTER.post("/initiate", response_model=InitiateResponse)
|
|
async def initiate_codex_auth():
|
|
"""
|
|
Start the OpenAI Codex OAuth flow.
|
|
|
|
Returns an authorization URL to open in the browser and a session_id to use
|
|
when polling /status or calling /exchange. A local HTTP server is started
|
|
on port 1455 to receive the redirect automatically.
|
|
"""
|
|
flow = create_authorization_flow()
|
|
server = OAuthCallbackServer(state=flow.state)
|
|
server_started = server.start()
|
|
|
|
session_id = str(uuid.uuid4())
|
|
_sessions[session_id] = {
|
|
"verifier": flow.verifier,
|
|
"state": flow.state,
|
|
"server": server,
|
|
"server_started": server_started,
|
|
}
|
|
|
|
instructions = (
|
|
"Open the URL in your browser and complete the OpenAI login. "
|
|
+ (
|
|
"The callback will be captured automatically."
|
|
if server_started
|
|
else "Port 1455 could not be bound — paste the redirect URL or code into /exchange."
|
|
)
|
|
)
|
|
|
|
return InitiateResponse(
|
|
session_id=session_id,
|
|
url=flow.url,
|
|
instructions=instructions,
|
|
)
|
|
|
|
|
|
@CODEX_AUTH_ROUTER.get("/status/{session_id}", response_model=StatusResponse)
|
|
async def poll_codex_auth_status(session_id: str):
|
|
"""
|
|
Poll for the result of an ongoing OAuth flow.
|
|
|
|
Returns {"status": "pending"} until the callback server captures the code.
|
|
On success the tokens are stored in environment variables and the session
|
|
is cleaned up.
|
|
"""
|
|
session = _sessions.get(session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
|
|
|
server: OAuthCallbackServer = session["server"]
|
|
|
|
# Non-blocking peek — check whether the callback server already received a code
|
|
code = server.get_code_nowait() if session.get("server_started") else None
|
|
|
|
if code is None:
|
|
return StatusResponse(status="pending")
|
|
|
|
# We have a code — exchange it
|
|
verifier: str = session["verifier"]
|
|
result = exchange_authorization_code(code, verifier)
|
|
|
|
# Clean up session
|
|
server.close()
|
|
_sessions.pop(session_id, None)
|
|
|
|
if not isinstance(result, TokenSuccess):
|
|
return StatusResponse(status="failed", detail=result.reason)
|
|
|
|
profile = _store_token(result)
|
|
return StatusResponse(
|
|
status="success",
|
|
account_id=profile.account_id,
|
|
username=profile.username,
|
|
email=profile.email,
|
|
is_pro=profile.is_pro,
|
|
)
|
|
|
|
|
|
@CODEX_AUTH_ROUTER.post("/exchange", response_model=ExchangeResponse)
|
|
async def exchange_codex_code(body: ExchangeRequest):
|
|
"""
|
|
Manual code exchange fallback.
|
|
|
|
Accepts the session_id from /initiate and either:
|
|
- a bare authorization code
|
|
- the full redirect URL (http://localhost:1455/auth/callback?code=…&state=…)
|
|
- the code#state shorthand
|
|
|
|
Exchanges the code for tokens and stores them in environment variables.
|
|
"""
|
|
session = _sessions.get(body.session_id)
|
|
if not session:
|
|
raise HTTPException(status_code=404, detail="Session not found or already consumed")
|
|
|
|
parsed = parse_authorization_input(body.code)
|
|
code = parsed.get("code")
|
|
incoming_state = parsed.get("state")
|
|
|
|
if not code:
|
|
raise HTTPException(status_code=400, detail="Could not extract authorization code from input")
|
|
|
|
if incoming_state and incoming_state != session["state"]:
|
|
raise HTTPException(status_code=400, detail="State mismatch — possible CSRF")
|
|
|
|
verifier: str = session["verifier"]
|
|
server: OAuthCallbackServer = session["server"]
|
|
|
|
result = exchange_authorization_code(code, verifier)
|
|
|
|
server.close()
|
|
_sessions.pop(body.session_id, None)
|
|
|
|
if not isinstance(result, TokenSuccess):
|
|
raise HTTPException(status_code=502, detail=f"Token exchange failed: {result.reason}")
|
|
|
|
profile = _store_token(result)
|
|
if not profile.account_id:
|
|
raise HTTPException(status_code=502, detail="Token exchanged but could not extract account ID")
|
|
|
|
return ExchangeResponse(
|
|
account_id=profile.account_id,
|
|
username=profile.username,
|
|
email=profile.email,
|
|
is_pro=profile.is_pro,
|
|
)
|
|
|
|
|
|
@CODEX_AUTH_ROUTER.post("/refresh", response_model=RefreshResponse)
|
|
async def refresh_codex_token():
|
|
"""
|
|
Refresh the stored Codex OAuth access token using the refresh token.
|
|
|
|
Updates environment variables with the new tokens.
|
|
"""
|
|
refresh_token = get_codex_refresh_token_env()
|
|
if not refresh_token:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="No Codex refresh token stored. Please authenticate first via /initiate",
|
|
)
|
|
|
|
result = refresh_access_token(refresh_token)
|
|
if not isinstance(result, TokenSuccess):
|
|
raise HTTPException(status_code=502, detail=f"Token refresh failed: {result.reason}")
|
|
|
|
profile = _store_token(result)
|
|
return RefreshResponse(
|
|
account_id=profile.account_id,
|
|
username=profile.username,
|
|
email=profile.email,
|
|
is_pro=profile.is_pro,
|
|
detail="Token refreshed successfully",
|
|
)
|
|
|
|
|
|
@CODEX_AUTH_ROUTER.get("/status", response_model=StatusResponse)
|
|
async def get_codex_auth_status():
|
|
"""
|
|
Return whether a valid Codex OAuth token is currently stored.
|
|
"""
|
|
import time
|
|
|
|
access_token = get_codex_access_token_env()
|
|
if not access_token:
|
|
return StatusResponse(status="not_authenticated", detail="No access token stored")
|
|
|
|
expires_str = get_codex_token_expires_env()
|
|
if expires_str:
|
|
try:
|
|
expires_ms = int(expires_str)
|
|
now_ms = int(time.time() * 1000)
|
|
if now_ms >= expires_ms:
|
|
return StatusResponse(status="expired", detail="Access token has expired — call /refresh")
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
profile = get_account_profile(access_token)
|
|
return StatusResponse(
|
|
status="authenticated",
|
|
account_id=profile.account_id,
|
|
username=profile.username or get_codex_username_env(),
|
|
email=profile.email or get_codex_email_env(),
|
|
is_pro=(
|
|
profile.is_pro
|
|
if profile.is_pro is not None
|
|
else _parse_optional_bool(get_codex_is_pro_env())
|
|
),
|
|
)
|
|
|
|
|
|
@CODEX_AUTH_ROUTER.post("/logout")
|
|
async def logout_codex():
|
|
"""
|
|
Clear all stored Codex OAuth credentials from environment variables and userConfig.json.
|
|
"""
|
|
set_codex_access_token_env("")
|
|
set_codex_refresh_token_env("")
|
|
set_codex_token_expires_env("")
|
|
set_codex_account_id_env("")
|
|
set_codex_username_env("")
|
|
set_codex_email_env("")
|
|
set_codex_is_pro_env("")
|
|
set_codex_model_env("")
|
|
save_codex_tokens_to_user_config()
|
|
return {"detail": "Logged out successfully"}
|