"""Auth capture API — remote browser for manual login + credential extraction.""" from __future__ import annotations import logging from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database import get_db from app.services.auth_capture_service import extract_all from app.services.browser_import_service import ( ImportSessionError, browser_imports, build_import_result, ) from app.services.browser_session_service import ( BrowserDependencyError, BrowserSessionError, browser_sessions, ) from app.utils.auth import get_current_user, get_user_from_token_param logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth-capture", tags=["auth-capture"]) SENSITIVE_CANDIDATE_FIELDS = frozenset({"value", "cookie_value"}) class CaptureSessionCreate(BaseModel): url: str = Field(..., description="Target login page URL to open in browser") width: int = Field(default=1280, ge=320, le=2560) height: int = Field(default=720, ge=240, le=1600) class CaptureSessionResponse(BaseModel): session_id: str ws_url: str class CaptureExtractResponse(BaseModel): cookies: list[dict] = [] storage: dict[str, str] = {} session_storage: dict[str, str] = {} auth_headers: list[dict] = [] candidates: list[dict] = [] class ImportSessionCreate(BaseModel): target_url: str = Field(..., description="Target page URL opened in the user's real browser") class ImportSessionCreateResponse(BaseModel): session_id: str secret: str expires_in_seconds: int class ImportSessionStatusResponse(BaseModel): session_id: str ready: bool = False expires_at: float result: Optional[CaptureExtractResponse] = None class ImportSessionSubmit(BaseModel): secret: str page_url: str = "" cookies: list[dict] = [] local_storage: dict[str, Any] = {} session_storage: dict[str, Any] = {} auth_headers: list[dict] = [] def _sanitize_candidate(candidate: dict[str, Any]) -> dict[str, Any]: return { key: value for key, value in candidate.items() if key not in SENSITIVE_CANDIDATE_FIELDS } def _browser_error(exc: Exception) -> HTTPException: if isinstance(exc, BrowserDependencyError): return HTTPException(503, str(exc)) if isinstance(exc, BrowserSessionError): return HTTPException(409, str(exc)) if isinstance(exc, KeyError): return HTTPException(404, "session not found") if isinstance(exc, ValueError): return HTTPException(400, str(exc)) logger.exception("auth-capture error") return HTTPException(500, "internal error") def _ws_url(session_id: str, token: str) -> str: """Build WebSocket URL for the remote browser viewer.""" return f"/api/browser-sessions/{session_id}/ws?token={token}" @router.post("/sessions", response_model=CaptureSessionResponse, status_code=201) async def create_capture_session( body: CaptureSessionCreate, _=Depends(get_current_user), ): """Create a temporary browser session pointing at the given URL. Returns a session_id and ws_url for the frontend to view/interact. The user should manually log in, then call GET /extract. """ try: session = await browser_sessions.create_ephemeral( url=body.url, width=body.width, height=body.height, ) except Exception as exc: raise _browser_error(exc) # Build a short-lived token for WS auth (reuse current user's token logic) # The frontend already has the user's Bearer token, pass it via query param return CaptureSessionResponse( session_id=session.id, ws_url=f"/api/browser-sessions/{session.id}/ws", ) @router.get("/sessions/{session_id}/extract", response_model=CaptureExtractResponse) async def extract_credentials( session_id: str, include_raw: bool = Query(default=False, description="Include full cookies/storage/headers in response"), _=Depends(get_current_user), ): """Extract auth credentials from the browser session. By default only returns curated candidates (typed, scored, with masked preview). Pass include_raw=true to also get full cookies, localStorage, and headers. """ try: session = browser_sessions.get_session(session_id) except KeyError: raise HTTPException(404, "session not found") try: result = await extract_all(session) except Exception as exc: raise _browser_error(exc) if not include_raw: # Strip raw data — only keep curated candidates with masked previews candidates = [_sanitize_candidate(candidate) for candidate in result.get("candidates", [])] return CaptureExtractResponse(candidates=candidates) return CaptureExtractResponse(**result) @router.delete("/sessions/{session_id}", status_code=204) async def close_capture_session( session_id: str, _=Depends(get_current_user), ): """Close and release the auth-capture browser session.""" try: await browser_sessions.close(session_id) except Exception as exc: raise _browser_error(exc) @router.post("/import-sessions", response_model=ImportSessionCreateResponse, status_code=201) async def create_import_session( body: ImportSessionCreate, user=Depends(get_current_user), ): """Create a one-time real-browser import session. The returned secret is intended for the local browser extension only. """ target_url = body.target_url.strip() if not target_url.startswith(("http://", "https://")): raise HTTPException(400, "Only http/https URLs are allowed") session, secret = browser_imports.create(target_url=target_url, created_by=user.email) return ImportSessionCreateResponse( session_id=session.id, secret=secret, expires_in_seconds=600, ) @router.get("/import-sessions/{session_id}", response_model=ImportSessionStatusResponse) async def get_import_session( session_id: str, include_raw: bool = Query(default=False), user=Depends(get_current_user), ): try: session = browser_imports.get(session_id, created_by=user.email) except ImportSessionError as exc: raise HTTPException(404, str(exc)) result = None if session.payload is not None: full_result = build_import_result(session.payload) if not include_raw: candidates = [_sanitize_candidate(candidate) for candidate in full_result.get("candidates", [])] result = CaptureExtractResponse(candidates=candidates) else: result = CaptureExtractResponse(**full_result) return ImportSessionStatusResponse( session_id=session.id, ready=session.payload is not None, expires_at=session.expires_at, result=result, ) @router.post("/import-sessions/{session_id}/submit", status_code=204) async def submit_import_session( session_id: str, body: ImportSessionSubmit, ): try: browser_imports.submit( session_id=session_id, secret=body.secret, payload=body.model_dump(exclude={"secret"}), ) except ImportSessionError as exc: raise HTTPException(400, str(exc))