feat(providers): add xAI Grok subscription OAuth (device-code) + per-provider session auth

First subscription-OAuth provider beyond Kimi Code (26th registry variant).
Log in with a Grok/SuperGrok/X subscription via RFC-8628 device-code OAuth
(auth.x.ai), then use it against api.x.ai/v1 — reusing the existing xai wire
(ChatCompletions + OpenAI listing + Passthrough + restrict + models_dev_id
xai). Sourced from Pi (earendil-works/pi auth/oauth/xai.ts): client
b1a00492..., scope 'openid profile email offline_access grok-cli:access
api:access', standard Bearer (no x-xai-token-auth).

Foundation (generalizes Kigi's Kimi-singleton OAuth to per-provider, root
cause, not a patch):
- Registry: OAuthConfig on PlatformSpec (client_id/host/device+token
  paths/scope/scope_key); XAI_OAUTH_CONFIG + XAI_GROK_SPEC (uses_oauth, method
  id 'xai-grok', an interactive login after kimi-code).
- Generic device-code wire (auth/oauth_device.rs) + GenericDeviceRefresher,
  sharing the RFC-8628 core with Kimi; Kimi's bespoke flow is byte-identical
  (X-Msh headers, KIMI_CODE_OAUTH_SCOPE, keyring gating unchanged).
- Per-provider AuthManager via a process-global pool (auth/oauth_registry.rs):
  build-on-demand with start_proactive_refresh, keyed by scope. The session
  resolves the AuthManager for the ACTIVE model's platform for bearer/refresh/
  401-recovery/api_key — an oauth-platform model always uses its OWN token,
  never the primary.
- Live /models under OAuth; base routes oauth().is_some() -> platform.base_url()
  (kimi-code stays on proxy_url).

Security: adversarial review + a systematic token-leak audit found and closed
FIVE channels where the primary Kimi token could reach api.x.ai (bearer
resolver, api_key stamping, aux summary/classifier/image-describe models, and
subagent model-override). Each fix routes through the platform-aware resolver
(the oauth model's pooled token or None, NEVER the primary) and is revert-to-red
verified. No access/refresh token is ever logged.

Registry at 26; picker updated (xai-grok interactive login row); TUI
context-window already auto-updates per model. Full gate green (234 suites,
fmt, clippy -D warnings, deny). GPT/Claude/Grok officially permit third-party
subscription use.
This commit is contained in:
2026-07-22 01:36:29 -04:00
parent 8a26460251
commit dbce6bf305
26 changed files with 2359 additions and 161 deletions
@@ -13,15 +13,47 @@
use std::sync::Arc;
use crate::auth::kimi_oauth::{
DeviceAuthorization, DevicePollResult, poll_device_token, request_device_authorization,
};
use kigi_models::OAuthConfig;
use crate::auth::kimi_oauth::{DeviceAuthorization, DevicePollResult};
use crate::auth::{AuthChannels, AuthManager, AuthUrlInfo, AuthUrlMode, KimiAuth};
/// Extra wait added to the poll interval when the server answers `slow_down`
/// (OAuth-standard device-flow backpressure).
const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
/// The wire behind a device-code login. The `Kimi` arm calls the bespoke Kimi
/// Code wire (X-Msh headers, `/api/oauth/*`) verbatim — byte-identical to the
/// pre-generalization path; the `Generic` arm drives a registry
/// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`].
enum DeviceFlowBackend<'a> {
Kimi { host: &'a str },
Generic(&'a OAuthConfig),
}
impl DeviceFlowBackend<'_> {
async fn request(&self) -> anyhow::Result<DeviceAuthorization> {
match self {
Self::Kimi { host } => {
crate::auth::kimi_oauth::request_device_authorization(host).await
}
Self::Generic(cfg) => {
crate::auth::oauth_device::request_device_authorization(cfg).await
}
}
}
async fn poll(&self, device_code: &str) -> anyhow::Result<DevicePollResult> {
match self {
Self::Kimi { host } => {
crate::auth::kimi_oauth::poll_device_token(host, device_code).await
}
Self::Generic(cfg) => {
crate::auth::oauth_device::poll_device_token(cfg, device_code).await
}
}
}
}
/// Outcome of one full poll loop over a single device authorization.
enum PollLoopOutcome {
/// Access token issued.
@@ -40,11 +72,29 @@ pub async fn run_device_code_login_channels(
host: &str,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(DeviceFlowBackend::Kimi { host }, auth_manager, channels).await
}
/// Device-code login for a GENERIC [`OAuthConfig`] provider (xai-grok). Same
/// TUI/CLI presentation as the Kimi login; only the wire differs.
pub async fn run_device_code_login_generic(
oauth: &OAuthConfig,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
run_device_code_login_backend(DeviceFlowBackend::Generic(oauth), auth_manager, channels).await
}
async fn run_device_code_login_backend(
backend: DeviceFlowBackend<'_>,
auth_manager: &Arc<AuthManager>,
channels: &mut Option<AuthChannels>,
) -> anyhow::Result<(KimiAuth, bool)> {
let interactive_tui = channels.is_some();
let mut channels = channels.take();
loop {
let device_auth = request_device_authorization(host).await?;
let device_auth = backend.request().await?;
let display_uri = device_auth.verification_uri_complete.clone();
if interactive_tui {
@@ -62,7 +112,7 @@ pub async fn run_device_code_login_channels(
prompt_on_stderr(&device_auth).await;
}
match complete_device_code_login(host, &device_auth).await? {
match complete_device_code_login(&backend, &device_auth).await? {
PollLoopOutcome::Done(auth) => {
let auth = auth_manager
.update(*auth)
@@ -112,7 +162,7 @@ async fn prompt_on_stderr(device_auth: &DeviceAuthorization) {
/// Poll the token endpoint until the user approves, the device code expires
/// (→ [`PollLoopOutcome::Restart`]), or the wire fails.
async fn complete_device_code_login(
host: &str,
backend: &DeviceFlowBackend<'_>,
device_auth: &DeviceAuthorization,
) -> anyhow::Result<PollLoopOutcome> {
let mut poll_interval = std::time::Duration::from_secs(device_auth.interval.max(1) as u64);
@@ -120,7 +170,7 @@ async fn complete_device_code_login(
// Sleep first: an immediate poll on a fresh code only returns
// authorization_pending (and risks slow_down).
tokio::time::sleep(poll_interval).await;
match poll_device_token(host, &device_auth.device_code).await? {
match backend.poll(&device_auth.device_code).await? {
DevicePollResult::Success(auth) => {
tracing::info!("auth: device login authorized");
return Ok(PollLoopOutcome::Done(auth));