pryscraper/routers/auth.py
cryptorugmunch 7e0b93dd84 feat(pry): wire captcha_solver.CaptchaSolver into /v1/auth/captcha endpoint
The big captcha_solver.py (6 providers: capsolver/2captcha/anti-captcha/
capmonster/deathbycaptcha/nextcaptcha, supports reCAPTCHA v2/v3, hCaptcha,
Turnstile, image) was an orphan module — never imported. The /v1/auth/captcha
endpoint was using the smaller auth_connector.solve_captcha (2 providers,
2captcha stubbed out).

This commit:
- Replaces the /v1/auth/captcha handler to delegate to CaptchaSolver
- Adds support for captcha_type: recaptcha_v2, recaptcha_v3, hcaptcha, image
- Adds per-provider api_keys dict for the 6-provider chain
- Adds tests/test_captcha_solver_wiring.py
- Annotates account_manager.py and signup_automator.py (BSL-licensed) with
  notes about their unwired status and where they belong in the architecture

The two remaining orphan modules are kept (not deleted) because they are
BSL 1.1 stealth code that may be wired in the future as anti-fingerprint
or identity-evasion routes. See FEATURES.md and ARCHITECTURE.md.

Audit item 10. Tests: 623 passed, 1 skipped (test_ready_returns_200 fails
pre-existing because Ollama is unreachable from test env).
2026-07-06 10:37:55 +02:00

150 lines
5.7 KiB
Python

"""Pry - Auth router (credential vault, SSO, CAPTCHA, session health).
Split from api.py on the api-router-split refactor. This file replaces
the inline endpoints that were tagged ["Auth"] in api.py. The behavior
is identical; the new shape is a FastAPI APIRouter that gets included
in the main app.
Endpoints (6):
POST /v1/auth/credentials - store credentials in the vault
GET /v1/auth/credentials - list credentials (no secrets)
DELETE /v1/auth/credentials/{id} - delete a credential
POST /v1/auth/sso - generate SSO login script
POST /v1/auth/captcha - solve a CAPTCHA
POST /v1/auth/session/health - check session health
Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
Licensed under MIT. See LICENSE.
"""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry - https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body
from errors import NotFoundError # type: ignore[import-not-found]
router = APIRouter(tags=["Auth"])
@router.post("/v1/auth/credentials", summary="Store credentials in the vault")
async def store_credentials(
name: str = Body(...),
credential_type: str = Body("password"),
credentials: dict[str, Any] = Body(...),
target_url: str = Body(""),
) -> dict[str, Any]:
"""Store credentials in the encrypted vault for authenticated scraping.
Credential types: password, api_key, cookie, token, sso
"""
from auth_connector import store_credential
result = store_credential(name, credential_type, credentials, target_url)
return {"success": result["success"], "data": result}
@router.get("/v1/auth/credentials", summary="List stored credentials")
async def list_credentials_endpoint() -> dict[str, Any]:
"""List all stored credentials (without exposing secrets)."""
from auth_connector import list_credentials
creds = list_credentials()
return {"success": True, "data": {"credentials": creds, "total": len(creds)}}
@router.delete("/v1/auth/credentials/{credential_id}", summary="Delete stored credentials")
async def delete_credentials_endpoint(credential_id: str) -> dict[str, Any]:
"""Delete stored credentials from the vault."""
from auth_connector import delete_credential
success = delete_credential(credential_id)
if not success:
raise NotFoundError(f"Credential not found: {credential_id}")
return {"success": True, "data": {"deleted": True}}
@router.post("/v1/auth/sso", summary="Generate SSO login script")
async def generate_sso(
provider: str = Body("okta"),
username: str = Body(...),
password: str = Body(...),
target_url: str = Body(...),
tenant: str = Body(""),
) -> dict[str, Any]:
"""Generate a browser automation script for SSO login.
Supports: okta, azure_ad, google_workspace, onelogin
"""
from auth_connector import generate_sso_script
result = await generate_sso_script(provider, username, password, target_url, tenant)
return {"success": result["success"], "data": result}
@router.post("/v1/auth/captcha", summary="Solve a CAPTCHA using multi-provider chain")
async def solve_captcha_endpoint(
captcha_type: str = Body("recaptcha_v2"),
site_key: str = Body(""),
page_url: str = Body(""),
action: str = Body("verify"),
min_score: float = Body(0.3),
image_base64: str = Body(""),
api_keys: dict[str, str] = Body(default_factory=dict),
) -> dict[str, Any]:
"""Solve a CAPTCHA using the 6-provider chain in captcha_solver.
Supported types:
- recaptcha_v2: needs site_key + page_url
- recaptcha_v3: needs site_key + page_url, optional action/min_score
- hcaptcha: needs site_key + page_url
- image: needs image_base64
api_keys maps provider name -> API key. Providers attempted in priority
order (capsolver -> 2captcha -> anti-captcha -> capmonster -> deathbycaptcha
-> nextcaptcha). Returns the first successful token.
"""
from captcha_solver import CaptchaSolver
solver = CaptchaSolver(api_keys=api_keys)
if captcha_type == "recaptcha_v2":
if not (site_key and page_url):
return {"success": False, "error": "site_key + page_url required for recaptcha_v2"}
result = await solver.solve_recaptcha_v2(site_key=site_key, page_url=page_url)
elif captcha_type == "recaptcha_v3":
if not (site_key and page_url):
return {"success": False, "error": "site_key + page_url required for recaptcha_v3"}
result = await solver.solve_recaptcha_v3(
site_key=site_key, page_url=page_url, action=action, min_score=min_score
)
elif captcha_type == "hcaptcha":
if not (site_key and page_url):
return {"success": False, "error": "site_key + page_url required for hcaptcha"}
result = await solver.solve_hcaptcha(site_key=site_key, page_url=page_url)
elif captcha_type == "image":
if not image_base64:
return {"success": False, "error": "image_base64 required for image CAPTCHA"}
result = await solver.solve_image(image_base64=image_base64)
else:
return {"success": False, "error": f"Unknown captcha_type: {captcha_type}"}
return {"success": result.get("success", False), "data": result}
@router.post("/v1/auth/session/health", summary="Check authenticated session health")
async def check_session(session_id: str = Body(...)) -> dict[str, Any]:
"""Check the health of an authenticated session.
Reports cookie validity, session age, and whether re-auth is needed.
"""
from auth_connector import check_session_health
result = check_session_health(session_id)
return {"success": True, "data": result}