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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user