Skip to content

Commit 6309838

Browse files
committed
feat(auth): expose subscription login methods and discover xAI endpoints
1 parent ded8183 commit 6309838

5 files changed

Lines changed: 200 additions & 21 deletions

File tree

‎src/crates/adapters/ai-adapters/AGENTS.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ For the auth/discovery path, use `cargo test -p openbitfun-ai-adapters --feature
8787
subscription-auth --lib`. Device-grant timing tests use the dev-only Tokio
8888
test clock and synthetic tokens; they do not authorize real accounts.
8989

90+
xAI OAuth resolves device and token endpoints from its OpenID discovery document
91+
at the start of each login or refresh. Keep the issuer and endpoint origin pinned
92+
to `https://auth.x.ai`; discovery failure must remain explicit. A pending device
93+
grant retains its discovered token endpoint throughout polling. Discovery uses
94+
the host's subscription HTTP proxy options and does not change inference routes
95+
or persisted credential shapes.
96+
9097
```bash
9198
cargo test -p openbitfun-agent-stream
9299
cargo test -p openbitfun-ai-adapters

‎src/crates/adapters/ai-adapters/src/subscription_auth/grok.rs‎

Lines changed: 126 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ use std::time::Duration;
1515
use tokio_util::sync::CancellationToken;
1616

1717
const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
18-
const DEVICE_AUTHORIZATION_URL: &str = "https://auth.x.ai/oauth2/device/code";
19-
const TOKEN_URL: &str = "https://auth.x.ai/oauth2/token";
18+
const ISSUER: &str = "https://auth.x.ai";
19+
const DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration";
2020
const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
2121
const SCOPE: &str = "openid profile email offline_access grok-cli:access api:access";
2222
const XAI_BASE_URL: &str = "https://api.x.ai/v1";
@@ -30,6 +30,63 @@ const SHORT_TOKEN_REFRESH_LEEWAY_MS: i64 = 2 * 60 * 1000;
3030
const LONG_TOKEN_REFRESH_LEEWAY_MS: i64 = 60 * 60 * 1000;
3131
const SHORT_TOKEN_THRESHOLD_MS: i64 = 45 * 60 * 1000;
3232

33+
#[derive(Debug, Deserialize)]
34+
struct DiscoveryDocument {
35+
issuer: String,
36+
device_authorization_endpoint: String,
37+
token_endpoint: String,
38+
}
39+
40+
struct OAuthEndpoints {
41+
device_authorization: reqwest::Url,
42+
token: reqwest::Url,
43+
}
44+
45+
fn trusted_auth_endpoint(value: &str) -> Result<reqwest::Url> {
46+
let url = reqwest::Url::parse(value).context("parse xAI authentication endpoint")?;
47+
if value.chars().any(|character| character.is_ascii_control())
48+
|| url.scheme() != "https"
49+
|| url.host_str() != Some("auth.x.ai")
50+
|| url.port_or_known_default() != Some(443)
51+
|| !url.username().is_empty()
52+
|| url.password().is_some()
53+
|| url.fragment().is_some()
54+
{
55+
return Err(anyhow!(
56+
"xAI discovery returned an untrusted authentication endpoint"
57+
));
58+
}
59+
Ok(url)
60+
}
61+
62+
fn discovery_endpoints(document: DiscoveryDocument) -> Result<OAuthEndpoints> {
63+
if document.issuer.trim_end_matches('/') != ISSUER {
64+
return Err(anyhow!(
65+
"xAI discovery issuer does not match the expected issuer"
66+
));
67+
}
68+
Ok(OAuthEndpoints {
69+
device_authorization: trusted_auth_endpoint(&document.device_authorization_endpoint)?,
70+
token: trusted_auth_endpoint(&document.token_endpoint)?,
71+
})
72+
}
73+
74+
async fn discover_endpoints(options: &SubscriptionHttpOptions) -> Result<OAuthEndpoints> {
75+
let response = oauth_request(http_client(options)?.get(DISCOVERY_URL))
76+
.send()
77+
.await
78+
.context("fetch xAI OpenID discovery document")?;
79+
if !response.status().is_success() {
80+
return Err(anyhow!("xAI discovery failed: HTTP {}", response.status()));
81+
}
82+
discovery_endpoints(
83+
response
84+
.json()
85+
.await
86+
.context("parse xAI discovery document")?,
87+
)
88+
}
89+
3390
#[derive(Debug, Deserialize)]
3491
struct DeviceCodeResponse {
3592
device_code: String,
@@ -141,9 +198,12 @@ fn validate_verification_url(url: &str) -> Result<()> {
141198
Ok(())
142199
}
143200

144-
async fn request_device_code(options: &SubscriptionHttpOptions) -> Result<DeviceCodeResponse> {
201+
async fn request_device_code(
202+
options: &SubscriptionHttpOptions,
203+
endpoints: &OAuthEndpoints,
204+
) -> Result<DeviceCodeResponse> {
145205
let client = http_client(options)?;
146-
let response = oauth_request(client.post(DEVICE_AUTHORIZATION_URL))
206+
let response = oauth_request(client.post(endpoints.device_authorization.clone()))
147207
.form(&[
148208
("client_id", CLIENT_ID),
149209
("scope", SCOPE),
@@ -210,9 +270,10 @@ fn classify_device_poll_error(
210270
async fn poll_once(
211271
device_code: &str,
212272
options: &SubscriptionHttpOptions,
273+
endpoints: &OAuthEndpoints,
213274
) -> Result<DevicePoll<TokenResponse>> {
214275
let client = http_client(options)?;
215-
let response = oauth_request(client.post(TOKEN_URL))
276+
let response = oauth_request(client.post(endpoints.token.clone()))
216277
.form(&[
217278
("grant_type", DEVICE_CODE_GRANT_TYPE),
218279
("client_id", CLIENT_ID),
@@ -295,8 +356,9 @@ async fn persist_tokens(tokens: TokenResponse, expected_revision: u64) -> Result
295356
}
296357

297358
async fn refresh(refresh_token: &str, options: &SubscriptionHttpOptions) -> Result<TokenResponse> {
359+
let endpoints = discover_endpoints(options).await?;
298360
let client = http_client(options)?;
299-
let response = oauth_request(client.post(TOKEN_URL))
361+
let response = oauth_request(client.post(endpoints.token))
300362
.form(&[
301363
("grant_type", "refresh_token"),
302364
("refresh_token", refresh_token),
@@ -323,7 +385,8 @@ pub(crate) async fn begin_login(
323385
expected_revision: u64,
324386
options: SubscriptionHttpOptions,
325387
) -> Result<StartedLogin> {
326-
let device = request_device_code(&options).await?;
388+
let endpoints = discover_endpoints(&options).await?;
389+
let device = request_device_code(&options, &endpoints).await?;
327390
let interval = positive_seconds(device.interval, DEFAULT_POLL_INTERVAL_SECS);
328391
let expires_in = positive_seconds(device.expires_in, DEFAULT_DEVICE_LIFETIME_SECS)
329392
.min(super::LOGIN_TIMEOUT.as_secs() as i64);
@@ -344,7 +407,7 @@ pub(crate) async fn begin_login(
344407
Duration::from_secs(expires_in as u64),
345408
Duration::from_secs(3),
346409
true,
347-
|| poll_once(&device_code, &options),
410+
|| poll_once(&device_code, &options, &endpoints),
348411
)
349412
.await
350413
.context("complete xAI device authorization")
@@ -485,6 +548,61 @@ mod tests {
485548
XAI_BASE_URL, XAI_REQUEST_URL,
486549
};
487550

551+
#[test]
552+
fn discovery_accepts_changed_paths_and_ignores_unrelated_metadata() {
553+
let document = serde_json::from_value(serde_json::json!({
554+
"issuer": "https://auth.x.ai/",
555+
"device_authorization_endpoint": "https://auth.x.ai/new/device",
556+
"token_endpoint": "https://auth.x.ai/new/token",
557+
"authorization_endpoint": "https://auth.x.ai/authorize"
558+
}))
559+
.unwrap();
560+
let endpoints = super::discovery_endpoints(document).unwrap();
561+
assert_eq!(
562+
endpoints.device_authorization.as_str(),
563+
"https://auth.x.ai/new/device"
564+
);
565+
assert_eq!(endpoints.token.as_str(), "https://auth.x.ai/new/token");
566+
}
567+
568+
#[test]
569+
fn discovery_rejects_wrong_issuer_missing_fields_and_untrusted_endpoints() {
570+
for invalid in [
571+
"http://auth.x.ai/token",
572+
"https://auth.x.ai:8443/token",
573+
"https://user@auth.x.ai/token",
574+
"https://auth.x.ai.evil.test/token",
575+
"https://attacker.example/token",
576+
"https://auth.x.ai/token#fragment",
577+
"https://auth.x.ai/\ntoken",
578+
] {
579+
for field in ["token_endpoint", "device_authorization_endpoint"] {
580+
let mut value = serde_json::json!({
581+
"issuer": "https://auth.x.ai",
582+
"device_authorization_endpoint": "https://auth.x.ai/device",
583+
"token_endpoint": "https://auth.x.ai/token"
584+
});
585+
value[field] = serde_json::json!(invalid);
586+
assert!(
587+
super::discovery_endpoints(serde_json::from_value(value).unwrap()).is_err()
588+
);
589+
}
590+
}
591+
let document = serde_json::from_value(serde_json::json!({
592+
"issuer": "https://attacker.example",
593+
"device_authorization_endpoint": "https://auth.x.ai/device",
594+
"token_endpoint": "https://auth.x.ai/token"
595+
}))
596+
.unwrap();
597+
assert!(super::discovery_endpoints(document).is_err());
598+
assert!(
599+
serde_json::from_value::<super::DiscoveryDocument>(serde_json::json!({
600+
"issuer": "https://auth.x.ai", "token_endpoint": "https://auth.x.ai/token"
601+
}))
602+
.is_err()
603+
);
604+
}
605+
488606
#[test]
489607
fn accepts_https_verification_urls_and_rejects_unsafe_urls() {
490608
validate_verification_url("https://accounts.x.ai/oauth2/device?user_code=ABCD-EFGH")

‎src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac
9696
import { LONG_CONTEXT_WARNING_THRESHOLD_TOKENS } from '@/shared/constants/modelContext';
9797
import {
9898
preferredSubscriptionLoginMethod,
99+
subscriptionLoginMethodsForSurface,
99100
settleSubscriptionLoginStart,
100101
subscriptionLoginRequiresLocalDevice,
101102
SubscriptionLoginCoordinator,
@@ -1185,7 +1186,10 @@ const ModelSettingsPage: React.FC = () => {
11851186
};
11861187
}, []);
11871188

1188-
const handleSubscriptionLogin = useCallback(async (provider: SubscriptionProvider) => {
1189+
const handleSubscriptionLogin = useCallback(async (
1190+
provider: SubscriptionProvider,
1191+
method?: SubscriptionLoginMethod,
1192+
) => {
11891193
if ((!isTauriRuntime() || isPeerDeviceModeActive()) && subscriptionLoginRequiresLocalDevice(provider)) {
11901194
notification.error(t('subscriptionAuth.peerLoginRequiresLocalDevice'));
11911195
return;
@@ -1195,7 +1199,7 @@ const ModelSettingsPage: React.FC = () => {
11951199
// newer provider's state or leaving an undiscoverable backend session.
11961200
const operation = loginCoordinatorRef.current.begin(provider);
11971201
if (!operation) return;
1198-
const requestedMethod = preferredSubscriptionLoginMethod(
1202+
const requestedMethod = method ?? preferredSubscriptionLoginMethod(
11991203
provider,
12001204
isTauriRuntime() && !isPeerDeviceModeActive(),
12011205
);
@@ -3629,6 +3633,12 @@ const ModelSettingsPage: React.FC = () => {
36293633
const isRefreshing = refreshingSubscriptionProviders.has(account.provider);
36303634
const isLoggingIn = loggingInProvider === account.provider;
36313635
const anyLoginInProgress = loggingInProvider !== null;
3636+
const loginMethods = subscriptionLoginMethodsForSurface(
3637+
account.provider,
3638+
account.login_methods,
3639+
isTauriRuntime() && !isPeerDeviceModeActive(),
3640+
);
3641+
const loginChoices = loginMethods.length ? loginMethods : [undefined];
36323642
const loginPanel = subscriptionLoginPanel?.provider === account.provider
36333643
? subscriptionLoginPanel
36343644
: null;
@@ -3693,17 +3703,26 @@ const ModelSettingsPage: React.FC = () => {
36933703
{t('subscriptionAuth.retryVault')}
36943704
</Button>
36953705
) : (
3696-
<Button
3697-
size="sm"
3698-
variant="primary"
3699-
loading={isLoggingIn}
3700-
disabled={anyLoginInProgress}
3701-
onClick={() => void handleSubscriptionLogin(account.provider)}
3702-
>
3703-
{t(loginPanel?.status === 'failed'
3704-
? 'subscriptionAuth.retryLogin'
3705-
: 'subscriptionAuth.login')}
3706-
</Button>
3706+
<>
3707+
{loginChoices.map((method, index) => (
3708+
<Button
3709+
key={method ?? 'default'}
3710+
size="sm"
3711+
variant={index === 0 ? 'primary' : 'outline'}
3712+
loading={isLoggingIn && subscriptionLoginPanel?.method === method}
3713+
disabled={anyLoginInProgress}
3714+
onClick={() => void handleSubscriptionLogin(account.provider, method)}
3715+
>
3716+
{t(method === 'device'
3717+
? 'subscriptionAuth.deviceLogin'
3718+
: method === 'browser'
3719+
? 'subscriptionAuth.browserLogin'
3720+
: loginPanel?.status === 'failed'
3721+
? 'subscriptionAuth.retryLogin'
3722+
: 'subscriptionAuth.login')}
3723+
</Button>
3724+
))}
3725+
</>
37073726
)}
37083727
{isLoggingIn && (
37093728
<Button

‎src/web-ui/src/infrastructure/config/components/subscriptionLoginCoordinator.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,35 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
preferredSubscriptionLoginMethod,
4+
subscriptionLoginMethodsForSurface,
45
settleSubscriptionLoginStart,
56
subscriptionLoginRequiresLocalDevice,
67
SubscriptionLoginCoordinator,
78
} from './subscriptionLoginCoordinator';
89

10+
describe('subscriptionLoginMethodsForSurface', () => {
11+
it('exposes both advertised Codex methods locally and device login through a peer', () => {
12+
expect(subscriptionLoginMethodsForSurface('codex', ['browser', 'device'], true))
13+
.toEqual(['browser', 'device']);
14+
expect(subscriptionLoginMethodsForSurface('codex', ['browser', 'device'], false))
15+
.toEqual(['device']);
16+
});
17+
18+
it('preserves the device-only providers and browser-only Antigravity contract', () => {
19+
for (const provider of ['opencode', 'grok', 'hermes'] as const) {
20+
expect(subscriptionLoginMethodsForSurface(provider, ['device'], true)).toEqual(['device']);
21+
expect(subscriptionLoginMethodsForSurface(provider, ['device'], false)).toEqual(['device']);
22+
}
23+
expect(subscriptionLoginMethodsForSurface('antigravity', ['browser'], true)).toEqual(['browser']);
24+
});
25+
26+
it('keeps legacy backends on default login without inventing unadvertised capabilities', () => {
27+
expect(subscriptionLoginMethodsForSurface('codex', undefined, true)).toEqual([]);
28+
expect(subscriptionLoginMethodsForSurface('codex', [], false)).toEqual([]);
29+
expect(subscriptionLoginMethodsForSurface('codex', ['browser'], true)).toEqual(['browser']);
30+
});
31+
});
32+
933
describe('preferredSubscriptionLoginMethod', () => {
1034
it('uses Codex device authorization when the callback browser is not local', () => {
1135
expect(preferredSubscriptionLoginMethod('codex', false)).toBe('device');

‎src/web-ui/src/infrastructure/config/components/subscriptionLoginCoordinator.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
import type { SubscriptionProvider } from '../types';
2+
import type { SubscriptionLoginMethod } from '@/infrastructure/api/service-api/AIApi';
3+
4+
export function subscriptionLoginMethodsForSurface(
5+
provider: SubscriptionProvider,
6+
methods: readonly SubscriptionLoginMethod[] | undefined,
7+
localBrowserCallbackReachable: boolean,
8+
): SubscriptionLoginMethod[] {
9+
return (methods ?? []).filter((method) => (
10+
provider !== 'codex' || method !== 'browser' || localBrowserCallbackReachable
11+
));
12+
}
213

314
/**
415
* Keeps the settings UI to one sign-in action while selecting the Codex flow

0 commit comments

Comments
 (0)