QR-based terminal login. Terminal apps can't do browser OAuth well, and
the auth often needs to happen on a different device (your phone)
than where the app runs (a headless workstation). The classic
localhost-callback trick fails there: the phone can't reach
localhost:8765 on the workstation.
This library packages two proven patterns for that situation. The core is provider-agnostic (any OAuth 2.0 + PKCE provider); the presets and field-tested gotchas come from X (Twitter) API v2 and the grok CLI.
For providers that only support redirect-based OAuth. A tiny Cloudflare Worker acts as the OAuth rendezvous:
Terminal (TUI) Phone Cloudflare
────────────── ───── ──────────
1. Generate PKCE pair + random state
persist (state, verifier, TTL) locally
2. Render auth URL as ASCII QR ───────► 3. Scan, log in to provider
4. Provider redirects to
Worker /callback ────► 5. Worker stores
oauth-pending/{state}
= {code} in KV (TTL 300s)
+ renders success page
(code visible as manual
fallback)
6. Terminal polls KV (fast only while ◄────────────────────────── Workers KV
auth pending), finds code,
exchanges code+verifier for tokens,
DELETES the KV entry
7. on_complete fires ─► dismiss the QR modal
Three security properties, preserved by design:
- PKCE keeps the relay low-trust — the
code_verifiernever leaves the workstation. The Worker/KV only ever see the authorization code, which is useless without the verifier. A compromised relay can't mint tokens. stateis the rendezvous key — random, single-use, TTL'd on both sides (local pending table and KVexpirationTtl: 300).- Delete-after-claim — the KV entry is removed immediately after a successful exchange; the 5-minute TTL caps the window if the flow is abandoned.
from xai_auth import KVRelayConfig, KVRelayPoller, PKCEFlow, SqliteStore, qr_ascii, x_provider
provider = x_provider(client_id="...", redirect_uri="https://your-relay.workers.dev/callback")
flow = PKCEFlow(provider, SqliteStore("~/.config/myapp/auth.sqlite3"))
url, state = await flow.begin()
print(qr_ascii(url))
print(url) # keep the plain URL visible — some users are on the same machine
config = KVRelayConfig.from_env() # CLOUDFLARE_ACCOUNT_ID / _API_TOKEN / _KV_NAMESPACE_ID
if config:
tokens = await KVRelayPoller(config, flow).wait_for_completion()
else:
# Relay unconfigured is a tier, not an error: manual code entry still works
tokens = await flow.complete_by_state(input("Paste the code shown in the browser: "), state)For daemon-style hosts, KVRelayPoller.run() is a long-running loop
with adaptive cadence: fast polling only while an auth is pending,
a slow existence check otherwise (the Cloudflare API is rate-limited
and not free at high frequency).
cd relay
wrangler kv namespace create AUTH_SESSIONS # paste the id into wrangler.toml
wrangler deployOne route, one KV namespace, ~150 lines, no framework. Point your
provider app's redirect_uri at https://<worker-host>/callback.
ENGINE_CALLBACK_URL (optional) makes the Worker POST {code, state}
directly to your engine — a latency optimization only; the engine may
be behind NAT/firewall, so KV polling is the reliable path.
If the provider supports RFC 8628 device flow, the provider hosts the rendezvous — skip the Worker entirely:
from xai_auth import DeviceFlowEndpoint, qr_ascii
endpoint = DeviceFlowEndpoint(
device_authorization_endpoint="https://provider/oauth2/device",
token_endpoint="https://provider/oauth2/token",
client_id="...",
)
authorization = await endpoint.authorize()
print(qr_ascii(authorization.qr_url))
token_data = await endpoint.poll_for_token(authorization)Or wrap a provider CLI that runs its own device flow:
from xai_auth import CLIDeviceLogin, qr_ascii
login = CLIDeviceLogin(["grok", "login", "--device-auth"])
url = await login.start() # scans stdout AND stderr (grok prints to stderr!)
print(qr_ascii(url))
await login.wait() # returns once the user approves on their phoneuv add xai-auth # core: httpx + qrcode
uv add 'xai-auth[textual]' # + QRCodeModal Textual frontendfrom xai_auth.frontends.textual import QRCodeModal
app.push_screen(QRCodeModal("Sign in to X", url))
# auto-dismiss: in the poller's on_complete, pop the modal if it's the current screen| Piece | Deps | Config |
|---|---|---|
QR render (qr_ascii) |
qrcode |
— |
OAuth core (PKCEFlow) |
httpx |
provider client_id, redirect_uri, scopes |
| Relay Worker | wrangler | KV namespace, optional ENGINE_CALLBACK_URL |
KV polling (KVRelayPoller) |
httpx |
CF account_id, API token (KV read+delete only — scope it minimally), namespace_id |
| Device flow | httpx / subprocess |
provider device endpoint or CLI |
| Textual modal | textual (extra) |
— |
Token storage defaults to a 2-table sqlite schema (SqliteStore); the
Store protocol lets you back it with postgres, keyring, etc.
Tokens are stored unencrypted at rest. SqliteStore is plaintext
sqlite — keep the file private (e.g. a chmod 600 path under
~/.config, which is also why *.sqlite3 is git-ignored), or back the
Store protocol with a keyring / secret manager on shared hosts.
- State lookup is by-state, never by-session — the phone completes
the flow with zero knowledge of the terminal session;
stateis the only correlation key. - Worker direct-POST is an optimization, not the mechanism — KV polling is the reliable path; the direct callback just shortens latency when the engine is reachable.
- Poll cadence — fast-poll only while a pending auth exists. Unconditional fast polling burns Cloudflare API quota for nothing.
- Delete-after-claim — remove the KV entry on successful exchange or replays within the TTL window are possible (PKCE limits the damage, but don't rely on it alone).
- QR rendering —
invert=Truematters on dark terminals; always keep the plain-URL fallback visible. - Provider CLIs are weird — grok prints its verification URL to
stderr, and a bare
grokfailing with ENXIO just means no/dev/tty.CLIDeviceLogincaptures both streams.
uv sync
uv run pytest