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:
@@ -60,9 +60,12 @@ impl SessionActor {
|
||||
stream_tool_calls: Some(sampling_config.stream_tool_calls),
|
||||
});
|
||||
let existing = self.chat_state_handle.get_credentials().await;
|
||||
// Read the session bearer from the switched-to model's OWN manager: a
|
||||
// grok model reads the xai-grok token (used only to classify the
|
||||
// credential's auth_type here), never the Kimi one. Kimi / non-oauth
|
||||
// models resolve to the primary — byte-identical.
|
||||
let session_key = self
|
||||
.auth_manager
|
||||
.as_ref()
|
||||
.auth_manager_for_model(&sampling_config.model)
|
||||
.and_then(|am| am.current_or_expired().map(|a| a.key));
|
||||
self.chat_state_handle
|
||||
.update_credentials(kigi_chat_state::Credentials {
|
||||
|
||||
@@ -624,12 +624,25 @@ impl SessionActor {
|
||||
let resolved_describe = self
|
||||
.resolve_aux_sampler_config(&self.image_description_model)
|
||||
.await;
|
||||
let (describe_model, sampler_config) =
|
||||
// LEAK 1b: only re-point the aux bearer_resolver when the aux model
|
||||
// actually resolved (Some) — the `None` fallback yields the SESSION
|
||||
// config, whose Kimi resolver must stay as-is.
|
||||
let aux_resolved = resolved_describe.is_some();
|
||||
let (describe_model, mut sampler_config) =
|
||||
crate::agent::config::finalize_image_describe_sampler_config(
|
||||
resolved_describe,
|
||||
&active_session_config,
|
||||
Some(self.max_retries),
|
||||
);
|
||||
// A grok (oauth-platform) image-describe model must not inherit the
|
||||
// session (Kimi) bearer_resolver stamped by `finalize_*`; re-point it at
|
||||
// grok's own manager. No-op for a first-party / non-oauth model.
|
||||
if aux_resolved {
|
||||
self.repoint_aux_bearer_resolver_for_oauth(
|
||||
&mut sampler_config,
|
||||
&self.image_description_model,
|
||||
);
|
||||
}
|
||||
let client = kigi_sampler::SamplingClient::new(sampler_config).map_err(|e| {
|
||||
acp::Error::internal_error().data(format!(
|
||||
"failed to build image-describe sampling client: {e}"
|
||||
|
||||
@@ -103,6 +103,30 @@ where
|
||||
result
|
||||
}
|
||||
}
|
||||
/// Wraps an [`AuthManager`](crate::auth::AuthManager) as a sampler
|
||||
/// [`BearerResolver`](kigi_sampler::BearerResolver), resolving the live
|
||||
/// (current-or-expired) bearer at request time. Shared by
|
||||
/// [`SessionActor::reconstruct_full_config`] (the session model) and the
|
||||
/// aux-model bearer repoint ([`SessionActor::repoint_aux_bearer_resolver_for_oauth`])
|
||||
/// so both wrap ONE definition. SECURITY: the bearer is resolved per request
|
||||
/// and never logged.
|
||||
pub(crate) struct AuthManagerBearerResolver(pub(crate) std::sync::Arc<crate::auth::AuthManager>);
|
||||
impl std::fmt::Debug for AuthManagerBearerResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthManagerBearerResolver").finish()
|
||||
}
|
||||
}
|
||||
impl kigi_sampler::BearerResolver for AuthManagerBearerResolver {
|
||||
fn current_bearer(&self) -> Option<String> {
|
||||
self.0.current_or_expired().map(|a| a.key)
|
||||
}
|
||||
}
|
||||
/// Wrap `am` as a shared sampler bearer resolver.
|
||||
fn auth_manager_bearer_resolver(
|
||||
am: std::sync::Arc<crate::auth::AuthManager>,
|
||||
) -> kigi_sampler::SharedBearerResolver {
|
||||
std::sync::Arc::new(AuthManagerBearerResolver(am))
|
||||
}
|
||||
impl SessionActor {
|
||||
pub(super) async fn prepare_tool_definitions_timed(&self) -> (Vec<ToolDefinition>, u64) {
|
||||
let mcp_wait_start = std::time::Instant::now();
|
||||
@@ -184,6 +208,76 @@ impl SessionActor {
|
||||
let auth_method = self.auth_method_id.load();
|
||||
SessionTokenAuthGate::new(auth_method.as_deref(), byok, base_url)
|
||||
}
|
||||
/// The [`AuthManager`](crate::auth::AuthManager) that governs INFERENCE auth
|
||||
/// for the model whose routing slug is `model` (the sampling config's
|
||||
/// `model`). A generic device-code OAuth platform (xai-grok) routes to its
|
||||
/// OWN scope-keyed manager from the process-global OAuth pool
|
||||
/// ([`crate::auth::oauth_registry::manager_for_model`], built on demand from
|
||||
/// the on-disk token); every other model routes to the primary Kimi
|
||||
/// `auth_manager`.
|
||||
///
|
||||
/// This is the single chokepoint that keeps a grok turn from ever sending
|
||||
/// the Kimi bearer (Facet B) and gives it its own proactive-refresh + 401
|
||||
/// recovery manager (Facet A). The pool is the single source of truth, so a
|
||||
/// grok login that lands AFTER this session spawned is resolved on the next
|
||||
/// grok turn (no frozen per-session snapshot). Cheap `Arc` clone. Kimi /
|
||||
/// first-party path is byte-identical: a non-oauth model always resolves to
|
||||
/// the primary.
|
||||
///
|
||||
/// `None` when there is no governing manager: a BYOK / test session with no
|
||||
/// primary. A grok model always resolves to its pooled manager (never the
|
||||
/// Kimi primary); when the user has not logged into grok that manager simply
|
||||
/// holds no token, so no Kimi bearer can leak.
|
||||
pub(super) fn auth_manager_for_model(
|
||||
&self,
|
||||
model: &str,
|
||||
) -> Option<std::sync::Arc<crate::auth::AuthManager>> {
|
||||
// `model` is the bare routing slug; recover the managed catalog key
|
||||
// (`{platform}/{model}`) so the platform — and thus its OAuth scope — is
|
||||
// unambiguous. A bare / config / unlisted model yields no managed key
|
||||
// and resolves to the primary.
|
||||
let managed_key = self.managed_key_for_model(model);
|
||||
crate::auth::oauth_registry::manager_for_model(
|
||||
&crate::util::kigi_home::kigi_home(),
|
||||
managed_key.as_deref().unwrap_or(model),
|
||||
self.auth_manager.as_ref(),
|
||||
)
|
||||
}
|
||||
/// Recover the managed catalog key (`{platform}/{model}`) for a routing slug
|
||||
/// from the live catalog. `None` for a bare / config / unlisted model.
|
||||
fn managed_key_for_model(&self, model: &str) -> Option<String> {
|
||||
let models = self.models_manager.models();
|
||||
crate::agent::config::find_model_by_id(&models, model).and_then(|e| e.info().id.clone())
|
||||
}
|
||||
/// Whether the aux/session model `model` routes to a generic device-code
|
||||
/// OAuth platform (xai-grok). The gate for re-pointing an aux model's
|
||||
/// bearer_resolver away from the session (Kimi) resolver — a first-party /
|
||||
/// non-oauth model returns `false` and keeps the stamped session resolver.
|
||||
fn model_is_oauth_platform(&self, model: &str) -> bool {
|
||||
let managed_key = self.managed_key_for_model(model);
|
||||
kigi_models::parse_managed_model_key(managed_key.as_deref().unwrap_or(model))
|
||||
.and_then(|(platform, _)| platform.oauth())
|
||||
.is_some()
|
||||
}
|
||||
/// LEAK guard for the stamped aux paths (auto-mode classifier, image
|
||||
/// describe). After [`crate::agent::config::stamp_session_local_sampler_fields`]
|
||||
/// has copied the SESSION model's `bearer_resolver` onto an aux
|
||||
/// `SamplerConfig`, re-point it at the AUX model's OWN platform manager when
|
||||
/// the aux model is an oauth platform (xai-grok) — so a grok aux model never
|
||||
/// inherits the live Kimi session bearer (→ api.x.ai). No-op for a
|
||||
/// first-party / non-oauth aux model (keeps the stamped session resolver →
|
||||
/// byte-identical). SECURITY: no token is logged.
|
||||
pub(super) fn repoint_aux_bearer_resolver_for_oauth(
|
||||
&self,
|
||||
cfg: &mut kigi_sampler::SamplerConfig,
|
||||
slug: &str,
|
||||
) {
|
||||
if self.model_is_oauth_platform(slug)
|
||||
&& let Some(manager) = self.auth_manager_for_model(slug)
|
||||
{
|
||||
cfg.bearer_resolver = Some(auth_manager_bearer_resolver(manager));
|
||||
}
|
||||
}
|
||||
/// Emit a unified-log breadcrumb whenever the session-token refresh gate is
|
||||
/// evaluated with an **`Unknown`** per-model BYOK status on a session-based
|
||||
/// method — the condition that (pre-fix) silently demoted live sessions to
|
||||
@@ -237,18 +331,6 @@ impl SessionActor {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(clippy::items_after_statements)]
|
||||
struct AuthManagerBearerResolver(std::sync::Arc<crate::auth::AuthManager>);
|
||||
impl std::fmt::Debug for AuthManagerBearerResolver {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AuthManagerBearerResolver").finish()
|
||||
}
|
||||
}
|
||||
impl kigi_sampler::BearerResolver for AuthManagerBearerResolver {
|
||||
fn current_bearer(&self) -> Option<String> {
|
||||
self.0.current_or_expired().map(|a| a.key)
|
||||
}
|
||||
}
|
||||
let cfg = self
|
||||
.chat_state_handle
|
||||
.get_sampling_config()
|
||||
@@ -273,6 +355,16 @@ impl SessionActor {
|
||||
SessionTokenAuthGate::new(auth_method.as_deref(), model_facts.byok, &cfg.base_url);
|
||||
let use_bearer_resolver = gate.active();
|
||||
self.log_auth_gate_unknown("reconstruct_full_config", gate, &cfg.base_url);
|
||||
// Resolve the bearer from the ACTIVE model's OWN manager: a grok model
|
||||
// wraps the xai-grok manager, never the Kimi one (captured before
|
||||
// `cfg.model` is moved into the struct below). `None` when the gate is
|
||||
// inactive or the oauth provider has no manager (fail-fast, no Kimi
|
||||
// fallback).
|
||||
let inference_auth_manager = if use_bearer_resolver {
|
||||
self.auth_manager_for_model(&cfg.model)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let auth_scheme = model_facts.auth_scheme;
|
||||
let mut extra_headers = cfg.extra_headers;
|
||||
crate::agent::config::inject_url_derived_headers(
|
||||
@@ -323,15 +415,7 @@ impl SessionActor {
|
||||
idle_timeout_secs: None,
|
||||
origin_client: self.origin_client.clone(),
|
||||
attribution_callback: self.attribution_callback.clone(),
|
||||
bearer_resolver: if use_bearer_resolver {
|
||||
self.auth_manager
|
||||
.as_ref()
|
||||
.map(|am| -> kigi_sampler::SharedBearerResolver {
|
||||
std::sync::Arc::new(AuthManagerBearerResolver(am.clone()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
bearer_resolver: inference_auth_manager.map(auth_manager_bearer_resolver),
|
||||
supports_backend_search: self.supports_backend_search.get(),
|
||||
compactions_remaining: self.compactions_remaining.get(),
|
||||
compaction_at_tokens: self.compaction_at_tokens.get(),
|
||||
@@ -461,10 +545,16 @@ impl SessionActor {
|
||||
slug: &str,
|
||||
) -> Option<kigi_sampler::SamplerConfig> {
|
||||
let creds = self.chat_state_handle.get_credentials().await;
|
||||
let session_key = self
|
||||
.auth_manager
|
||||
.as_ref()
|
||||
.and_then(|am| am.current_or_expired().map(|a| a.key.clone()));
|
||||
// Resolve the aux token by the aux model's OWN platform: a grok
|
||||
// (oauth-platform) aux model draws its pooled grok token or `None` —
|
||||
// NEVER the primary Kimi session token (which `resolve_credentials`
|
||||
// would otherwise stamp onto an api.x.ai request). A first-party /
|
||||
// non-oauth aux model still gets the primary (byte-identical).
|
||||
let session_key = crate::auth::oauth_registry::session_key_for_model(
|
||||
&crate::util::kigi_home::kigi_home(),
|
||||
slug,
|
||||
self.auth_manager.as_ref(),
|
||||
);
|
||||
let models = self.models_manager.models();
|
||||
let endpoints = self.models_manager.endpoints();
|
||||
crate::agent::config::resolve_aux_model_sampling_config(
|
||||
@@ -491,6 +581,10 @@ impl SessionActor {
|
||||
&active_session_config,
|
||||
Some(self.max_retries),
|
||||
);
|
||||
// LEAK 1b: a grok aux classifier must not inherit the SESSION model's
|
||||
// (Kimi) bearer_resolver stamped above; re-point it at grok's own
|
||||
// manager (its pooled token, or None). No-op for a non-oauth aux.
|
||||
self.repoint_aux_bearer_resolver_for_oauth(&mut cfg, slug);
|
||||
let model = cfg.model.clone();
|
||||
let client = kigi_sampler::SamplingClient::new(cfg)
|
||||
.map_err(|e| {
|
||||
@@ -661,29 +755,40 @@ impl SessionActor {
|
||||
)),
|
||||
);
|
||||
}
|
||||
if auth_recovery_eligible && let Some(ref am) = self.auth_manager {
|
||||
if am.try_recover_unauthorized().await {
|
||||
tracing::info!(
|
||||
// Recover via the ACTIVE model's OWN manager: a grok 401 recovers the
|
||||
// xai-grok session via the xai-grok manager, never the Kimi one. For a
|
||||
// Kimi / non-oauth model this resolves to the primary — byte-identical.
|
||||
if auth_recovery_eligible {
|
||||
let recovery_model = self
|
||||
.chat_state_handle
|
||||
.get_sampling_config()
|
||||
.await
|
||||
.map(|c| c.model)
|
||||
.unwrap_or_default();
|
||||
if let Some(am) = self.auth_manager_for_model(&recovery_model) {
|
||||
if am.try_recover_unauthorized().await {
|
||||
tracing::info!(
|
||||
session_id = % self.session_info.id.0,
|
||||
"auth recovery: sampler 401, recovered, retrying"
|
||||
);
|
||||
kigi_log::unified_log::info(
|
||||
"auth recovery: sampler 401, recovered, retrying",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
None,
|
||||
);
|
||||
self.prepare_sampler_for_turn().await;
|
||||
return Ok(SamplerFailureRecovery::RefreshAuthAndResubmit);
|
||||
}
|
||||
tracing::warn!(
|
||||
session_id = % self.session_info.id.0,
|
||||
"auth recovery: sampler 401, recovered, retrying"
|
||||
"auth recovery: sampler 401, refresh failed"
|
||||
);
|
||||
kigi_log::unified_log::info(
|
||||
"auth recovery: sampler 401, recovered, retrying",
|
||||
kigi_log::unified_log::warn(
|
||||
"auth recovery: sampler 401, refresh failed",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
None,
|
||||
);
|
||||
self.prepare_sampler_for_turn().await;
|
||||
return Ok(SamplerFailureRecovery::RefreshAuthAndResubmit);
|
||||
}
|
||||
tracing::warn!(
|
||||
session_id = % self.session_info.id.0,
|
||||
"auth recovery: sampler 401, refresh failed"
|
||||
);
|
||||
kigi_log::unified_log::warn(
|
||||
"auth recovery: sampler 401, refresh failed",
|
||||
Some(self.session_info.id.0.as_ref()),
|
||||
None,
|
||||
);
|
||||
}
|
||||
if matches!(error.kind, SamplingErrorKind::IdleTimeout) {
|
||||
self.signals_handle().record_idle_timeout();
|
||||
@@ -846,14 +951,17 @@ impl SessionActor {
|
||||
}
|
||||
/// Proactively refresh the auth token if near expiry.
|
||||
pub(super) async fn refresh_token_if_expired(&self) {
|
||||
if let Some(ref am) = self.auth_manager {
|
||||
let (model_id, base_url) = self
|
||||
.chat_state_handle
|
||||
.get_sampling_config()
|
||||
.await
|
||||
.map(|c| (c.model, c.base_url))
|
||||
.unwrap_or_default();
|
||||
// Refresh the ACTIVE model's OWN manager: a grok model refreshes the
|
||||
// xai-grok token via the xai-grok manager, never the Kimi one. For a
|
||||
// Kimi / non-oauth model this resolves to the primary — byte-identical.
|
||||
if let Some(am) = self.auth_manager_for_model(&model_id) {
|
||||
let creds = self.chat_state_handle.get_credentials().await;
|
||||
let (model_id, base_url) = self
|
||||
.chat_state_handle
|
||||
.get_sampling_config()
|
||||
.await
|
||||
.map(|c| (c.model, c.base_url))
|
||||
.unwrap_or_default();
|
||||
if self.auth_gate(&model_id, &base_url).active()
|
||||
&& let Ok(key) = am.get_valid_token().await
|
||||
{
|
||||
@@ -975,3 +1083,47 @@ impl SessionActor {
|
||||
.push_assistant_response(assistant_item);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod bearer_resolver_tests {
|
||||
use super::AuthManagerBearerResolver;
|
||||
use kigi_sampler::BearerResolver;
|
||||
|
||||
/// LEAK 1b: the shared `AuthManagerBearerResolver` resolves the LIVE bearer
|
||||
/// of the manager it wraps. So an aux bearer_resolver built over grok's OWN
|
||||
/// (oauth) pooled manager yields grok's token (or `None`) — NEVER the Kimi
|
||||
/// session token that a Kimi-manager resolver would. The
|
||||
/// `repoint_aux_bearer_resolver_for_oauth` fix wraps exactly this grok
|
||||
/// manager for a grok aux model.
|
||||
#[tokio::test]
|
||||
async fn resolver_resolves_the_wrapped_manager_never_kimi() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let kimi = std::sync::Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
crate::auth::KimiCodeConfig::default(),
|
||||
));
|
||||
kimi.hot_swap(crate::auth::KimiAuth {
|
||||
key: "kimi-tok".to_string(),
|
||||
auth_mode: crate::auth::AuthMode::OAuth,
|
||||
..crate::auth::KimiAuth::test_default()
|
||||
});
|
||||
// The Kimi-manager resolver yields the Kimi bearer.
|
||||
assert_eq!(
|
||||
AuthManagerBearerResolver(kimi.clone()).current_bearer(),
|
||||
Some("kimi-tok".to_string()),
|
||||
);
|
||||
// The grok (oauth) pooled manager is distinct — its resolver never
|
||||
// yields the Kimi bearer (grok's own token, or None).
|
||||
let oauth = kigi_models::PlatformId::XaiGrok
|
||||
.oauth()
|
||||
.expect("xai-grok carries an OAuthConfig");
|
||||
let grok = crate::auth::oauth_registry::global_manager_for(
|
||||
&crate::util::kigi_home::kigi_home(),
|
||||
oauth,
|
||||
);
|
||||
assert_ne!(
|
||||
AuthManagerBearerResolver(grok).current_bearer(),
|
||||
Some("kimi-tok".to_string()),
|
||||
"a grok aux bearer_resolver must never resolve the Kimi session token",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user