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
@@ -114,7 +114,9 @@ pub struct BuiltAuthMethods {
/// 1. `xai.api_key` (if `has_external_api_key`)
/// 2. `cached_token` (if `has_cached_token`)
/// 3. `kimi-code` (the Kimi Code device login)
/// 4. every API-key registry platform, in `PlatformId::ALL` order
/// 4. every generic device-code OAuth login (`xai-grok`, …), in
/// `PlatformId::ALL` order — interactive logins after `kimi-code`
/// 5. every API-key registry platform, in `PlatformId::ALL` order
/// (`moonshot-cn`, `moonshot-ai`, …), always advertised
///
/// The platform methods are for the INTERACTIVE login picker only: they come
@@ -162,6 +164,15 @@ pub fn build_auth_methods(inputs: AuthMethodsBuildInputs<'_>) -> BuiltAuthMethod
}
methods.push(kimi_code_auth_method(login_label));
// Generic device-code OAuth logins (xai-grok, …) are interactive logins
// too: advertise them right after kimi-code, BEFORE the API-key platforms,
// so they stay out of the `auth_methods.first()` startup-metadata slot yet
// ahead of the api-key picker rows.
for platform in kigi_models::PlatformId::ALL {
if platform.oauth().is_some() {
methods.push(oauth_platform_auth_method(platform));
}
}
for platform in kigi_models::PlatformId::ALL {
if !platform.uses_oauth() {
methods.push(platform_auth_method(platform));
@@ -182,6 +193,9 @@ pub enum AuthMethodKind {
KimiCode,
/// Registry API-key platform login (method id = the platform id).
ApiKeyPlatform(kigi_models::PlatformId),
/// Generic device-code OAuth platform login (method id = the platform id,
/// e.g. `xai-grok`). Interactive, like Kimi Code.
OAuthPlatform(kigi_models::PlatformId),
Unknown,
}
@@ -191,9 +205,12 @@ impl AuthMethodKind {
XAI_API_KEY_METHOD_ID => Self::XaiApiKey,
CACHED_TOKEN_AUTH_METHOD_ID => Self::CachedToken,
KIMI_CODE_METHOD_ID => Self::KimiCode,
other => match platform_for_method_id_str(other) {
Some(platform) => Self::ApiKeyPlatform(platform),
None => Self::Unknown,
other => match kigi_models::PlatformId::parse(other) {
// A generic device-code OAuth platform (xai-grok).
Some(p) if p.oauth().is_some() => Self::OAuthPlatform(p),
// A non-OAuth API-key registry platform.
Some(p) if !p.uses_oauth() => Self::ApiKeyPlatform(p),
_ => Self::Unknown,
},
}
}
@@ -206,13 +223,33 @@ impl AuthMethodKind {
}
/// `true` for session-based methods (cached_token, interactive login).
///
/// `OAuthPlatform` (xai-grok) qualifies: it mints a refreshable device-code
/// session, so the per-turn refresh / 401-recovery gate must be ACTIVE for
/// it. This is correct ONLY because the session routes a model's
/// bearer/refresh/recovery to that model's OWN scope-keyed `AuthManager`
/// (see `SessionActor::auth_manager_for_model`) — the gate being active
/// wraps the grok manager for a grok turn, never the Kimi one.
pub fn is_session_based(self) -> bool {
matches!(self, Self::CachedToken | Self::KimiCode)
matches!(
self,
Self::CachedToken | Self::KimiCode | Self::OAuthPlatform(_)
)
}
/// Requires user interaction (device-code login in the browser).
/// Requires user interaction (device-code login in the browser). Both the
/// Kimi Code login and every generic device-code OAuth platform qualify.
pub fn needs_interactive_login(self) -> bool {
matches!(self, Self::KimiCode)
matches!(self, Self::KimiCode | Self::OAuthPlatform(_))
}
/// The generic device-code OAuth platform behind this method, if any
/// (drives the `authenticate` dispatch to the generic device flow).
pub fn oauth_platform(self) -> Option<kigi_models::PlatformId> {
match self {
Self::OAuthPlatform(p) => Some(p),
_ => None,
}
}
pub fn auth_error_message(self) -> &'static str {
@@ -337,6 +374,16 @@ pub fn kimi_code_auth_method(label: Option<&str>) -> acp::AuthMethod {
)
}
/// A generic device-code OAuth platform's interactive login method (method id
/// = the platform id, e.g. `xai-grok`; label from the spec's `login_label`).
pub fn oauth_platform_auth_method(platform: kigi_models::PlatformId) -> acp::AuthMethod {
let name = platform.login_label();
acp::AuthMethod::Agent(
acp::AuthMethodAgent::new(acp::AuthMethodId::new(platform.as_str()), name.to_string())
.description(Some(format!("Sign in with {name}"))),
)
}
/// Interactive API-key login method ids equal
/// [`kigi_models::PlatformId::as_str`] (`moonshot-cn` / `moonshot-ai` / …),
/// which is also the `[platforms.<id>]` config-table name and the auth.json
@@ -514,6 +561,73 @@ mod tests {
);
}
/// xai-grok is a generic device-code OAuth login: it classifies as an
/// `OAuthPlatform`, needs an interactive (browser) login, is NOT api-key,
/// is NOT an API-key registry platform, and is advertised right after
/// kimi-code among the interactive logins.
#[test]
fn xai_grok_is_an_interactive_oauth_login() {
let id = acp::AuthMethodId::new("xai-grok");
let kind = AuthMethodKind::from_id(&id);
assert_eq!(
kind,
AuthMethodKind::OAuthPlatform(kigi_models::PlatformId::XaiGrok)
);
assert!(
kind.needs_interactive_login(),
"xai-grok needs a browser login"
);
assert!(!kind.is_api_key(), "xai-grok is not an api-key method");
assert_eq!(
kind.oauth_platform(),
Some(kigi_models::PlatformId::XaiGrok)
);
// Never an API-key picker target (keeps it out of the paste-box path).
assert_eq!(platform_for_method_id(&id), None);
// Placement: immediately after kimi-code, before the api-key rows.
let built = build_auth_methods(default_inputs());
let ids = method_ids(&built);
let kimi_pos = ids.iter().position(|m| *m == KIMI_CODE_METHOD_ID).unwrap();
assert_eq!(
ids[kimi_pos + 1],
"xai-grok",
"xai-grok must be the interactive login right after kimi-code"
);
assert_eq!(
ids[kimi_pos + 2],
MOONSHOT_CN_METHOD_ID,
"the api-key rows follow the generic oauth logins"
);
}
/// A generic device-code OAuth platform (xai-grok) is SESSION-BASED: its
/// device-code session is refreshable, so the per-turn refresh / 401 gate
/// must be active for it (routing then sends the model's OWN manager). It
/// stays an interactive login and is NOT api-key-shaped.
#[test]
fn oauth_platform_is_session_based_and_refreshable() {
let id = acp::AuthMethodId::new("xai-grok");
let kind = AuthMethodKind::from_id(&id);
assert_eq!(
kind,
AuthMethodKind::OAuthPlatform(kigi_models::PlatformId::XaiGrok)
);
assert!(
kind.is_session_based(),
"OAuthPlatform must be session-based so the refresh/401 gate is active"
);
assert!(
is_session_based_method(&id),
"is_session_based_method(xai-grok) must be true"
);
// Session-based, yet still an interactive browser login and never
// api-key-shaped.
assert!(kind.needs_interactive_login());
assert!(!kind.is_api_key());
// The session-expired copy (not the api-key copy) is the right error.
assert_eq!(kind.auth_error_message(), AUTH_ERROR_SESSION_EXPIRED);
}
/// The OAuth platform id must never resolve as an API-key platform
/// method — `platform_for_method_id`'s `uses_oauth` filter is what keeps
/// the generic `authenticate` arm from hijacking the device login.
@@ -606,6 +720,7 @@ mod tests {
vec![
XAI_API_KEY_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -654,6 +769,7 @@ mod tests {
XAI_API_KEY_METHOD_ID,
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -695,6 +811,7 @@ mod tests {
vec![
CACHED_TOKEN_AUTH_METHOD_ID,
KIMI_CODE_METHOD_ID,
"xai-grok",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",
@@ -739,6 +856,7 @@ mod tests {
method_ids(&built),
vec![
KIMI_CODE_METHOD_ID,
"xai-grok",
MOONSHOT_CN_METHOD_ID,
MOONSHOT_AI_METHOD_ID,
"openai",