"""Browser authentication and CLI-native onboarding for `deepeval login`. Device-code style pairing (RFC 8628 shaped), with no localhost callback server. The CLI never handles credentials and no secrets ever transit localhost, so the flow works over SSH / containers / Colab and is unaffected by Chrome's Local Network Access permission prompt. Flow: 1. The CLI asks the backend to create a short-lived login session: POST {api_base}/cli/auth/sessions -> 200 {"success": true, "data": {"userCode": "ABCD-1234", "deviceCode": "", "expiresIn": 600, "interval": 3}} `userCode` is short and user-visible (shown in the terminal and in the browser so the user can confirm they're pairing the right session). `deviceCode` is a secret with >= 128 bits of entropy; it never leaves the CLI and is the only thing that can claim the API key. 2. The CLI opens the browser at the returned verification URL. The browser handles authentication and explicit authorization only; onboarding remains in the terminal. 3. The CLI polls the token endpoint with the secret device code: POST {api_base}/cli/auth/sessions/token {"deviceCode": ""} -> pending: {"success": true, "data": {"status": "pending"}} -> authenticated: {"success": true, "data": {"status": "authenticated", "setupToken": "", "email": "user@example.com"}} -> expired / consumed / unknown: 404, or {"success": false, "error": "..."} 4. The CLI fetches onboarding context and completes setup using the short-lived setup token. The completion response contains a newly minted project API key, and consumes the login session. """ import sys import time import webbrowser from typing import Any, Dict, Optional, Sequence, Tuple, Union import requests import typer from pydantic import ValidationError from rich import print import deepeval from deepeval.cli.auth.api import ( CliAuthorization, CliOnboardingContext, NewUserOnboardingRequest, ExistingProjectKeyRequest, DevicePairing, ) from deepeval.cli.utils import with_utm from deepeval.confident.api import get_base_api_url from deepeval.telemetry import set_logged_in_with CREATE_PAIRING_ENDPOINT = "/cli/auth/sessions" PAIRING_TOKEN_ENDPOINT = "/cli/auth/sessions/token" ONBOARDING_ENDPOINT = "/cli/onboarding" ONBOARDING_COMPLETE_ENDPOINT = "/cli/onboarding/complete" REQUEST_TIMEOUT_SECONDS = 10 # Completion creates the organization/project in one backend transaction, # which can take longer than a normal request. Retries are safe because the # request carries an idempotency key. COMPLETE_TIMEOUT_SECONDS = 30 COMPLETE_MAX_ATTEMPTS = 3 class AuthFlowError(Exception): """Raised when the browser login flow cannot complete.""" def _is_interactive() -> bool: try: return sys.stdin.isatty() and sys.stdout.isatty() except Exception: return False CONFIDENT_PURPLE = "#6a00ff" def _fill_radio_indicator(question: Any) -> None: """Make the select behave like a radio group: ● on the pointed row, ○ on the rest. questionary's `use_indicator` renders a hollow circle on every row in select mode (the filled one is reserved for checkbox mode, driven by `selected_options`, which select never populates). Sync `selected_options` with the pointed row at render time so the active option gets the filled dot. """ from questionary.prompts.common import InquirerControl for control in question.application.layout.find_all_controls(): if isinstance(control, InquirerControl): original = control.text def patched(control=control, original=original): control.selected_options = [control.get_pointed_at().value] return original() control.text = patched return def _questionary_style(): import questionary return questionary.Style( [ ("qmark", f"fg:{CONFIDENT_PURPLE} bold"), ("question", "bold"), ("instruction", "fg:#767676"), ("pointer", f"fg:{CONFIDENT_PURPLE} bold"), ("highlighted", f"fg:{CONFIDENT_PURPLE} bold"), # prompt_toolkit's built-in style renders "selected" in # reverse video (fg/bg swapped); noreverse disables that. ("selected", f"fg:{CONFIDENT_PURPLE} bold noreverse"), ("answer", f"fg:{CONFIDENT_PURPLE} bold"), ] ) def prompt_text(message: str, default: Optional[str] = None) -> str: """Styled text input matching `prompt_select`, with a plain fallback.""" if _is_interactive(): try: import questionary answer = questionary.text( message, default=default or "", qmark="?", style=_questionary_style(), ).ask() # `ask()` returns None when the user aborts (e.g. Ctrl-C). if answer is None: raise AuthFlowError("Input cancelled.") return answer except AuthFlowError: raise except Exception: # Any questionary/terminal issue: degrade to the plain prompt. pass return typer.prompt(message, default=default) def prompt_select(message: str, choices: Sequence[Tuple[str, Any]]) -> Any: """Arrow-key selection when the terminal supports it, numbered fallback otherwise.""" if _is_interactive(): try: import questionary style = _questionary_style() question = questionary.select( message, choices=[ questionary.Choice(title=label, value=value) for label, value in choices ], qmark="?", pointer="❯", use_indicator=True, instruction="(arrow keys)", style=style, ) _fill_radio_indicator(question) answer = question.ask() # `ask()` returns None when the user aborts (e.g. Ctrl-C). if answer is None: raise AuthFlowError("Selection cancelled.") return answer except AuthFlowError: raise except Exception: # Any questionary/terminal issue: degrade to the numbered prompt. pass print(message) for index, (label, _) in enumerate(choices, start=1): print(f" {index}. {label}") while True: raw = typer.prompt("Enter a number") try: selected = int(raw) except (TypeError, ValueError): print("❌ Please enter a valid number.") continue if 1 <= selected <= len(choices): return choices[selected - 1][1] print(f"❌ Please enter a number between 1 and {len(choices)}.") def _request_headers() -> Dict[str, str]: return { "Content-Type": "application/json", "X-DeepEval-Version": deepeval.__version__, } def _unwrap(res: requests.Response) -> Dict[str, Any]: """Unwrap the standard `{success, data, error}` API envelope. Tolerates a bare payload so the backend can be iterated on without breaking the CLI.""" try: payload = res.json() except ValueError: raise AuthFlowError( f"{res.request.method} {res.url} did not return JSON: " f"{res.text[:200]!r}" ) if not isinstance(payload, dict): raise AuthFlowError( f"{res.request.method} {res.url} returned an unexpected payload: " f"{res.text[:200]!r}" ) if "success" in payload: if not payload.get("success"): raise AuthFlowError( payload.get("error") or "The pairing request failed." ) data = payload.get("data") return data if isinstance(data, dict) else {} return payload def create_pairing() -> DevicePairing: url = f"{get_base_api_url()}{CREATE_PAIRING_ENDPOINT}" try: res = requests.post( url, headers=_request_headers(), json={}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as e: raise AuthFlowError(f"Could not reach POST {url} ({e}).") if res.status_code == 404: raise AuthFlowError( f"The backend does not support browser login yet " f"(404 from POST {url})." ) if res.status_code != 200: raise AuthFlowError( f"POST {url} failed with HTTP {res.status_code}: {res.text[:200]!r}" ) data = _unwrap(res) try: return DevicePairing.model_validate(data) except ValidationError: raise AuthFlowError( f"POST {url} did not return the required login session fields: " f"{res.text[:200]!r}" ) def _poll_once( device_code: str, ) -> Optional[CliAuthorization]: """One poll of the token endpoint. Returns setup authorization when browser auth completes, None while pending (or on a transient network error), and raises AuthFlowError on terminal failures (expired, consumed, backend missing). """ url = f"{get_base_api_url()}{PAIRING_TOKEN_ENDPOINT}" try: res = requests.post( url, headers=_request_headers(), json={"deviceCode": device_code}, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException: return None if res.status_code == 404: raise AuthFlowError( f"This pairing is no longer valid, or the backend does not " f"support browser login yet (404 from POST {url})." ) if res.status_code != 200: raise AuthFlowError( f"POST {url} failed with HTTP {res.status_code}: {res.text[:200]!r}" ) data = _unwrap(res) status = data.get("status") if status == "pending": return None if status == "authenticated": try: return CliAuthorization.model_validate(data) except ValidationError: raise AuthFlowError( "Browser authentication completed but the server did not " "return a setup token." ) raise AuthFlowError( f"The pairing is no longer valid (status: {status or 'unknown'}). " "Run `deepeval login` again." ) def browser_pairing_login() -> Optional[CliAuthorization]: """Authenticate the user in the browser and return CLI setup access. Returns a short-lived setup authorization, or None if the flow was aborted / timed out / unavailable. """ try: pairing = create_pairing() except AuthFlowError: print( "\n⚠️ Unexpected error — seems like browser login isn't " "available right now." ) return None login_url = with_utm( pairing.verification_url, medium="cli", content="login_pair_browser_open", ) fallback_url = with_utm( pairing.verification_url, medium="cli", content="login_pair_fallback_link", ) print("\n🌐 Opening your browser — confirm this pairing code there:") print(f"\n [bold green]{pairing.user_code}[/bold green]\n") webbrowser.open(login_url) print( f"[dim]Browser didn't open? Use [link={fallback_url}]{fallback_url}[/link][/dim]" ) print( "[dim]Waiting for the browser... " "press Ctrl+C to paste an API key instead.[/dim]" ) deadline = time.monotonic() + pairing.expires_in try: while time.monotonic() < deadline: time.sleep(pairing.interval) try: completed = _poll_once(pairing.device_code) except AuthFlowError as e: print(f"\n⚠️ {e}") return None if completed is None: continue if completed.email: set_logged_in_with(completed.email) return completed print("\n⌛ Timed out waiting for the browser.") return None except KeyboardInterrupt: print() return None def get_cli_onboarding_context(setup_token: str) -> CliOnboardingContext: url = f"{get_base_api_url()}{ONBOARDING_ENDPOINT}" try: res = requests.get( url, headers={ **_request_headers(), "Authorization": f"Bearer {setup_token}", }, timeout=REQUEST_TIMEOUT_SECONDS, ) except requests.RequestException as e: raise AuthFlowError(f"Could not reach GET {url} ({e}).") if res.status_code != 200: raise AuthFlowError( f"GET {url} failed with HTTP {res.status_code}: {res.text[:200]!r}" ) try: data = res.json() except ValueError: raise AuthFlowError(f"GET {url} did not return JSON.") try: return CliOnboardingContext.model_validate(data) except ValidationError: raise AuthFlowError( f"GET {url} returned an unexpected onboarding payload." ) def complete_cli_onboarding( setup_token: str, request: Union[NewUserOnboardingRequest, ExistingProjectKeyRequest], idempotency_key: str, ) -> str: url = f"{get_base_api_url()}{ONBOARDING_COMPLETE_ENDPOINT}" res = None last_error: Optional[requests.RequestException] = None for attempt in range(COMPLETE_MAX_ATTEMPTS): retryable = attempt < COMPLETE_MAX_ATTEMPTS - 1 try: res = requests.post( url, headers={ **_request_headers(), "Authorization": f"Bearer {setup_token}", "Idempotency-Key": idempotency_key, }, json=request.to_payload(), timeout=COMPLETE_TIMEOUT_SECONDS, ) except requests.RequestException as e: last_error = e if retryable: print("Still working — retrying...") time.sleep(2) continue # A previous (timed-out) attempt may still hold the completion lock # server-side; give it a moment and retry with the same key. if res.status_code == 409 and "in progress" in res.text and retryable: time.sleep(3) continue break if res is None: raise AuthFlowError(f"Could not reach POST {url} ({last_error}).") if res.status_code != 200: raise AuthFlowError( f"POST {url} failed with HTTP {res.status_code}: " f"{res.text[:200]!r}" ) try: data = res.json() except ValueError: raise AuthFlowError(f"POST {url} did not return JSON.") if not isinstance(data, dict) or not data.get("apiKey"): raise AuthFlowError( "CLI onboarding completed without returning an API key." ) return str(data["apiKey"])