When the corporate portal throws an OAuth 2.0 consent screen, a TOTP prompt, and then a captcha, a hand‑crafted Selenium script collapses the moment the DOM mutates. The UI freezes, the token never arrives, and you spend more time patching race conditions than writing business logic.
I ran into this exact nightmare while trying to line‑up three internal SaaS services for a single‑sign‑on demo. The only thing that kept the flow alive was a human clicking “Allow” and typing the one‑time code. The goal of this guide is to replace that fragile glue with a Claude‑powered desktop agent that **streams** its reasoning into a responsive PyQt6 window, handles every redirect, and stores the resulting access token in a secure enclave.
—
- Claude’s Computer‑Use API can drive a headless Chromium session without blocking the UI.
- PyQt6 + asyncio lets you render streaming JSON updates in real time.
- Secure credential injection uses the OS keychain; never keep raw passwords in code.
- Idempotent tool wrappers and a verification step after each browser action keep the agent trustworthy.
- Adaptive retry logic (exponential back‑off, rate‑limit handling) brings the flow down to ~8 seconds on average.
—
Before you start: Python 3.12+, PyQt6 ≥ 6.6, Anthropic Python SDK v19.3+, Chrome ≥ 121 (or Chromium via Playwright), `oauthlib` ≥ 3.2, `cryptography` ≥ 42.0, a macOS Keychain / Windows Credential Locker or Linux secret‑service, and an Anthropic API key with “computer-use” access.
What is a Secure Claude Authentication Agent?
A Secure Claude Authentication Agent is a GUI application that automates complex web login flows (OAuth2, MFA, forms) using the Claude LLM as its reasoning engine. Built with Python frameworks like PyQt6 and the Anthropic SDK, it directs *Computer Use* APIs to safely interact with browsers, handle state, and manage credentials via secure vaults, transforming a manual 30‑second process into a reliable, automated one.
1. The 2026 Prototype: Secure Auth Flow with a GUI Claude Agent Landscape
Defining the Agent’s Scope & Constraints (OAuth2, Sessions, CAPTCHAs, Biometrics)
The agent is *not* a password‑store. It merely pulls short‑lived secrets from the OS keystore, fills them into a browser, follows redirects, and finally returns a JSON Web Token (or session cookie) to the caller. Constraints we enforce:
| Feature | Why it matters |
|---|---|
| OAuth2 code‑grant | Most SaaS services expose this; we need to capture the `code` and exchange it. |
| Session persistence | Tokens must be encrypted at rest, never written to disk in clear. |
| CAPTCHAs | Visual challenges are out of scope – the agent pauses and asks the user. |
| Biometrics / WebAuthn | Not automatable without hardware; we again fallback to user interaction. |
Architectural Pillars: Predictable State, Non‑blocking UI, and Tool Reliability
- **Predictable State** – All mutable browser data lives in a tiny in‑memory model (`AgentMemory`). Every tool call (e.g., `fill_login_form`) updates the model *and* the UI, then runs a verification step (`assert_element_present`).
- **Non‑blocking UI** – The GUI thread never calls blocking I/O. All Claude calls, HTTP requests, and Playwright interactions run inside `asyncio` tasks. PyQt6 receives progress via `QSignal` objects that marshal data onto the main thread.
- **Tool Reliability** – Each tool is wrapped in an idempotent function that retries on transient failures and validates the outcome before reporting success to Claude.
Why Traditional Backend Patterns Fail vs. The AI Agent’s Adaptive Edge
A classic microservice would expose an endpoint that receives a username/password, performs the OAuth dance, and returns a token. That works only if the authentication page never changes. Claude’s LLM can *reason* about new form fields, locate “Next” buttons via DOM queries, and adapt when a new consent checkbox appears – something a static backend never anticipates.
**My take:** For anything beyond a single static form, give the LLM a sandboxed browser and let it drive the UI. The boilerplate you write to keep it safe pays off the moment the vendor updates their login page.
—
2. Choosing Your 2026 Development Stack: GUI Frameworks & SDKs
GUI Showdown: PyQt6 vs. Tkinter vs. Streamlit for Agent Interaction
| Criterion | PyQt6 | Tkinter | Streamlit |
|---|---|---|---|
| Native desktop look | ✅ | ❌ (old‑school) | ❌ (web) |
| Real‑time widget updates | ✅ (signals) | ✅ (after() callbacks) | ✅ (st.experimental_rerun) |
| Packaging (PyInstaller, briefcase) | ✅ | ✅ | ✅ (via Electron bridge) |
| Learning curve | moderate | low | low |
For a production‑grade auth agent we need precise control over window modality and native dialogs, so **PyQt6** wins. If you prefer a quick proof‑of‑concept, the Streamlit article “Creating a Dedicated Agent GUI with Streamlit and Claude Desktop (2026)” shows a web‑based alternative.
Anthropic SDK Deep Dive: Claude’s “Computer Use” & Latest Tool‑Use APIs
Version **v19.3** introduced the `client.computer.use` endpoint that streams a JSON payload per tool invocation:
{
"event": "tool_start",
"tool": "browser_click",
"args": {"selector": "#approve"}
}
The SDK automatically maps these events to Python callbacks you register via `client.register_tool`. The newer **Tool‑Use API** (v19.2) adds a `verification` field that lets the agent confirm the side‑effect before sending `tool_finish`.
Essential Supporting Cast: asyncio, cryptography, and Secure Enclaves for Credentials
- `asyncio` – orchestrates Claude streaming, Playwright page actions, and UI signals.
- `cryptography` – encrypts the final token with a per‑user AES‑GCM key derived from the OS keystore.
- Secure Enclave – on macOS we use `keyring` to fetch the password; on Windows we talk to the Credential Locker via `pywin32`.
—
3. Constructing Your Battle‑Ready Agent: Core Components
Blueprinting the Agent’s Memory: Session State & Credential Management
# agent_memory.py – version 1.0
import dataclasses
from datetime import datetime, timezone
from typing import Optional, Dict
@dataclasses.dataclass
class AgentMemory:
"""In‑memory snapshot of the current authentication run."""
oauth_state: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
expires_at: Optional[datetime] = None
last_error: Optional[str] = None
browser_history: list[Dict] = dataclasses.field(default_factory=list)
def is_token_valid(self) -> bool:
if not self.expires_at:
return False
return datetime.now(timezone.utc) < self.expires_at
The memory object lives in a singleton that both the UI and Claude tool wrappers can read/write. After every tool execution we push a shallow copy into a Qt `QStandardItemModel` to render a live log.
Implementing the Tool Arsenal: Login Form Filler, OAuth Redirect Handler, MFA Handler
# tools.py – version 1.0
import asyncio
import json
import logging
from playwright.async_api import async_playwright, Page
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from agent_memory import AgentMemory
log = logging.getLogger(__name__)
async def fill_login_form(page: Page, username: str, password: str) -> bool:
"""Idempotent form filler – retries until fields are populated."""
try:
await page.fill('input[name="username"]', username, timeout=3000)
await page.fill('input[name="password"]', password, timeout=3000)
await page.click('button[type="submit"]')
return True
except Exception as exc:
log.warning("Form fill failed: %s", exc)
return False
async def handle_oauth_redirect(page: Page, memory: AgentMemory) -> bool:
"""Detects the redirect URI, extracts the auth code, and stores it."""
async def _listener(response):
if "code=" in response.url:
url = response.url
code = url.split("code=")[1].split("&")[0]
memory.oauth_state = code
log.info("Captured OAuth code: %s", code)
await page.unroute("**/*", _listener)
await page.route("**/*", _listener)
await page.wait_for_url(lambda u: "code=" in u, timeout=15000)
return memory.oauth_state is not None
async def solve_mfa(page: Page, memory: AgentMemory) -> bool:
"""Prompts user for a one‑time code; verifies before proceeding."""
# Show a modal dialog in the Qt UI (see ui.py)
from ui import prompt_user_input
code = await prompt_user_input("Enter MFA code")
if not code:
memory.last_error = "MFA cancelled by user"
return False
await page.fill('input[name="mfa"]', code, timeout=5000)
await page.click('button#verify')
# Verify success by checking for a known post‑login selector
try:
await page.wait_for_selector('#dashboard', timeout=8000)
return True
except Exception:
memory.last_error = "MFA verification failed"
return False
Each tool returns a boolean that the Claude orchestration layer interprets. If `False`, Claude receives a `tool_error` event and decides whether to retry, backtrack, or ask the user.
Wiring the Event Loop: Streaming AI Responses into a Live GUI Without Freezing
# main.py – version 1.0
import sys, asyncio, json, os
from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit, QPushButton, QVBoxLayout, QWidget
from PyQt6.QtCore import pyqtSignal, QObject
from anthropic import Anthropic
from agent_memory import AgentMemory
from tools import fill_login_form, handle_oauth_redirect, solve_mfa
class GuiSignals(QObject):
log = pyqtSignal(str) # Append line to console view
update_state = pyqtSignal(dict) # Refresh UI elements
class AuthAgentWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Claude Auth Agent")
self.resize(720, 500)
self.signals = GuiSignals()
self._build_ui()
self.signals.log.connect(self.append_log)
def _build_ui(self):
self.console = QTextEdit(readOnly=True)
self.start_btn = QPushButton("Start Login")
self.start_btn.clicked.connect(self.start_flow)
layout = QVBoxLayout()
layout.addWidget(self.console)
layout.addWidget(self.start_btn)
container = QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
def append_log(self, msg: str):
self.console.append(msg)
def start_flow(self):
self.start_btn.setEnabled(False)
asyncio.create_task(run_auth_flow(self.signals))
async def run_auth_flow(signals: GuiSignals):
memory = AgentMemory()
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
# 1️⃣ Stream Claude's plan
async for event in client.computer.use(
model="claude-3-5-sonnet-202406",
messages=[{"role":"user","content":"Login to https://demo.saas.example.com"}],
stream=True,
):
# Claude sends tool calls as JSON payloads
if event.type == "tool_start":
tool = event.tool
args = event.args
signals.log.emit(f"[Claude] → {tool} {json.dumps(args)}")
# Dispatch to our Python implementation
if tool == "fill_login_form":
success = await fill_login_form(page=args["page"], username=args["username"], password=args["password"])
elif tool == "handle_oauth_redirect":
success = await handle_oauth_redirect(page=args["page"], memory=memory)
elif tool == "solve_mfa":
success = await solve_mfa(page=args["page"], memory=memory)
else:
success = False
# Report back to Claude
await client.computer.tool_finish(
tool=tool,
success=success,
output={"status":"ok"} if success else {"error":"tool_failed"}
)
elif event.type == "message":
signals.log.emit(f"[Claude] {event.content}")
# 2️⃣ Exchange OAuth code for token (if captured)
if memory.oauth_state:
token = await exchange_code_for_token(memory.oauth_state)
memory.access_token = token["access_token"]
memory.refresh_token = token.get("refresh_token")
memory.expires_at = datetime.fromtimestamp(token["expires_at"], tz=timezone.utc)
signals.log.emit("[Agent] Login successful – token stored securely.")
else:
signals.log.emit("[Agent] Login failed – no OAuth code.")
The key to non‑blocking behavior is the `asyncio.create_task` call triggered from the Qt slot. The `GuiSignals` object marshals text back onto the main thread, keeping the UI snappy even while Claude streams dozens of tool calls.
—
4. The Secure Authentication Flow: A Step‑by‑Step Implementation
Credential Pickup: Secure Vault Injection & Auto‑Filling Login Forms
# credentials.py – version 1.0
import keyring
from typing import Tuple
def get_user_pass(service: str, user: str) -> Tuple[str, str]:
"""Fetch username/password from OS keychain; raise if missing."""
pw = keyring.get_password(service, user)
if not pw:
raise RuntimeError(f"Password for {user}@{service} not found in keychain")
return (user, pw)
We never write the password to a temporary file. The `fill_login_form` tool receives the cleartext only inside the same async task that talks to Playwright, then discards it.
Navigating the OAuth 2.0 Dance: Handling Redirects & Consent
OAuth 2.0 code‑grant requires three distinct pages:
- **Authorization request** – Claude clicks the “Sign in with SSO” button.
- **Consent screen** – Claude reads any checkboxes, decides whether to accept automatically (based on policy) or ask the user.
- **Redirect URI** – The page URL now holds `code=`; `handle_oauth_redirect` captures it.
The code‑exchange step uses `oauthlib`:
# token_exchange.py – version 1.0
import aiohttp
from oauthlib.oauth2 import BackendApplicationClient
from requests.auth import HTTPBasicAuth
async def exchange_code_for_token(code: str) -> dict:
token_url = "https://auth.example.com/oauth/token"
client_id = os.getenv("OAUTH_CLIENT_ID")
client_secret = os.getenv("OAUTH_CLIENT_SECRET")
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "myapp://callback"
}
async with aiohttp.ClientSession() as sess:
async with sess.post(token_url, data=data,
auth=HTTPBasicAuth(client_id, client_secret)) as resp:
resp.raise_for_status()
return await resp.json()
All network traffic stays inside `asyncio`; any 429 or 5xx response triggers an exponential back‑off (see **Production Hardening**).
Conquering MFA & Progressive Challenges (CAPTCHA, Biometric Prompts)
Claude can recognize that a selector `#captcha` appeared. The tool wrapper returns `False`, prompting a fallback UI that asks the human to solve it. For SMS‑based TOTP, we expose a tiny modal (`prompt_user_input`) where the user types the code. If a WebAuthn prompt appears, we surface a native OS dialog and abort automation, because without a hardware token there is nothing safe to do.
Finalizing the Session: Token Capture & Secure Persistence
# secure_store.py – version 1.0
import os, json
from cryptography.hazmat.primitives.ciphers.aead