OAuth flows (Phase 82.10.u)

Two-step admin RPC flow that lets an operator authorise a Claude subscription or MiniMax Token Plan from the SPA wizard without the SPA ever touching the PKCE verifier or refresh tokens.

Why an admin RPC and not just stdin

Pre-82.10.u, OAuth lived inside crates/setup/src/services/: interactive stdin paste, claude login style. That works for single-tenant operators who own the daemon shell, but for a multi-tenant SaaS the operator is a browser tab — there is no stdin.

Phase 82.10.u extracts the PKCE primitives (crates/llm-auth) and exposes them over admin RPC so a SPA can drive the same flow. The framework owns the verifier across the two HTTP requests via InMemoryVerifierStore; the SPA only sees opaque session ids.

Endpoints

nexo/admin/llm_providers/oauth_start

// req
{
  "factory_type": "anthropic",
  "auth_mode": "oauth_auth_code",
  "tenant_id": null
}
// resp (auth_code)
{
  "session_id": "f2c1...",
  "authorize_url": "https://claude.ai/oauth/authorize?...",
  "expires_at_ms": 1714776600000,
  "flow_kind": "auth_code"
}
// resp (device_code) — for `(minimax, oauth_device_code)`
{
  "session_id": "9a3e...",
  "authorize_url": "https://api.minimax.io/...",
  "expires_at_ms": ...,
  "flow_kind": "device_code",
  "user_code": "ABC123",
  "polling_interval_ms": 2000
}

nexo/admin/llm_providers/oauth_finish

// req — auth_code
{
  "session_id": "f2c1...",
  "instance_id": "anthropic-personal",
  "code": "abc#def"        // operator pasted from callback page
}
// req — device_code (no code; daemon polls)
{
  "session_id": "9a3e...",
  "instance_id": "minimax-cliente-a"
}
// resp
{
  "ok": true,
  "account_email": "user@example.com",  // Anthropic only
  "expires_at_ms": 1714780200000,
  "secret_id": "LLM_ANTHROPIC_PERSONAL_OAUTH_BUNDLE"
}

State machine

[oauth_start]                                    [oauth_finish]
─────────────                                    ─────────────
                          (10 min TTL)
PKCE gen → store.put → ...........  → take →  exchange/poll → bundle → SecretsStore
                                                                     ↓
                                                                yaml patch:
                                                                  auth.mode = oauth_bundle
                                                                  auth.bundle = <secret path>
                                                                     ↓
                                                                reload_signal()

Defensive design

ConcernMitigation
CSRFstate is checked against the stashed PKCE state inside exchange_code
Replaytake() removes the entry BEFORE exchange; second call → SESSION_NOT_FOUND
Expired sessionspeek_status discriminates Live / Expired / Missing so the SPA gets accurate diagnostics
Memory bloatBackground sweep every 60 s drops stale entries; capacity 100 with FIFO eviction
Verifier leakVerifier never travels to the SPA — only opaque session_id
Auditcode, access_token, refresh_token, oauth_bundle masked via redact_secret_keys

Client SDK

crates/llm-auth exposes the primitives so any consumer (admin RPC, CLI wizard, future MCP server) shares the same crypto + HTTP shape:

#![allow(unused)]
fn main() {
use nexo_llm_auth::{gen_pkce, StateEncoding};
use nexo_llm_auth::anthropic::{build_authorize_url, exchange_code, TOKEN_URL};

let pkce = gen_pkce(StateEncoding::HexOnly);
let url = build_authorize_url(&pkce);
// ... operator pastes `<code>#<state>` ...
let bundle = exchange_code(&pkce, &code, &state, TOKEN_URL).await?;
}

CLI flow (unchanged)

agent setup anthropicoauth_login mode still uses crates/setup/src/services/anthropic_oauth.rs which now wraps the same nexo-llm-auth primitives. Operators with shell access keep the stdin paste UX.

Microapp wizard

The SPA-side UI lives in agent-creator-microapp/frontend/src/components/OAuthPane.tsx and the zustand store agent-creator-microapp/frontend/src/lib/oauthFlow.ts. State machine: idle → starting → awaiting_user → exchanging → success | error. Auth-code variant renders a paste box; device-code variant renders the user_code + verification_uri with a Confirm button (the daemon polls upstream).