Files
strix/strix/interface/auth_cli.py
T
Jonathan SingerandClaude Fable 5 3d419d312f review: harden OAuth state check and tighten error handling
Address self-review findings ahead of code review:
- auth_cli: require the OAuth `state` on the automated loopback callback
  (reject missing/mismatched — CSRF), keep manual paste lenient since it's
  user-initiated. Add tests for _finish state handling.
- codex: drop the redundant chatgpt-account-id from client default_headers;
  the per-request auth hook already stamps it (single source of truth).
- main: tighten the "sign-in expired" hint to match `error code: 401` /
  `http 401` rather than a bare "401" substring that could misfire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:14:09 -04:00

478 lines
17 KiB
Python

"""`strix auth` — manage model-subscription sign-in.
Subcommands:
- ``strix auth login chatgpt [--model NAME] [--manual]`` — OAuth sign-in with a
ChatGPT Plus/Pro subscription. Opens a browser and catches the redirect on a
local server; ``--manual`` (or a failure to open the browser / bind the port)
falls back to pasting the redirect URL by hand.
- ``strix auth status`` — show whether a subscription sign-in is active.
- ``strix auth logout`` — forget the stored sign-in.
Signing in persists ``STRIX_AUTH_MODE=subscription`` and a Codex model to
``~/.strix/cli-config.json`` so subsequent ``strix`` runs use the subscription.
Tokens live separately in ``~/.strix/subscription-auth.json`` (see
``strix/auth/store.py``); they are never written to the env-var config.
"""
from __future__ import annotations
import argparse
import base64
import logging
import os
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from strix.auth import codex
from strix.config import load_settings, persist_current
logger = logging.getLogger(__name__)
_CALLBACK_TIMEOUT_S = 300
# CLI-facing name for the login provider. Internally this is the Codex OAuth
# flow (``codex.PROVIDER``), but users know it as ChatGPT, so that's what the
# command and messaging say. ``codex`` is accepted as an alias.
LOGIN_PROVIDER = "chatgpt"
_ACCEPTED_PROVIDERS = frozenset({LOGIN_PROVIDER, codex.PROVIDER})
_USAGE = (
"Usage:\n"
" strix auth login chatgpt [--model NAME] [--manual]\n"
" strix auth status\n"
" strix auth logout"
)
def run_auth(argv: list[str]) -> int:
"""Entry point for ``strix auth …``. Returns a process exit code."""
console = Console()
subcommand = argv[0] if argv else "login"
rest = argv[1:]
if subcommand in ("-h", "--help", "help"):
console.print(_USAGE)
return 0
if subcommand == "login":
return _login(console, rest)
if subcommand == "status":
return _status(console)
if subcommand == "logout":
return _logout(console)
# Bare `strix auth` (no subcommand) defaults to login; anything else is an error.
if not argv:
return _login(console, [])
console.print(f"[red]Unknown auth command:[/] {subcommand}\n")
console.print(_USAGE)
return 2
def _login(console: Console, argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog="strix auth login", add_help=True)
parser.add_argument(
"provider",
nargs="?",
default=LOGIN_PROVIDER,
help="Model provider to sign in with (default: chatgpt).",
)
parser.add_argument(
"--model",
default=codex.DEFAULT_CODEX_MODEL,
help=f"ChatGPT model to use (default: {codex.DEFAULT_CODEX_MODEL}).",
)
parser.add_argument(
"--manual",
action="store_true",
help="Skip the local callback server and paste the redirect URL by hand.",
)
try:
args = parser.parse_args(argv)
except SystemExit as exc: # argparse already printed the message
return int(exc.code or 2)
if args.provider.lower() not in _ACCEPTED_PROVIDERS:
console.print(
f"[red]Unsupported provider:[/] {args.provider}. "
f"Only '{LOGIN_PROVIDER}' (ChatGPT subscription) is supported."
)
return 2
# Capture any shell-exported STRIX_LLM before we persist our own, so we can
# warn if it would override the model chosen here (env wins over the config
# file we write). This must be read before _persist_subscription_config sets it.
preexisting_llm = os.environ.get("STRIX_LLM")
verifier, challenge = codex.generate_pkce()
state = codex.create_state()
authorize_url = codex.build_authorize_url(challenge, state)
console.print()
console.print("[bold]Signing in with ChatGPT[/] [dim](provider: chatgpt)[/]")
console.print(
"[dim]This uses your ChatGPT Plus/Pro plan for inference instead of a metered API key.[/]"
)
console.print()
try:
record = _run_oauth_flow(console, authorize_url, verifier, state, manual=args.manual)
except codex.CodexAuthError as exc:
return _fail(console, exc)
except KeyboardInterrupt:
console.print("\n[yellow]Sign-in cancelled.[/]")
return 130
codex.save_record(record)
_persist_subscription_config(args.model)
_warn_if_env_overrides_model(console, preexisting_llm, args.model)
stored_model = load_settings().llm.model or f"openai/{codex.normalize_model(args.model)}"
_print_success(console, stored_model)
return 0
def _warn_if_env_overrides_model(console: Console, preexisting: str | None, chosen: str) -> None:
"""Warn when a shell-exported STRIX_LLM will override the model just saved.
The config file we write loses to an environment variable at load time, so a
lingering ``export STRIX_LLM=…`` would silently win. Only warn when it
resolves to a different model than the one chosen here (same value, or an
incompatible one that coerces to the same default, is not a real conflict).
"""
if not preexisting or not preexisting.strip():
return
if codex.normalize_model(preexisting) == codex.normalize_model(chosen):
return
console.print(
f"[yellow]Note:[/] STRIX_LLM is set in your shell to "
f"[bold]{preexisting.strip()}[/], which overrides the model just saved. "
f"Run [bold cyan]unset STRIX_LLM[/] so the subscription model is used."
)
def _run_oauth_flow(
console: Console,
authorize_url: str,
verifier: str,
state: str,
*,
manual: bool,
) -> dict[str, Any]:
"""Drive the browser (or manual) OAuth flow and return a token record."""
server = None if manual else _try_start_callback_server()
console.print("Open this URL in your browser to authorize:")
console.print(f"[cyan]{authorize_url}[/]")
console.print()
if not manual:
try:
webbrowser.open(authorize_url)
except Exception: # noqa: BLE001 - opening a browser is best-effort
logger.debug("could not open browser", exc_info=True)
if server is not None:
console.print("[dim]Waiting for you to finish signing in…[/]")
result = server.wait(_CALLBACK_TIMEOUT_S)
server.shutdown()
if result is not None:
code, returned_state, error = result
if error:
raise codex.CodexAuthError("oauth_error", error)
return _finish(code, returned_state, verifier, state, require_state=True)
console.print("[yellow]Timed out waiting for the browser. Falling back to manual paste.[/]")
# Manual fallback: the user completes sign-in and pastes the redirect URL
# (the browser lands on a localhost page that won't load if no server is up;
# the address bar still holds the code+state).
console.print()
try:
pasted = console.input("Paste the full redirect URL (or code#state): ").strip()
except EOFError as exc:
raise codex.CodexAuthError("no_input", "no redirect URL provided") from exc
code, returned_state = codex.parse_redirect_input(pasted)
return _finish(code, returned_state, verifier, state, require_state=False)
def _finish(
code: str | None,
returned_state: str | None,
verifier: str,
expected_state: str,
*,
require_state: bool,
) -> dict[str, Any]:
if not code:
raise codex.CodexAuthError("no_code", "no authorization code found in the redirect")
# The loopback callback from OpenAI always carries state, so a missing or
# mismatched value there is forged (CSRF) and must be rejected. Manual paste
# is user-initiated (the user copies their own redirect), so state is only
# validated when the pasted value includes it.
if require_state and returned_state is None:
raise codex.CodexAuthError("state_mismatch", "missing state in callback; possible CSRF")
if returned_state is not None and returned_state != expected_state:
raise codex.CodexAuthError("state_mismatch", "state did not match; possible CSRF")
return codex.exchange_code(code, verifier)
class _CallbackServer:
"""A one-shot local HTTP server that catches the OAuth redirect."""
def __init__(self, httpd: HTTPServer, event: threading.Event, holder: dict[str, Any]) -> None:
self._httpd = httpd
self._event = event
self._holder = holder
self._thread = threading.Thread(target=httpd.serve_forever, daemon=True)
self._thread.start()
def wait(self, timeout: float) -> tuple[str | None, str | None, str | None] | None:
if not self._event.wait(timeout):
return None
return (
self._holder.get("code"),
self._holder.get("state"),
self._holder.get("error"),
)
def shutdown(self) -> None:
self._httpd.shutdown()
self._httpd.server_close()
def _try_start_callback_server() -> _CallbackServer | None:
event = threading.Event()
holder: dict[str, Any] = {}
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args: Any) -> None: # silence default stderr logging
pass
def do_GET(self) -> None:
parsed = urlparse(self.path)
if parsed.path != codex.CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
query = parse_qs(parsed.query)
holder["code"] = _first(query, "code")
holder["state"] = _first(query, "state")
holder["error"] = _first(query, "error_description") or _first(query, "error")
body = _render_callback_html().encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
event.set()
try:
httpd = HTTPServer(("127.0.0.1", codex.CALLBACK_PORT), Handler)
except OSError:
logger.debug("could not bind callback port %d", codex.CALLBACK_PORT, exc_info=True)
return None
return _CallbackServer(httpd, event, holder)
def _first(query: dict[str, list[str]], key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _persist_subscription_config(model: str) -> None:
"""Persist subscription mode + model to cli-config.json for later runs."""
normalized = codex.normalize_model(model)
os.environ["STRIX_AUTH_MODE"] = "subscription"
os.environ["STRIX_LLM"] = f"openai/{normalized}"
persist_current()
def _status(console: Console) -> int:
record = codex.read_record()
if record is None:
console.print("[yellow]Not signed in.[/] Run [cyan]strix auth login[/] to sign in.")
return 1
settings = load_settings()
console.print("[green]Signed in[/] with a ChatGPT subscription (Codex).")
console.print(f" Account: [bold]{record.get('account_id')}[/]")
console.print(f" Model: [bold]{settings.llm.model or codex.DEFAULT_CODEX_MODEL}[/]")
if settings.llm.auth_mode != "subscription":
console.print(
" [yellow]Note:[/] STRIX_AUTH_MODE is not 'subscription'; "
"runs will use API-key billing until you re-run [cyan]strix auth login[/]."
)
return 0
def _logout(console: Console) -> int:
codex.logout()
console.print("[green]Signed out.[/] Stored subscription credentials removed.")
console.print(
"[dim]Runs still set to subscription mode will ask you to sign in again. "
"Set STRIX_AUTH_MODE=api_key (and LLM_API_KEY) to use metered billing.[/]"
)
return 0
def _fail(console: Console, exc: codex.CodexAuthError) -> int:
error_text = Text()
error_text.append("SIGN-IN FAILED", style="bold red")
error_text.append("\n\n", style="white")
error_text.append(f"{exc}", style="white")
console.print()
console.print(
Panel(
error_text,
title="[bold white]STRIX",
title_align="left",
border_style="red",
padding=(1, 2),
)
)
return 1
def _print_success(console: Console, model: str) -> None:
text = Text()
text.append("Signed in with your ChatGPT subscription", style="bold #22c55e")
text.append("\n\n", style="white")
text.append("Model", style="dim")
text.append(" ")
text.append(model, style="bold white")
text.append("\n")
text.append("Billing", style="dim")
text.append(" ")
text.append("your ChatGPT plan (no per-token API charges)", style="white")
text.append("\n\n", style="white")
text.append("Run a scan as usual, e.g. ", style="white")
text.append("strix --target https://example.com", style="bold cyan")
text.append("\nChange model with ", style="dim")
text.append("strix auth login chatgpt --model <name>", style="cyan")
console.print()
console.print(
Panel(
text,
title="[bold white]STRIX",
title_align="left",
border_style="#22c55e",
padding=(1, 2),
)
)
console.print()
_LOGO_PATH = Path(__file__).resolve().parent.parent / "viewer" / "static" / "logo.png"
def _logo_img_tag() -> str:
"""Return an ``<img>`` for the Strix logo as an inline data URI, or "".
The callback page is served offline by the local OAuth server, so the logo
is embedded rather than linked. Missing/unreadable file degrades to just the
"Strix" wordmark.
"""
try:
data = _LOGO_PATH.read_bytes()
except OSError:
return ""
encoded = base64.b64encode(data).decode("ascii")
return f'<img class="logo" src="data:image/png;base64,{encoded}" alt="" />'
def _render_callback_html() -> str:
return _CALLBACK_HTML.replace("<!--LOGO-->", _logo_img_tag())
_CALLBACK_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Strix — signed in</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; padding: 24px;
font-family: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
background: #000; color: #ededed;
display: flex; flex-direction: column; align-items: center; justify-content: center;
}
.topbar {
position: absolute; top: 20px; left: 22px;
display: flex; align-items: center; gap: 6px; text-decoration: none;
}
.topbar .logo { width: 40px; height: 40px; display: block; }
.topbar span {
font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em; color: #fff;
transition: color .15s ease;
}
.topbar:hover span { color: #c9c9c9; }
.brand {
font-size: 2.1rem; font-weight: 700; letter-spacing: -.02em; color: #fff;
text-align: center; margin: 0 0 10px;
}
h1 {
font-size: 1.35rem; font-weight: 600; letter-spacing: -.01em; color: #f5f5f5;
text-align: center; margin: 0 0 28px;
}
.card {
width: 100%; max-width: 430px; text-align: center;
background: #171717; border: 1px solid rgba(255, 255, 255, .06);
border-radius: 24px; padding: 40px 40px 34px;
}
.badge {
margin: 0 auto 22px; width: 52px; height: 52px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 23px; color: #fff;
background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
}
.msg { margin: 0 auto; max-width: 34ch; color: #b5b5b5; line-height: 1.6; font-size: .98rem; }
.rule { height: 1px; background: rgba(255, 255, 255, .07); margin: 26px 0 0; }
.tagline { margin: 22px 0 0; color: #7c7c7c; font-size: .9rem; line-height: 1.55; }
.tagline b { color: #ededed; font-weight: 500; }
.links {
margin-top: 18px; display: flex; gap: 8px; justify-content: center;
align-items: center; flex-wrap: wrap; font-size: .84rem;
}
.links a { color: #a3a3a3; text-decoration: none; transition: color .15s ease; }
.links a:hover { color: #fff; }
.links .dot { color: #3a3a3a; }
.close { margin: 24px 0 0; color: #5a5a5a; font-size: .78rem; text-align: center; }
</style></head>
<body>
<a class="topbar" href="https://strix.ai" target="_blank" rel="noopener"
aria-label="Strix — strix.ai">
<!--LOGO-->
<span>Strix</span>
</a>
<div class="brand">Strix</div>
<h1>You're signed in</h1>
<main class="card">
<div class="badge">✓</div>
<p class="msg">Strix is connected to your ChatGPT subscription. Head back to your
terminal — your security test runs there.</p>
<div class="rule"></div>
<p class="tagline">Autonomous AI hackers that <b>find and fix</b> your app's
vulnerabilities.</p>
<nav class="links">
<a href="https://strix.ai" target="_blank" rel="noopener">strix.ai</a>
<span class="dot">·</span>
<a href="https://docs.strix.ai" target="_blank" rel="noopener">docs</a>
<span class="dot">·</span>
<a href="https://discord.gg/strix-ai" target="_blank" rel="noopener">community</a>
</nav>
</main>
<p class="close">You can close this tab.</p>
</body></html>"""
__all__ = ["run_auth"]