From 6309838fdfd4883d8a101944b65eee1939cb0351 Mon Sep 17 00:00:00 2001 From: uiharuayako <2451602772@qq.com> Date: Sat, 26 Sep 2026 13:18:01 +0800 Subject: [PATCH] feat(auth): expose subscription login methods and discover xAI endpoints --- src/crates/adapters/ai-adapters/AGENTS.md | 7 + .../ai-adapters/src/subscription_auth/grok.rs | 134 ++++++++++++++++-- .../config/components/ModelSettingsPage.tsx | 45 ++++-- .../subscriptionLoginCoordinator.test.ts | 24 ++++ .../subscriptionLoginCoordinator.ts | 11 ++ 5 files changed, 200 insertions(+), 21 deletions(-) diff --git a/src/crates/adapters/ai-adapters/AGENTS.md b/src/crates/adapters/ai-adapters/AGENTS.md index f39cf65f49..77d9722391 100644 --- a/src/crates/adapters/ai-adapters/AGENTS.md +++ b/src/crates/adapters/ai-adapters/AGENTS.md @@ -87,6 +87,13 @@ For the auth/discovery path, use `cargo test -p openbitfun-ai-adapters --feature subscription-auth --lib`. Device-grant timing tests use the dev-only Tokio test clock and synthetic tokens; they do not authorize real accounts. +xAI OAuth resolves device and token endpoints from its OpenID discovery document +at the start of each login or refresh. Keep the issuer and endpoint origin pinned +to `https://auth.x.ai`; discovery failure must remain explicit. A pending device +grant retains its discovered token endpoint throughout polling. Discovery uses +the host's subscription HTTP proxy options and does not change inference routes +or persisted credential shapes. + ```bash cargo test -p openbitfun-agent-stream cargo test -p openbitfun-ai-adapters diff --git a/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs b/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs index 66d0f64cdc..6a980ca16a 100644 --- a/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs +++ b/src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs @@ -15,8 +15,8 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; -const DEVICE_AUTHORIZATION_URL: &str = "https://auth.x.ai/oauth2/device/code"; -const TOKEN_URL: &str = "https://auth.x.ai/oauth2/token"; +const ISSUER: &str = "https://auth.x.ai"; +const DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; const SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; const XAI_BASE_URL: &str = "https://api.x.ai/v1"; @@ -30,6 +30,63 @@ const SHORT_TOKEN_REFRESH_LEEWAY_MS: i64 = 2 * 60 * 1000; const LONG_TOKEN_REFRESH_LEEWAY_MS: i64 = 60 * 60 * 1000; const SHORT_TOKEN_THRESHOLD_MS: i64 = 45 * 60 * 1000; +#[derive(Debug, Deserialize)] +struct DiscoveryDocument { + issuer: String, + device_authorization_endpoint: String, + token_endpoint: String, +} + +struct OAuthEndpoints { + device_authorization: reqwest::Url, + token: reqwest::Url, +} + +fn trusted_auth_endpoint(value: &str) -> Result { + let url = reqwest::Url::parse(value).context("parse xAI authentication endpoint")?; + if value.chars().any(|character| character.is_ascii_control()) + || url.scheme() != "https" + || url.host_str() != Some("auth.x.ai") + || url.port_or_known_default() != Some(443) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(anyhow!( + "xAI discovery returned an untrusted authentication endpoint" + )); + } + Ok(url) +} + +fn discovery_endpoints(document: DiscoveryDocument) -> Result { + if document.issuer.trim_end_matches('/') != ISSUER { + return Err(anyhow!( + "xAI discovery issuer does not match the expected issuer" + )); + } + Ok(OAuthEndpoints { + device_authorization: trusted_auth_endpoint(&document.device_authorization_endpoint)?, + token: trusted_auth_endpoint(&document.token_endpoint)?, + }) +} + +async fn discover_endpoints(options: &SubscriptionHttpOptions) -> Result { + let response = oauth_request(http_client(options)?.get(DISCOVERY_URL)) + .send() + .await + .context("fetch xAI OpenID discovery document")?; + if !response.status().is_success() { + return Err(anyhow!("xAI discovery failed: HTTP {}", response.status())); + } + discovery_endpoints( + response + .json() + .await + .context("parse xAI discovery document")?, + ) +} + #[derive(Debug, Deserialize)] struct DeviceCodeResponse { device_code: String, @@ -141,9 +198,12 @@ fn validate_verification_url(url: &str) -> Result<()> { Ok(()) } -async fn request_device_code(options: &SubscriptionHttpOptions) -> Result { +async fn request_device_code( + options: &SubscriptionHttpOptions, + endpoints: &OAuthEndpoints, +) -> Result { let client = http_client(options)?; - let response = oauth_request(client.post(DEVICE_AUTHORIZATION_URL)) + let response = oauth_request(client.post(endpoints.device_authorization.clone())) .form(&[ ("client_id", CLIENT_ID), ("scope", SCOPE), @@ -210,9 +270,10 @@ fn classify_device_poll_error( async fn poll_once( device_code: &str, options: &SubscriptionHttpOptions, + endpoints: &OAuthEndpoints, ) -> Result> { let client = http_client(options)?; - let response = oauth_request(client.post(TOKEN_URL)) + let response = oauth_request(client.post(endpoints.token.clone())) .form(&[ ("grant_type", DEVICE_CODE_GRANT_TYPE), ("client_id", CLIENT_ID), @@ -295,8 +356,9 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result } async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result { + let endpoints = discover_endpoints(options).await?; let client = http_client(options)?; - let response = oauth_request(client.post(TOKEN_URL)) + let response = oauth_request(client.post(endpoints.token)) .form(&[ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), @@ -323,7 +385,8 @@ pub(crate) async fn begin_login( expected_revision: u64, options: SubscriptionHttpOptions, ) -> Result { - let device = request_device_code(&options).await?; + let endpoints = discover_endpoints(&options).await?; + let device = request_device_code(&options, &endpoints).await?; let interval = positive_seconds(device.interval, DEFAULT_POLL_INTERVAL_SECS); let expires_in = positive_seconds(device.expires_in, DEFAULT_DEVICE_LIFETIME_SECS) .min(super::LOGIN_TIMEOUT.as_secs() as i64); @@ -344,7 +407,7 @@ pub(crate) async fn begin_login( Duration::from_secs(expires_in as u64), Duration::from_secs(3), true, - || poll_once(&device_code, &options), + || poll_once(&device_code, &options, &endpoints), ) .await .context("complete xAI device authorization") @@ -485,6 +548,61 @@ mod tests { XAI_BASE_URL, XAI_REQUEST_URL, }; + #[test] + fn discovery_accepts_changed_paths_and_ignores_unrelated_metadata() { + let document = serde_json::from_value(serde_json::json!({ + "issuer": "https://auth.x.ai/", + "device_authorization_endpoint": "https://auth.x.ai/new/device", + "token_endpoint": "https://auth.x.ai/new/token", + "authorization_endpoint": "https://auth.x.ai/authorize" + })) + .unwrap(); + let endpoints = super::discovery_endpoints(document).unwrap(); + assert_eq!( + endpoints.device_authorization.as_str(), + "https://auth.x.ai/new/device" + ); + assert_eq!(endpoints.token.as_str(), "https://auth.x.ai/new/token"); + } + + #[test] + fn discovery_rejects_wrong_issuer_missing_fields_and_untrusted_endpoints() { + for invalid in [ + "http://auth.x.ai/token", + "https://auth.x.ai:8443/token", + "https://user@auth.x.ai/token", + "https://auth.x.ai.evil.test/token", + "https://attacker.example/token", + "https://auth.x.ai/token#fragment", + "https://auth.x.ai/\ntoken", + ] { + for field in ["token_endpoint", "device_authorization_endpoint"] { + let mut value = serde_json::json!({ + "issuer": "https://auth.x.ai", + "device_authorization_endpoint": "https://auth.x.ai/device", + "token_endpoint": "https://auth.x.ai/token" + }); + value[field] = serde_json::json!(invalid); + assert!( + super::discovery_endpoints(serde_json::from_value(value).unwrap()).is_err() + ); + } + } + let document = serde_json::from_value(serde_json::json!({ + "issuer": "https://attacker.example", + "device_authorization_endpoint": "https://auth.x.ai/device", + "token_endpoint": "https://auth.x.ai/token" + })) + .unwrap(); + assert!(super::discovery_endpoints(document).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "issuer": "https://auth.x.ai", "token_endpoint": "https://auth.x.ai/token" + })) + .is_err() + ); + } + #[test] fn accepts_https_verification_urls_and_rejects_unsafe_urls() { validate_verification_url("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH") diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx index 763d006e42..451d095bb6 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx @@ -96,6 +96,7 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac import { LONG_CONTEXT_WARNING_THRESHOLD_TOKENS } from '@/shared/constants/modelContext'; import { preferredSubscriptionLoginMethod, + subscriptionLoginMethodsForSurface, settleSubscriptionLoginStart, subscriptionLoginRequiresLocalDevice, SubscriptionLoginCoordinator, @@ -1185,7 +1186,10 @@ const ModelSettingsPage: React.FC = () => { }; }, []); - const handleSubscriptionLogin = useCallback(async (provider: SubscriptionProvider) => { + const handleSubscriptionLogin = useCallback(async ( + provider: SubscriptionProvider, + method?: SubscriptionLoginMethod, + ) => { if ((!isTauriRuntime() || isPeerDeviceModeActive()) && subscriptionLoginRequiresLocalDevice(provider)) { notification.error(t('subscriptionAuth.peerLoginRequiresLocalDevice')); return; @@ -1195,7 +1199,7 @@ const ModelSettingsPage: React.FC = () => { // newer provider's state or leaving an undiscoverable backend session. const operation = loginCoordinatorRef.current.begin(provider); if (!operation) return; - const requestedMethod = preferredSubscriptionLoginMethod( + const requestedMethod = method ?? preferredSubscriptionLoginMethod( provider, isTauriRuntime() && !isPeerDeviceModeActive(), ); @@ -3629,6 +3633,12 @@ const ModelSettingsPage: React.FC = () => { const isRefreshing = refreshingSubscriptionProviders.has(account.provider); const isLoggingIn = loggingInProvider === account.provider; const anyLoginInProgress = loggingInProvider !== null; + const loginMethods = subscriptionLoginMethodsForSurface( + account.provider, + account.login_methods, + isTauriRuntime() && !isPeerDeviceModeActive(), + ); + const loginChoices = loginMethods.length ? loginMethods : [undefined]; const loginPanel = subscriptionLoginPanel?.provider === account.provider ? subscriptionLoginPanel : null; @@ -3693,17 +3703,26 @@ const ModelSettingsPage: React.FC = () => { {t('subscriptionAuth.retryVault')} ) : ( - + <> + {loginChoices.map((method, index) => ( + + ))} + )} {isLoggingIn && (