返回 CodeWhale
xai_oauth.rs
根目录 / crates / tui / src / xai_oauth.rs
1 //! xAI / Grok OAuth credential loading, refresh, and device-code login.
2 //!
3 //! Two paths, matching [#4257](https://github.com/Hmbown/CodeWhale/issues/4257):
4 //!
5 //! 1. **Read-only external login** — reuse one exact official Grok CLI token
6 //! file only after provider-scoped consent. External tokens are never
7 //! refreshed or rewritten.
8 //! 2. **Native device-code** — request a code from `auth.x.ai`, print the
9 //! verification URL + user code, poll the token endpoint, and write tokens
10 //! to Codewhale-owned storage.
11 //!
12 //! Access tokens are sent as `Authorization: Bearer` on the OpenAI-compatible
13 //! xAI Chat Completions route (`https://api.x.ai/v1`). Token values are never
14 //! logged.
15
16 use std::collections::BTreeMap;
17 #[cfg(test)]
18 use std::fs;
19 use std::io::Read as _;
20 use std::path::{Path, PathBuf};
21 use std::thread;
22 use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24 use anyhow::{Context, Result, bail};
25 use serde::de::DeserializeOwned;
26 use serde::{Deserialize, Serialize};
27 use serde_json::Value;
28
29 use crate::config::{ApiProvider, Config};
30
31 /// Official Grok CLI public OIDC client id (public client; no secret).
32 pub const GROK_OIDC_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
33 /// Default issuer / authorization server.
34 pub const XAI_OIDC_ISSUER: &str = "https://auth.x.ai";
35 /// User-principal scopes requested by device-code login.
36 ///
37 /// Although xAI advertises `team:read` in discovery metadata, its device-code
38 /// endpoint rejects that scope for User principals. Keep team enrichment out of
39 /// the user-principal login request.
40 pub const DEFAULT_SCOPES: &str = "openid profile email offline_access api:access grok-cli:access";
41 const REFRESH_SKEW_SECS: i64 = 60;
42 const DEVICE_POLL_DEFAULT_SECS: u64 = 5;
43 const DEVICE_POLL_MAX_SECS: u64 = 900;
44 /// RFC 8628 §3.5: on `slow_down` the polling interval increases by 5 seconds.
45 const DEVICE_SLOW_DOWN_STEP_SECS: u64 = 5;
46 const OAUTH_RESPONSE_BODY_LIMIT: u64 = 64 * 1024;
47 const OAUTH_ERROR_DETAIL_LIMIT: usize = 256;
48
49 /// One entry in `~/.grok/auth.json` (map key = `{issuer}::{client_id}`).
50 #[derive(Debug, Clone, Serialize, Deserialize)]
51 pub struct GrokAuthEntry {
52 /// Access token (JWT). Field name matches the Grok CLI (`key`).
53 #[serde(default)]
54 pub key: Option<String>,
55 #[serde(default)]
56 pub refresh_token: Option<String>,
57 /// RFC3339 expiry timestamp written by the Grok CLI.
58 #[serde(default)]
59 pub expires_at: Option<String>,
60 #[serde(default)]
61 pub oidc_issuer: Option<String>,
62 #[serde(default)]
63 pub oidc_client_id: Option<String>,
64 #[serde(default)]
65 pub auth_mode: Option<String>,
66 /// Preserve unknown CLI fields on rewrite.
67 #[serde(flatten)]
68 pub extra: BTreeMap<String, Value>,
69 }
70
71 /// Token endpoint response (device-code exchange or refresh).
72 #[derive(Debug, Clone, Deserialize)]
73 struct TokenResponse {
74 access_token: Option<String>,
75 refresh_token: Option<String>,
76 expires_in: Option<u64>,
77 error: Option<String>,
78 }
79
80 #[derive(Debug, Clone, Deserialize)]
81 struct DeviceCodeResponse {
82 device_code: Option<String>,
83 user_code: Option<String>,
84 verification_uri: Option<String>,
85 verification_uri_complete: Option<String>,
86 expires_in: Option<u64>,
87 interval: Option<u64>,
88 error: Option<String>,
89 error_description: Option<String>,
90 }
91
92 #[derive(Debug, Clone)]
93 struct DeviceCodeGrant {
94 device_code: String,
95 user_code: String,
96 verification_uri: Option<String>,
97 verification_uri_complete: Option<String>,
98 expires_in: Option<u64>,
99 interval: Option<u64>,
100 }
101
102 #[derive(Debug, Clone, Deserialize)]
103 struct OidcDiscoveryResponse {
104 issuer: Option<String>,
105 device_authorization_endpoint: Option<String>,
106 token_endpoint: Option<String>,
107 }
108
109 #[derive(Debug, Clone, PartialEq, Eq)]
110 struct DeviceOauthEndpoints {
111 device_authorization_endpoint: String,
112 token_endpoint: String,
113 }
114
115 /// Resolved bearer credential ready for API use.
116 #[derive(Debug, Clone)]
117 pub struct XaiOAuthCredentials {
118 pub access_token: String,
119 #[allow(dead_code)]
120 pub refresh_token: Option<String>,
121 #[allow(dead_code)]
122 pub expires_at: Option<String>,
123 #[allow(dead_code)]
124 pub issuer: String,
125 #[allow(dead_code)]
126 pub client_id: String,
127 }
128
129 /// A successful device-code exchange that has not yet been made active.
130 ///
131 /// Keeping the bearer material in memory until activation lets the config
132 /// pointer and a uniquely named owned credential generation commit as one
133 /// logical operation. A cancelled or failed finalization never leaves a
134 /// canonical token file that becomes active on a later launch.
135 #[derive(Debug)]
136 pub struct PendingXaiDeviceLogin {
137 issuer: String,
138 client_id: String,
139 token: TokenResponse,
140 }
141
142 #[cfg(test)]
143 pub(crate) fn pending_device_login_for_test(
144 access_token: &str,
145 refresh_token: &str,
146 ) -> PendingXaiDeviceLogin {
147 PendingXaiDeviceLogin {
148 issuer: XAI_OIDC_ISSUER.to_string(),
149 client_id: GROK_OIDC_CLIENT_ID.to_string(),
150 token: TokenResponse {
151 access_token: Some(access_token.to_string()),
152 refresh_token: Some(refresh_token.to_string()),
153 expires_in: Some(3600),
154 error: None,
155 },
156 }
157 }
158
159 /// Receipt for the committed Codewhale-owned xAI OAuth generation.
160 #[derive(Debug)]
161 pub struct XaiDeviceActivation {
162 #[allow(dead_code)]
163 pub credentials: XaiOAuthCredentials,
164 pub config_path: PathBuf,
165 pub auth_path: PathBuf,
166 }
167
168 /// Whether `[providers.xai] auth_mode` selects the OAuth path.
169 #[must_use]
170 pub fn auth_mode_uses_xai_oauth(mode: &str) -> bool {
171 matches!(
172 normalize_auth_mode(mode).as_str(),
173 "oauth"
174 | "xai_oauth"
175 | "xai"
176 | "grok"
177 | "grok_oauth"
178 | "grok_cli"
179 | "device"
180 | "device_code"
181 | "device_auth"
182 )
183 }
184
185 fn normalize_auth_mode(mode: &str) -> String {
186 mode.trim().to_ascii_lowercase().replace(['-', ' '], "_")
187 }
188
189 /// Resolve the Grok CLI auth file path.
190 ///
191 /// Priority:
192 /// 1. `GROK_AUTH_PATH` / `XAI_AUTH_PATH`
193 /// 2. `$GROK_HOME/auth.json`
194 /// 3. `~/.grok/auth.json`
195 #[must_use]
196 pub fn auth_file_path() -> PathBuf {
197 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
198 if let Ok(path) = std::env::var(key) {
199 let p = PathBuf::from(path.trim());
200 if !p.as_os_str().is_empty() {
201 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
202 }
203 }
204 }
205 if let Ok(home) = std::env::var("GROK_HOME") {
206 let p = PathBuf::from(home.trim());
207 if !p.as_os_str().is_empty() {
208 let path = p.join("auth.json");
209 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
210 }
211 }
212 let path = crate::config::effective_home_dir()
213 .unwrap_or_else(|| PathBuf::from("."))
214 .join(".grok")
215 .join("auth.json");
216 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
217 }
218
219 /// Codewhale-owned xAI token file. Native login and refresh never target the
220 /// Grok CLI's file.
221 pub fn codewhale_auth_file_path() -> Result<PathBuf> {
222 codewhale_config::legacy_xai_oauth_path()
223 }
224
225 fn configured_owned_auth_file_path(config: &Config) -> Result<Option<PathBuf>> {
226 let generation = config
227 .provider_config_for(ApiProvider::Xai)
228 .and_then(|entry| entry.oauth_credential_generation.as_deref());
229 match generation {
230 Some(generation) => codewhale_config::xai_oauth_generation_path(generation).map(Some),
231 None => Ok(None),
232 }
233 }
234
235 /// Detect the [#5032] bricked-launch state: `[providers.xai]` selects OAuth and
236 /// points `oauth_credential_generation` at a Codewhale-owned credential file
237 /// that no longer exists. This is a distinct, more specific failure than
238 /// "unconfigured" — the pointer is present and authoritative, so
239 /// [`credentials_valid`] returns false and cannot fall through to a legacy or
240 /// external credential, which is exactly what bricked the dogfood machine.
241 ///
242 /// Returns false for any other state: OAuth not selected, no generation
243 /// configured, a malformed generation pointer (a different, already-fail-closed
244 /// failure), or a generation whose owned file is present.
245 ///
246 /// [#5032]: https://github.com/Hmbown/CodeWhale/issues/5032
247 #[must_use]
248 pub fn owned_generation_is_dangling(config: &Config) -> bool {
249 if !config
250 .provider_config_for(ApiProvider::Xai)
251 .and_then(|entry| entry.auth_mode.as_deref())
252 .is_some_and(auth_mode_uses_xai_oauth)
253 {
254 return false;
255 }
256 match configured_owned_auth_file_path(config) {
257 Ok(Some(path)) => !path.exists(),
258 // `None` => no generation configured (not a dangling pointer). `Err` =>
259 // the generation is malformed/invalid; that is a different, already
260 // fail-closed failure, not the missing-file state this detects.
261 _ => false,
262 }
263 }
264
265 #[must_use]
266 pub fn credentials_present(config: &Config) -> bool {
267 credentials_valid(config)
268 }
269
270 /// Prompt-free structural check for xAI OAuth material. Never refreshes,
271 /// writes, or makes network requests. External storage is not inspected until
272 /// exact read-only consent has been validated.
273 #[must_use]
274 pub fn credentials_valid(config: &Config) -> bool {
275 // Codewhale-owned OAuth bytes are inert until the xAI provider explicitly
276 // selects OAuth. A failed post-login config finalization can therefore
277 // never make a newly written token silently ready on the next launch.
278 if !config
279 .provider_config_for(ApiProvider::Xai)
280 .and_then(|entry| entry.auth_mode.as_deref())
281 .is_some_and(auth_mode_uses_xai_oauth)
282 {
283 return false;
284 }
285 if let Ok(Some(path)) = configured_owned_auth_file_path(config)
286 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
287 && let Some((_, entry)) = select_entry(&mut file)
288 && (entry_access_token_is_fresh(&entry)
289 || entry
290 .refresh_token
291 .as_deref()
292 .is_some_and(|token| !token.trim().is_empty()))
293 {
294 return true;
295 }
296 if config
297 .provider_config_for(ApiProvider::Xai)
298 .and_then(|entry| entry.oauth_credential_generation.as_deref())
299 .is_some()
300 {
301 // A configured generation is authoritative. Invalid, missing, unsafe,
302 // or malformed owned storage must not fall through to an external CLI.
303 return false;
304 }
305 if let Ok(path) = codewhale_auth_file_path()
306 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
307 && let Some((_, entry)) = select_entry(&mut file)
308 && (entry_access_token_is_fresh(&entry)
309 || entry
310 .refresh_token
311 .as_deref()
312 .is_some_and(|token| !token.trim().is_empty()))
313 {
314 return true;
315 }
316
317 let path = auth_file_path();
318 let Ok(grant) = config.external_credential_read_grant(
319 ApiProvider::Xai,
320 codewhale_config::ExternalCredentialSource::GrokCli,
321 &path,
322 ) else {
323 return false;
324 };
325 let Ok(mut file) = load_external_auth_file(&grant) else {
326 return false;
327 };
328 select_entry(&mut file).is_some_and(|(_, entry)| entry_access_token_is_fresh(&entry))
329 }
330
331 /// Load xAI OAuth credentials. Codewhale-owned credentials may refresh and
332 /// rewrite Codewhale-owned storage. External credentials are read-only.
333 pub fn get_access_token(config: &Config) -> Result<String> {
334 Ok(get_credentials(config)?.access_token)
335 }
336
337 pub fn get_credentials(config: &Config) -> Result<XaiOAuthCredentials> {
338 anyhow::ensure!(
339 config.api_provider() == ApiProvider::Xai
340 && config
341 .provider_config_for(ApiProvider::Xai)
342 .and_then(|entry| entry.auth_mode.as_deref())
343 .is_some_and(auth_mode_uses_xai_oauth),
344 "Codewhale-owned xAI OAuth credentials are inactive until the xAI route explicitly selects OAuth"
345 );
346 if let Some(owned_path) = configured_owned_auth_file_path(config)? {
347 return get_owned_credentials(&owned_path);
348 }
349 let owned_path = codewhale_auth_file_path()?;
350 if load_owned_auth_file(&owned_path)?.is_some() {
351 return get_owned_credentials(&owned_path);
352 }
353
354 let external_path = auth_file_path();
355 let grant = config.external_credential_read_grant(
356 ApiProvider::Xai,
357 codewhale_config::ExternalCredentialSource::GrokCli,
358 &external_path,
359 )?;
360 let mut file = load_external_auth_file(&grant)?;
361 let (scope, entry) = select_entry(&mut file).ok_or_else(|| {
362 anyhow::anyhow!(
363 "xAI OAuth credentials at {} have no usable entry. Run `grok login` again or use `codewhale auth xai-device` for Codewhale-owned storage.",
364 codewhale_config::quote_os_path(grant.path())
365 )
366 })?;
367 if !entry_access_token_is_fresh(&entry) {
368 bail!(
369 "xAI OAuth access token in {} is expired. Read-only consent never refreshes or rewrites another CLI's credentials. Run `grok login` again or use `codewhale auth xai-device`.",
370 codewhale_config::quote_os_path(grant.path())
371 );
372 }
373 let token = entry
374 .key
375 .clone()
376 .filter(|token| !token.trim().is_empty())
377 .context("xAI OAuth access token is empty")?;
378 Ok(credentials_from_entry(scope, &entry, token))
379 }
380
381 fn get_owned_credentials(path: &Path) -> Result<XaiOAuthCredentials> {
382 let directory = codewhale_config::xai_oauth_credentials_dir()?;
383 anyhow::ensure!(
384 path.parent() == Some(directory.as_path()),
385 "Codewhale-owned xAI OAuth path escaped the credentials directory"
386 );
387 let name = path
388 .file_name()
389 .and_then(|name| name.to_str())
390 .context("Codewhale-owned xAI OAuth path must have a UTF-8 basename")?;
391 anyhow::ensure!(
392 name == codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
393 || codewhale_config::is_valid_xai_oauth_generation(name),
394 "Codewhale-owned xAI OAuth path has an invalid basename"
395 );
396 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
397 get_owned_credentials_locked(store, name, refresh_access_token)
398 })
399 }
400
401 fn get_owned_credentials_locked<F>(
402 store: &codewhale_config::XaiOAuthCredentialStore,
403 name: &str,
404 refresh_access: F,
405 ) -> Result<XaiOAuthCredentials>
406 where
407 F: FnOnce(&str, &str, &str) -> Result<TokenResponse>,
408 {
409 let path = store.path_for(name)?;
410 let mut file = load_owned_auth_file_from_store(store, name)?.ok_or_else(|| {
411 anyhow::anyhow!(
412 "Codewhale-owned xAI OAuth credentials were not found at {}. Run `codewhale auth xai-device` again.",
413 codewhale_config::quote_os_path(&path)
414 )
415 })?;
416 let (scope, mut entry) = select_entry(&mut file).ok_or_else(|| {
417 anyhow::anyhow!(
418 "Codewhale-owned xAI OAuth credentials at {} have no usable entry. Run `codewhale auth xai-device` again.",
419 codewhale_config::quote_os_path(&path)
420 )
421 })?;
422
423 if entry_access_token_is_fresh(&entry) {
424 let token = entry
425 .key
426 .clone()
427 .filter(|t| !t.trim().is_empty())
428 .context("xAI OAuth access token is empty")?;
429 return Ok(credentials_from_entry(scope, &entry, token));
430 }
431
432 let refresh = entry
433 .refresh_token
434 .as_deref()
435 .filter(|t| !t.trim().is_empty())
436 .context(
437 "xAI OAuth access token expired and no refresh_token is stored. \
438 Run `grok login` or `codewhale auth xai-device` again.",
439 )?;
440 let issuer = entry
441 .oidc_issuer
442 .clone()
443 .filter(|s| !s.trim().is_empty())
444 .unwrap_or_else(|| issuer_from_scope(&scope));
445 let client_id = entry
446 .oidc_client_id
447 .clone()
448 .filter(|s| !s.trim().is_empty())
449 .unwrap_or_else(|| client_id_from_scope(&scope));
450
451 let refreshed = refresh_access(&issuer, &client_id, refresh)?;
452 apply_token_response(&mut entry, &issuer, &client_id, &refreshed)?;
453 file.insert(scope.clone(), entry.clone());
454 write_auth_file_to_store(store, name, &file, true)?;
455
456 let token = entry
457 .key
458 .clone()
459 .filter(|t| !t.trim().is_empty())
460 .context("xAI OAuth refresh returned an empty access token")?;
461 Ok(credentials_from_entry(scope, &entry, token))
462 }
463
464 /// Interactive device-code login. Prints verification URL + user code to
465 /// `stderr` and polls until approved. The returned bearer material remains
466 /// pending in memory until [`activate_device_login`] commits an owned
467 /// generation and its config pointer.
468 ///
469 /// Public residual entry point for CLI/TUI wiring (`codewhale auth` /
470 /// slash command). Call from a headless or TUI surface that can print the
471 /// verification URL.
472 pub async fn device_code_login() -> Result<PendingXaiDeviceLogin> {
473 let issuer = std::env::var("GROK_OIDC_ISSUER")
474 .or_else(|_| std::env::var("XAI_OIDC_ISSUER"))
475 .unwrap_or_else(|_| XAI_OIDC_ISSUER.to_string());
476 let client_id = std::env::var("GROK_OIDC_CLIENT_ID")
477 .or_else(|_| std::env::var("XAI_OIDC_CLIENT_ID"))
478 .unwrap_or_else(|_| GROK_OIDC_CLIENT_ID.to_string());
479 let scopes = std::env::var("GROK_OIDC_SCOPES")
480 .or_else(|_| std::env::var("XAI_OIDC_SCOPES"))
481 .unwrap_or_else(|_| DEFAULT_SCOPES.to_string());
482 let open_browser = std::env::var_os("CODEWHALE_XAI_OAUTH_NO_BROWSER").is_none();
483
484 device_code_login_on_blocking_thread(issuer, client_id, scopes, open_browser).await
485 }
486
487 async fn device_code_login_on_blocking_thread(
488 issuer: String,
489 client_id: String,
490 scopes: String,
491 open_browser: bool,
492 ) -> Result<PendingXaiDeviceLogin> {
493 tokio::task::spawn_blocking(move || {
494 device_code_login_with(&issuer, &client_id, &scopes, open_browser)
495 })
496 .await
497 .context("xAI device-code login worker failed")?
498 }
499
500 fn device_code_login_with(
501 issuer: &str,
502 client_id: &str,
503 scopes: &str,
504 open_browser: bool,
505 ) -> Result<PendingXaiDeviceLogin> {
506 let endpoints = resolve_device_oauth_endpoints(issuer);
507 let device = request_device_code(&endpoints.device_authorization_endpoint, client_id, scopes)?;
508 let verify = device
509 .verification_uri_complete
510 .clone()
511 .or(device.verification_uri.clone())
512 .unwrap_or_else(|| format!("{issuer}/device"));
513
514 eprintln!("xAI device-code login");
515 eprintln!(" Open: {verify}");
516 eprintln!(" Code: {}", device.user_code);
517 eprintln!("Waiting for approval in the browser… (Ctrl+C to abort)");
518 if open_browser && let Err(err) = webbrowser::open(&verify) {
519 eprintln!("Could not open the browser automatically: {err}");
520 }
521
522 let mut interval = device.interval.unwrap_or(DEVICE_POLL_DEFAULT_SECS).max(1);
523 let deadline = std::time::Instant::now()
524 + Duration::from_secs(device.expires_in.unwrap_or(DEVICE_POLL_MAX_SECS).max(30));
525
526 loop {
527 let now = std::time::Instant::now();
528 if now >= deadline {
529 bail!(
530 "xAI device-code authorization timed out. Re-run device login \
531 and approve the code before it expires."
532 );
533 }
534 // Never sleep past the code's expiry, even after slow_down backoff.
535 thread::sleep(Duration::from_secs(interval).min(deadline - now));
536 match poll_device_token(&endpoints.token_endpoint, client_id, &device.device_code) {
537 Ok(token) => {
538 return Ok(PendingXaiDeviceLogin {
539 issuer: issuer.to_string(),
540 client_id: client_id.to_string(),
541 token,
542 });
543 }
544 Err(err) => {
545 let msg = err.to_string();
546 match device_poll_backoff(interval, &msg) {
547 Some(next_interval) => {
548 interval = next_interval;
549 continue;
550 }
551 None => return Err(err),
552 }
553 }
554 }
555 }
556 }
557
558 /// Commit a pending device login as a uniquely named owned generation and
559 /// atomically point `[providers.xai]` at it under the shared config lock.
560 ///
561 /// The credential file is staged while the config lock is held. If config
562 /// persistence fails, the unreferenced stage is removed. Only after the new
563 /// pointer commits is the previously selected generation removed best-effort.
564 pub fn activate_device_login(
565 pending: PendingXaiDeviceLogin,
566 config_path: Option<&Path>,
567 live_config: Option<&mut Config>,
568 ) -> Result<XaiDeviceActivation> {
569 codewhale_config::with_xai_oauth_lifecycle_lock(move |store| {
570 activate_device_login_locked(pending, config_path, live_config, store)
571 })
572 }
573
574 fn activate_device_login_locked(
575 pending: PendingXaiDeviceLogin,
576 config_path: Option<&Path>,
577 live_config: Option<&mut Config>,
578 store: &codewhale_config::XaiOAuthCredentialStore,
579 ) -> Result<XaiDeviceActivation> {
580 let config_path = crate::config_persistence::config_toml_path(config_path)?;
581 let generation = format!(
582 "{}{}{}",
583 codewhale_config::XAI_OAUTH_GENERATION_PREFIX,
584 uuid::Uuid::new_v4().simple(),
585 codewhale_config::XAI_OAUTH_GENERATION_SUFFIX
586 );
587 codewhale_config::validate_xai_oauth_generation(&generation)?;
588 let auth_path = store.path_for(&generation)?;
589 let key_inside =
590 crate::config::provider_config_key(ApiProvider::Xai).context("xAI auth mode key")?;
591 let mut stage_written = false;
592
593 let activation = codewhale_config::mutate_config_document(&config_path, |document| {
594 let previous_generation_item = document
595 .get("providers")
596 .and_then(toml_edit::Item::as_table_like)
597 .and_then(|providers| providers.get(key_inside))
598 .and_then(toml_edit::Item::as_table_like)
599 .and_then(|provider| provider.get("oauth_credential_generation"));
600 let previous_generation = previous_generation_item
601 .map(|item| {
602 item.as_str()
603 .context(
604 "refusing xAI login because the existing credential generation pointer is not a string",
605 )
606 .map(ToOwned::to_owned)
607 })
608 .transpose()?;
609 if let Some(previous) = previous_generation.as_deref() {
610 codewhale_config::validate_xai_oauth_generation(previous).with_context(|| {
611 "refusing xAI login because the existing credential generation pointer is invalid"
612 })?;
613 }
614
615 let previous_owned_name = match previous_generation.as_deref() {
616 Some(previous) => Some(previous.to_string()),
617 None if store
618 .read_to_string(codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME)?
619 .is_some() =>
620 {
621 Some(codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME.to_string())
622 }
623 None => None,
624 };
625 let mut file = match previous_owned_name.as_deref() {
626 // A valid pointer whose file is gone (interrupted revocation,
627 // external cleanup) must not brick login: only a successful
628 // activation can ever rewrite the pointer, so treat the missing
629 // generation like a fresh start instead of failing (#5032).
630 Some(name) => load_owned_auth_file_from_store(store, name)?.unwrap_or_else(|| {
631 tracing::warn!(
632 target: "codewhale::xai_oauth",
633 generation = name,
634 "config pointed at a missing owned xAI OAuth generation; starting a fresh credential file"
635 );
636 BTreeMap::new()
637 }),
638 None => BTreeMap::new(),
639 };
640 let scope = format!("{}::{}", pending.issuer, pending.client_id);
641 let mut entry = file.remove(&scope).unwrap_or(GrokAuthEntry {
642 key: None,
643 refresh_token: None,
644 expires_at: None,
645 oidc_issuer: Some(pending.issuer.clone()),
646 oidc_client_id: Some(pending.client_id.clone()),
647 auth_mode: Some("oidc".to_string()),
648 extra: BTreeMap::new(),
649 });
650 apply_token_response(
651 &mut entry,
652 &pending.issuer,
653 &pending.client_id,
654 &pending.token,
655 )?;
656 let access = entry
657 .key
658 .clone()
659 .filter(|token| !token.trim().is_empty())
660 .context("xAI device-code login returned an empty access token")?;
661 file.insert(scope.clone(), entry.clone());
662 write_auth_file_to_store(store, &generation, &file, false)?;
663 stage_written = true;
664
665 codewhale_config::set_config_document_value(
666 document,
667 &["providers", key_inside, "auth_mode"],
668 "oauth",
669 )?;
670 codewhale_config::set_config_document_value(
671 document,
672 &["providers", key_inside, "oauth_credential_generation"],
673 generation.clone(),
674 )?;
675 codewhale_config::unset_config_document_value(
676 document,
677 &["providers", key_inside, "external_credentials"],
678 )?;
679 Ok((
680 previous_owned_name,
681 credentials_from_entry(scope, &entry, access),
682 ))
683 });
684
685 let (previous_owned_name, credentials) = match activation {
686 Ok(activation) => activation,
687 Err(error) => {
688 if stage_written && let Err(cleanup_error) = store.remove(&generation) {
689 return Err(error).context(format!(
690 "xAI login was not activated; also failed to remove unreferenced staged credentials at {}: {cleanup_error}",
691 codewhale_config::quote_os_path(&auth_path)
692 ));
693 }
694 return Err(error)
695 .context("xAI login was not activated; provider configuration is unchanged");
696 }
697 };
698
699 if let Some(config) = live_config {
700 config.mark_codewhale_owned_xai_oauth(generation.clone());
701 }
702 if let Some(previous) = previous_owned_name
703 && previous != generation
704 && let Err(error) = store.remove(&previous)
705 {
706 tracing::warn!(
707 target: "codewhale::xai_oauth",
708 error = %error,
709 "new xAI OAuth generation committed but superseded generation cleanup failed"
710 );
711 }
712 eprintln!(
713 "Signed in. Codewhale-owned credentials activated at {}.",
714 codewhale_config::quote_os_path(&auth_path)
715 );
716 Ok(XaiDeviceActivation {
717 credentials,
718 config_path,
719 auth_path,
720 })
721 }
722
723 /// Best-effort repair for the [#5032] bricked-launch state: remove the stale
724 /// `[providers.xai] oauth_credential_generation` pointer from the PERSISTED
725 /// config file so the next launch is no longer bricked. Mirrors the document
726 /// edits in [`activate_device_login_locked`] (which replaces the pointer under
727 /// the config lock) and [`crate::config::clear_api_key`]'s unlocked scrub.
728 ///
729 /// Leaves `auth_mode = "oauth"` intact: the user still wants OAuth, they simply
730 /// need to re-authenticate. The launch-path caller must treat any error as
731 /// non-fatal — log a warning and continue. Returns `Ok(())` when the stale
732 /// pointer was removed (or was already absent).
733 ///
734 /// [#5032]: https://github.com/Hmbown/CodeWhale/issues/5032
735 pub fn clear_dangling_xai_oauth_generation(config_path: Option<&Path>) -> Result<()> {
736 let config_path = crate::config_persistence::config_toml_path(config_path)?;
737 let key_inside =
738 crate::config::provider_config_key(ApiProvider::Xai).context("xAI provider config key")?;
739 codewhale_config::mutate_config_document(&config_path, |document| {
740 codewhale_config::unset_config_document_value(
741 document,
742 &["providers", key_inside, "oauth_credential_generation"],
743 )?;
744 Ok(())
745 })
746 }
747
748 #[must_use]
749 pub fn missing_auth_message() -> String {
750 format!(
751 "xAI OAuth credentials not found.\n\
752 Options:\n\
753 1. Run `codewhale auth xai-device` for Codewhale-owned OAuth storage\n\
754 2. To read an existing Grok CLI login without changing it, run \
755 `codewhale auth external-consent --provider xai --mode read-only --path {}`\n\
756 3. Or use API-key auth: export XAI_API_KEY=... / \
757 codewhale auth set --provider xai",
758 codewhale_config::quote_os_path(&auth_file_path())
759 )
760 }
761
762 // ── internals ──────────────────────────────────────────────────────────────
763
764 type AuthFile = BTreeMap<String, GrokAuthEntry>;
765
766 fn load_owned_auth_file(path: &Path) -> Result<Option<AuthFile>> {
767 let Some(raw) = crate::external_credentials::read_codewhale_owned_to_string(path)? else {
768 return Ok(None);
769 };
770 parse_auth_file(&raw, path).map(Some)
771 }
772
773 fn load_owned_auth_file_from_store(
774 store: &codewhale_config::XaiOAuthCredentialStore,
775 name: &str,
776 ) -> Result<Option<AuthFile>> {
777 let Some(raw) = store.read_to_string(name)? else {
778 return Ok(None);
779 };
780 parse_auth_file(&raw, &store.path_for(name)?).map(Some)
781 }
782
783 fn load_external_auth_file(
784 grant: &codewhale_config::ExternalCredentialReadGrant,
785 ) -> Result<AuthFile> {
786 let Some(raw) = crate::external_credentials::read_to_string(grant)? else {
787 bail!(
788 "external xAI/Grok credential file not found at {}",
789 codewhale_config::quote_os_path(grant.path())
790 );
791 };
792 parse_auth_file(&raw, grant.path())
793 }
794
795 fn parse_auth_file(raw: &str, path: &Path) -> Result<AuthFile> {
796 let value: Value = serde_json::from_str(raw).map_err(|_| {
797 anyhow::anyhow!(
798 "xAI/Grok credential file {} is not valid credential JSON",
799 codewhale_config::quote_os_path(path)
800 )
801 })?;
802 let obj = value.as_object().ok_or_else(|| {
803 anyhow::anyhow!(
804 "xAI/Grok credential file {} must be a JSON object of entries",
805 codewhale_config::quote_os_path(path)
806 )
807 })?;
808 let mut out = BTreeMap::new();
809 for (k, v) in obj {
810 match serde_json::from_value::<GrokAuthEntry>(v.clone()) {
811 Ok(entry) => {
812 out.insert(k.clone(), entry);
813 }
814 Err(_) => {
815 tracing::warn!(
816 target: "codewhale::xai_oauth",
817 "skipping unreadable xAI auth entry"
818 );
819 }
820 }
821 }
822 Ok(out)
823 }
824
825 fn write_auth_file_to_store(
826 store: &codewhale_config::XaiOAuthCredentialStore,
827 name: &str,
828 file: &AuthFile,
829 allow_replace: bool,
830 ) -> Result<()> {
831 let serialized =
832 serde_json::to_vec_pretty(file).context("serializing xAI OAuth credentials")?;
833 store
834 .write(name, &serialized, allow_replace)
835 .with_context(|| {
836 format!(
837 "writing xAI OAuth credentials to {}",
838 codewhale_config::quote_os_path(&store.directory().join(name))
839 )
840 })?;
841 #[cfg(test)]
842 crate::external_credentials::record_owned_credential_write();
843 Ok(())
844 }
845
846 fn select_entry(file: &mut AuthFile) -> Option<(String, GrokAuthEntry)> {
847 // Prefer the official Grok CLI client id scope when present.
848 let preferred_suffix = format!("::{GROK_OIDC_CLIENT_ID}");
849 if let Some((k, v)) = file
850 .iter()
851 .find(|(k, e)| k.ends_with(&preferred_suffix) && entry_has_usable_secret(e))
852 {
853 return Some((k.clone(), v.clone()));
854 }
855 file.iter()
856 .find(|(_, e)| entry_has_usable_secret(e))
857 .map(|(k, v)| (k.clone(), v.clone()))
858 }
859
860 fn entry_has_usable_secret(entry: &GrokAuthEntry) -> bool {
861 entry.key.as_deref().is_some_and(|t| !t.trim().is_empty())
862 || entry
863 .refresh_token
864 .as_deref()
865 .is_some_and(|t| !t.trim().is_empty())
866 }
867
868 fn entry_access_token_is_fresh(entry: &GrokAuthEntry) -> bool {
869 let Some(token) = entry.key.as_deref().filter(|t| !t.trim().is_empty()) else {
870 return false;
871 };
872 if let Some(exp) = entry.expires_at.as_deref().and_then(parse_rfc3339_secs) {
873 let now = now_unix_secs().unwrap_or(0);
874 return exp - now > REFRESH_SKEW_SECS;
875 }
876 // Fall back to JWT exp claim when expires_at is missing.
877 match jwt_expiry_seconds(token) {
878 Some(exp) => {
879 let now = now_unix_secs().unwrap_or(0) as u64;
880 (exp as i64) - (now as i64) > REFRESH_SKEW_SECS
881 }
882 // Unknown expiry → treat as stale so refresh runs.
883 None => false,
884 }
885 }
886
887 fn credentials_from_entry(
888 scope: String,
889 entry: &GrokAuthEntry,
890 access_token: String,
891 ) -> XaiOAuthCredentials {
892 XaiOAuthCredentials {
893 access_token,
894 refresh_token: entry.refresh_token.clone(),
895 expires_at: entry.expires_at.clone(),
896 issuer: entry
897 .oidc_issuer
898 .clone()
899 .unwrap_or_else(|| issuer_from_scope(&scope)),
900 client_id: entry
901 .oidc_client_id
902 .clone()
903 .unwrap_or_else(|| client_id_from_scope(&scope)),
904 }
905 }
906
907 fn issuer_from_scope(scope: &str) -> String {
908 scope
909 .split_once("::")
910 .map(|(issuer, _)| issuer.to_string())
911 .unwrap_or_else(|| XAI_OIDC_ISSUER.to_string())
912 }
913
914 fn client_id_from_scope(scope: &str) -> String {
915 scope
916 .split_once("::")
917 .map(|(_, id)| id.to_string())
918 .unwrap_or_else(|| GROK_OIDC_CLIENT_ID.to_string())
919 }
920
921 fn apply_token_response(
922 entry: &mut GrokAuthEntry,
923 issuer: &str,
924 client_id: &str,
925 token: &TokenResponse,
926 ) -> Result<()> {
927 let access = token
928 .access_token
929 .as_deref()
930 .filter(|t| !t.trim().is_empty())
931 .context("token response missing access_token")?;
932 entry.key = Some(access.to_string());
933 if let Some(rt) = token
934 .refresh_token
935 .as_deref()
936 .filter(|t| !t.trim().is_empty())
937 {
938 entry.refresh_token = Some(rt.to_string());
939 }
940 entry.oidc_issuer = Some(issuer.to_string());
941 entry.oidc_client_id = Some(client_id.to_string());
942 entry.auth_mode = Some("oidc".to_string());
943 if let Some(expires_in) = token.expires_in {
944 entry.expires_at = Some(rfc3339_from_now(expires_in));
945 } else if let Some(exp) = jwt_expiry_seconds(access) {
946 entry.expires_at = Some(rfc3339_from_unix(exp as i64));
947 }
948 Ok(())
949 }
950
951 fn fallback_device_oauth_endpoints(issuer: &str) -> DeviceOauthEndpoints {
952 let issuer = issuer.trim_end_matches('/');
953 DeviceOauthEndpoints {
954 device_authorization_endpoint: format!("{issuer}/oauth2/device/code"),
955 token_endpoint: format!("{issuer}/oauth2/token"),
956 }
957 }
958
959 /// RFC 8628 §3.5 polling update for a failed token poll.
960 ///
961 /// Returns the interval to use for the next poll when polling should
962 /// continue: `authorization_pending` keeps the current interval, `slow_down`
963 /// increases it by [`DEVICE_SLOW_DOWN_STEP_SECS`]. Any other error is
964 /// terminal and returns `None`.
965 fn device_poll_backoff(interval: u64, error: &str) -> Option<u64> {
966 if error.contains("authorization_pending") {
967 Some(interval)
968 } else if error.contains("slow_down") {
969 Some(interval + DEVICE_SLOW_DOWN_STEP_SECS)
970 } else {
971 None
972 }
973 }
974
975 fn resolve_device_oauth_endpoints(issuer: &str) -> DeviceOauthEndpoints {
976 match discover_device_oauth_endpoints(issuer) {
977 Ok(endpoints) => endpoints,
978 Err(err) => {
979 let fallback = fallback_device_oauth_endpoints(issuer);
980 tracing::warn!(
981 target: "codewhale::xai_oauth",
982 error = %err,
983 device_authorization_endpoint = %fallback.device_authorization_endpoint,
984 token_endpoint = %fallback.token_endpoint,
985 "xAI OIDC discovery failed; using documented endpoint fallback"
986 );
987 fallback
988 }
989 }
990 }
991
992 fn discover_device_oauth_endpoints(issuer: &str) -> Result<DeviceOauthEndpoints> {
993 let discovery_url = format!(
994 "{}/.well-known/openid-configuration",
995 issuer.trim_end_matches('/')
996 );
997 let client = crate::tls::reqwest_blocking_client_builder()
998 .timeout(Duration::from_secs(20))
999 .build()
1000 .context("Failed to build xAI OIDC discovery client")?;
1001 #[cfg(test)]
1002 crate::external_credentials::record_oauth_network();
1003 let response = client
1004 .get(&discovery_url)
1005 .header(reqwest::header::ACCEPT, "application/json")
1006 .send()
1007 .context("xAI OIDC discovery request failed")?;
1008 let (status, discovery): (_, OidcDiscoveryResponse) =
1009 parse_oauth_json_response(response, "xAI OIDC discovery")?;
1010 if !status.is_success() {
1011 bail!("xAI OIDC discovery failed with HTTP {status}");
1012 }
1013 validate_discovered_issuer(discovery.issuer, issuer)?;
1014
1015 Ok(DeviceOauthEndpoints {
1016 device_authorization_endpoint: validate_discovered_oauth_endpoint(
1017 discovery.device_authorization_endpoint,
1018 "device_authorization_endpoint",
1019 issuer,
1020 )?,
1021 token_endpoint: validate_discovered_oauth_endpoint(
1022 discovery.token_endpoint,
1023 "token_endpoint",
1024 issuer,
1025 )?,
1026 })
1027 }
1028
1029 fn validate_discovered_issuer(discovered: Option<String>, expected: &str) -> Result<()> {
1030 let discovered = discovered
1031 .as_deref()
1032 .map(str::trim)
1033 .filter(|issuer| !issuer.is_empty())
1034 .context("xAI OIDC discovery missing issuer")?;
1035 if discovered.trim_end_matches('/') != expected.trim_end_matches('/') {
1036 bail!("xAI OIDC discovery issuer does not match the requested issuer");
1037 }
1038 Ok(())
1039 }
1040
1041 fn validate_discovered_oauth_endpoint(
1042 endpoint: Option<String>,
1043 field: &str,
1044 issuer: &str,
1045 ) -> Result<String> {
1046 let endpoint = endpoint
1047 .as_deref()
1048 .map(str::trim)
1049 .filter(|endpoint| !endpoint.is_empty())
1050 .with_context(|| format!("xAI OIDC discovery missing {field}"))?;
1051 let parsed = reqwest::Url::parse(endpoint)
1052 .with_context(|| format!("xAI OIDC discovery returned an invalid {field}"))?;
1053 if !matches!(parsed.scheme(), "http" | "https") {
1054 bail!("xAI OIDC discovery returned unsupported {field} scheme");
1055 }
1056 let issuer = reqwest::Url::parse(issuer).context("xAI OIDC issuer is not a valid URL")?;
1057 if issuer.scheme() == "https" && parsed.scheme() != "https" {
1058 bail!("xAI OIDC discovery attempted to downgrade {field} from HTTPS");
1059 }
1060 if !parsed.username().is_empty() || parsed.password().is_some() {
1061 bail!("xAI OIDC discovery returned credentials in {field}");
1062 }
1063 if parsed.origin() != issuer.origin() {
1064 bail!("xAI OIDC discovery returned {field} on a different origin than the issuer");
1065 }
1066 Ok(endpoint.to_string())
1067 }
1068
1069 fn parse_oauth_json_response<T: DeserializeOwned>(
1070 response: reqwest::blocking::Response,
1071 operation: &str,
1072 ) -> Result<(reqwest::StatusCode, T)> {
1073 let status = response.status();
1074 let content_type = response
1075 .headers()
1076 .get(reqwest::header::CONTENT_TYPE)
1077 .and_then(|value| value.to_str().ok())
1078 .unwrap_or("missing")
1079 .to_string();
1080 let mut reader = response.take(OAUTH_RESPONSE_BODY_LIMIT + 1);
1081 let mut body = Vec::new();
1082 reader
1083 .read_to_end(&mut body)
1084 .with_context(|| format!("reading {operation} response"))?;
1085 let truncated = body.len() as u64 > OAUTH_RESPONSE_BODY_LIMIT;
1086 if truncated {
1087 body.truncate(OAUTH_RESPONSE_BODY_LIMIT as usize);
1088 }
1089
1090 let parsed = serde_json::from_slice(&body).map_err(|_| {
1091 let limit = if truncated {
1092 " (body exceeded the 64 KiB diagnostic limit)"
1093 } else {
1094 ""
1095 };
1096 anyhow::anyhow!(
1097 "{operation} returned HTTP {status} with content type {content_type}; expected JSON{limit}"
1098 )
1099 })?;
1100 Ok((status, parsed))
1101 }
1102
1103 fn oauth_failure_detail(
1104 error: Option<&str>,
1105 description: Option<&str>,
1106 status: reqwest::StatusCode,
1107 ) -> String {
1108 let mut code = bounded_oauth_error_text(error.unwrap_or("request_failed"));
1109 if code.is_empty() {
1110 code = "request_failed".to_string();
1111 }
1112 let description = description
1113 .map(bounded_oauth_error_text)
1114 .filter(|description| !description.is_empty() && description != &code);
1115 match description {
1116 Some(description) => format!("{code}: {description}; HTTP {status}"),
1117 None => format!("{code}; HTTP {status}"),
1118 }
1119 }
1120
1121 fn bounded_oauth_error_text(raw: &str) -> String {
1122 let mut output = String::with_capacity(raw.len().min(OAUTH_ERROR_DETAIL_LIMIT));
1123 let mut previous_was_space = false;
1124 let mut written = 0;
1125 for character in raw.chars() {
1126 let character = if character.is_whitespace() {
1127 ' '
1128 } else if character.is_control() {
1129 continue;
1130 } else {
1131 character
1132 };
1133 if character == ' ' && previous_was_space {
1134 continue;
1135 }
1136 if written == OAUTH_ERROR_DETAIL_LIMIT {
1137 break;
1138 }
1139 output.push(character);
1140 previous_was_space = character == ' ';
1141 written += 1;
1142 }
1143 output.trim().to_string()
1144 }
1145
1146 fn refresh_access_token(
1147 issuer: &str,
1148 client_id: &str,
1149 refresh_token: &str,
1150 ) -> Result<TokenResponse> {
1151 #[cfg(test)]
1152 crate::external_credentials::record_oauth_refresh();
1153 let token_endpoint = resolve_device_oauth_endpoints(issuer).token_endpoint;
1154 let client = crate::tls::reqwest_blocking_client_builder()
1155 .timeout(Duration::from_secs(20))
1156 .build()
1157 .context("Failed to build xAI OAuth refresh client")?;
1158 let params = [
1159 ("client_id", client_id),
1160 ("grant_type", "refresh_token"),
1161 ("refresh_token", refresh_token),
1162 ];
1163 #[cfg(test)]
1164 crate::external_credentials::record_oauth_network();
1165 let response = client
1166 .post(token_endpoint)
1167 .form(&params)
1168 .send()
1169 .context("xAI OAuth refresh request failed")?;
1170 let (status, body): (_, TokenResponse) =
1171 parse_oauth_json_response(response, "xAI OAuth refresh")?;
1172 if !status.is_success() || body.error.is_some() {
1173 // Refresh requests carry a credential. Do not echo a server-provided
1174 // description that could reflect the submitted refresh token.
1175 let err = oauth_failure_detail(body.error.as_deref(), None, status);
1176 bail!(
1177 "xAI OAuth refresh failed ({err}). Run `grok login` or device-code login again. \
1178 If SuperGrok OAuth returns HTTP 403, use XAI_API_KEY instead."
1179 );
1180 }
1181 Ok(body)
1182 }
1183
1184 fn request_device_code(
1185 device_authorization_endpoint: &str,
1186 client_id: &str,
1187 scopes: &str,
1188 ) -> Result<DeviceCodeGrant> {
1189 let client = crate::tls::reqwest_blocking_client_builder()
1190 .timeout(Duration::from_secs(20))
1191 .build()
1192 .context("Failed to build xAI device-code client")?;
1193 let params = [("client_id", client_id), ("scope", scopes)];
1194 #[cfg(test)]
1195 crate::external_credentials::record_oauth_network();
1196 let response = client
1197 .post(device_authorization_endpoint)
1198 .form(&params)
1199 .send()
1200 .context("xAI device-code request failed")?;
1201 let (status, body): (_, DeviceCodeResponse) =
1202 parse_oauth_json_response(response, "xAI device-code request")?;
1203 if !status.is_success() || body.error.is_some() {
1204 let err = oauth_failure_detail(
1205 body.error.as_deref(),
1206 body.error_description.as_deref(),
1207 status,
1208 );
1209 bail!("xAI device-code request failed ({err})");
1210 }
1211 let device_code = body
1212 .device_code
1213 .filter(|value| !value.trim().is_empty())
1214 .context("xAI device-code response missing device_code")?;
1215 let user_code = body
1216 .user_code
1217 .filter(|value| !value.trim().is_empty())
1218 .context("xAI device-code response missing user_code")?;
1219 Ok(DeviceCodeGrant {
1220 device_code,
1221 user_code,
1222 verification_uri: body.verification_uri,
1223 verification_uri_complete: body.verification_uri_complete,
1224 expires_in: body.expires_in,
1225 interval: body.interval,
1226 })
1227 }
1228
1229 fn poll_device_token(
1230 token_endpoint: &str,
1231 client_id: &str,
1232 device_code: &str,
1233 ) -> Result<TokenResponse> {
1234 let client = crate::tls::reqwest_blocking_client_builder()
1235 .timeout(Duration::from_secs(20))
1236 .build()
1237 .context("Failed to build xAI device-code poll client")?;
1238 let params = [
1239 ("client_id", client_id),
1240 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
1241 ("device_code", device_code),
1242 ];
1243 #[cfg(test)]
1244 crate::external_credentials::record_oauth_network();
1245 let response = client
1246 .post(token_endpoint)
1247 .form(&params)
1248 .send()
1249 .context("xAI device-code token poll failed")?;
1250 let (status, body): (_, TokenResponse) =
1251 parse_oauth_json_response(response, "xAI device-code token exchange")?;
1252 if let Some(err) = body.error.as_deref() {
1253 if matches!(err, "authorization_pending" | "slow_down") {
1254 bail!("{err}");
1255 }
1256 // Poll requests carry the device credential. Keep diagnostics to the
1257 // standard error code and HTTP status rather than echoing descriptions.
1258 let detail = oauth_failure_detail(Some(err), None, status);
1259 bail!("xAI device-code token exchange failed: {detail}");
1260 }
1261 if !status.is_success() {
1262 let detail = oauth_failure_detail(None, None, status);
1263 bail!("xAI device-code token exchange failed: {detail}");
1264 }
1265 Ok(body)
1266 }
1267
1268 fn jwt_expiry_seconds(token: &str) -> Option<u64> {
1269 use base64::Engine as _;
1270 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1271 let mut parts = token.split('.');
1272 let _header = parts.next()?;
1273 let payload = parts.next()?;
1274 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
1275 let claims: Value = serde_json::from_slice(&decoded).ok()?;
1276 claims.get("exp")?.as_u64()
1277 }
1278
1279 fn now_unix_secs() -> Option<i64> {
1280 SystemTime::now()
1281 .duration_since(UNIX_EPOCH)
1282 .ok()
1283 .map(|d| d.as_secs() as i64)
1284 }
1285
1286 fn parse_rfc3339_secs(raw: &str) -> Option<i64> {
1287 // Prefer chrono when available for full RFC3339; fall back to simple UTC forms.
1288 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
1289 return Some(dt.timestamp());
1290 }
1291 // e.g. 2026-07-09T12:00:00Z
1292 let trimmed = raw.trim().trim_end_matches('Z');
1293 let (date, time) = trimmed.split_once('T')?;
1294 let mut d = date.split('-');
1295 let y: i32 = d.next()?.parse().ok()?;
1296 let m: u32 = d.next()?.parse().ok()?;
1297 let day: u32 = d.next()?.parse().ok()?;
1298 let time = time.split('+').next()?.split('-').next()?;
1299 let mut t = time.split(':');
1300 let hh: u32 = t.next()?.parse().ok()?;
1301 let mm: u32 = t.next()?.parse().ok()?;
1302 let ss: u32 = t
1303 .next()
1304 .and_then(|s| s.split('.').next())
1305 .and_then(|s| s.parse().ok())
1306 .unwrap_or(0);
1307 let ndt = chrono::NaiveDate::from_ymd_opt(y, m, day)?.and_hms_opt(hh, mm, ss)?;
1308 Some(ndt.and_utc().timestamp())
1309 }
1310
1311 fn rfc3339_from_now(expires_in: u64) -> String {
1312 let ts = now_unix_secs().unwrap_or(0) + expires_in as i64;
1313 rfc3339_from_unix(ts)
1314 }
1315
1316 fn rfc3339_from_unix(ts: i64) -> String {
1317 chrono::DateTime::from_timestamp(ts, 0)
1318 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
1319 .unwrap_or_else(|| format!("{ts}"))
1320 }
1321
1322 #[cfg(all(unix, test))]
1323 use std::os::unix::fs::PermissionsExt;
1324
1325 #[cfg(test)]
1326 mod tests {
1327 use super::*;
1328 use tempfile::TempDir;
1329 use wiremock::matchers::{body_string_contains, header, method, path};
1330 use wiremock::{Mock, MockServer, ResponseTemplate};
1331
1332 #[test]
1333 fn auth_mode_accepts_oauth_aliases() {
1334 for mode in [
1335 "oauth",
1336 "xai_oauth",
1337 "XAI-OAuth",
1338 "grok",
1339 "grok_cli",
1340 "device_code",
1341 "device-auth",
1342 ] {
1343 assert!(
1344 auth_mode_uses_xai_oauth(mode),
1345 "expected oauth mode: {mode}"
1346 );
1347 }
1348 assert!(!auth_mode_uses_xai_oauth("api_key"));
1349 assert!(!auth_mode_uses_xai_oauth("keyring"));
1350 }
1351
1352 #[test]
1353 fn loads_fresh_token_from_grok_auth_json() {
1354 let _guard = crate::test_support::lock_test_env();
1355 let dir = TempDir::new().unwrap();
1356 let root = dir.path().canonicalize().expect("canonical temp root");
1357 let path = root.join("auth.json");
1358 let future = rfc3339_from_now(3600);
1359 let scope = format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}");
1360 let file = serde_json::json!({
1361 scope: {
1362 "key": "test-access-token",
1363 "refresh_token": "test-refresh",
1364 "expires_at": future,
1365 "oidc_issuer": XAI_OIDC_ISSUER,
1366 "oidc_client_id": GROK_OIDC_CLIENT_ID,
1367 "auth_mode": "oidc"
1368 }
1369 });
1370 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
1371 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
1372 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
1373 let config = Config {
1374 provider: Some(ApiProvider::Xai.as_str().to_string()),
1375 providers: Some(crate::config::ProvidersConfig {
1376 xai: crate::config::ProviderConfig {
1377 auth_mode: Some("oauth".to_string()),
1378 external_credentials: Some(
1379 codewhale_config::ExternalCredentialConsentToml::read_only(
1380 codewhale_config::ProviderKind::Xai,
1381 codewhale_config::ExternalCredentialSource::GrokCli,
1382 path.clone(),
1383 ),
1384 ),
1385 ..Default::default()
1386 },
1387 ..Default::default()
1388 }),
1389 ..Default::default()
1390 };
1391 crate::external_credentials::reset_side_effect_trap();
1392 let result = get_credentials(&config);
1393 let creds = result.expect("load");
1394 assert_eq!(creds.access_token, "test-access-token");
1395 assert_eq!(creds.client_id, GROK_OIDC_CLIENT_ID);
1396 assert_eq!(
1397 crate::external_credentials::side_effect_trap_counts(),
1398 (1, 1)
1399 );
1400 }
1401
1402 #[test]
1403 fn disabled_external_grok_credentials_cause_zero_external_io() {
1404 let _guard = crate::test_support::lock_test_env();
1405 let dir = TempDir::new().unwrap();
1406 let root = dir.path().canonicalize().expect("canonical temp root");
1407 let path = root.join("external-grok-auth.json");
1408 let raw = serde_json::json!({
1409 format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}"): {
1410 "key": "must-never-be-read",
1411 "refresh_token": "must-never-be-used",
1412 "expires_at": rfc3339_from_now(3600),
1413 "future_field": {"preserve": true}
1414 }
1415 })
1416 .to_string();
1417 fs::write(&path, &raw).unwrap();
1418 let owned_home = root.join("codewhale-owned");
1419 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
1420 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
1421 let config = Config {
1422 provider: Some(ApiProvider::Xai.as_str().to_string()),
1423 providers: Some(crate::config::ProvidersConfig {
1424 xai: crate::config::ProviderConfig {
1425 auth_mode: Some("oauth".to_string()),
1426 ..Default::default()
1427 },
1428 ..Default::default()
1429 }),
1430 ..Default::default()
1431 };
1432
1433 crate::external_credentials::reset_side_effect_trap();
1434 assert!(!credentials_valid(&config));
1435 let error = get_credentials(&config).expect_err("external access is disabled");
1436 assert!(error.to_string().contains("are disabled"));
1437 assert_eq!(
1438 crate::external_credentials::side_effect_trap_counts(),
1439 (0, 0)
1440 );
1441 assert_eq!(
1442 crate::external_credentials::complete_side_effect_trap_counts(),
1443 (0, 0, 0, 0, 0),
1444 "disabled external authority must reach no credential or OAuth sink"
1445 );
1446 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
1447 }
1448
1449 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1450 async fn expired_read_only_external_credentials_never_refresh_rewrite_or_network() {
1451 let _guard = crate::test_support::lock_test_env();
1452 let server = MockServer::start().await;
1453 let dir = TempDir::new().unwrap();
1454 let root = dir.path().canonicalize().expect("canonical temp root");
1455 let path = root.join("external-grok-auth.json");
1456 let scope = format!("{}::{GROK_OIDC_CLIENT_ID}", server.uri());
1457 let raw = serde_json::json!({
1458 scope: {
1459 "key": "expired-external-access",
1460 "refresh_token": "must-never-be-submitted",
1461 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
1462 "oidc_issuer": server.uri(),
1463 "oidc_client_id": GROK_OIDC_CLIENT_ID,
1464 "future_field": {"preserve": true}
1465 }
1466 })
1467 .to_string();
1468 fs::write(&path, &raw).unwrap();
1469 let owned_home = root.join("codewhale-owned");
1470 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
1471 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
1472 let config = Config {
1473 provider: Some(ApiProvider::Xai.as_str().to_string()),
1474 providers: Some(crate::config::ProvidersConfig {
1475 xai: crate::config::ProviderConfig {
1476 auth_mode: Some("oauth".to_string()),
1477 external_credentials: Some(
1478 codewhale_config::ExternalCredentialConsentToml::read_only(
1479 codewhale_config::ProviderKind::Xai,
1480 codewhale_config::ExternalCredentialSource::GrokCli,
1481 path.clone(),
1482 ),
1483 ),
1484 ..Default::default()
1485 },
1486 ..Default::default()
1487 }),
1488 ..Default::default()
1489 };
1490
1491 crate::external_credentials::reset_side_effect_trap();
1492 let error = tokio::task::block_in_place(|| get_credentials(&config))
1493 .expect_err("read-only external credentials must fail instead of refreshing");
1494 assert!(
1495 error
1496 .to_string()
1497 .contains("Read-only consent never refreshes")
1498 );
1499 assert_eq!(
1500 crate::external_credentials::side_effect_trap_counts(),
1501 (1, 1)
1502 );
1503 assert_eq!(
1504 crate::external_credentials::complete_side_effect_trap_counts(),
1505 (1, 1, 0, 0, 0),
1506 "read-only external expiry must not reach write, refresh, or network sinks"
1507 );
1508 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
1509 assert!(!owned_home.join("credentials/xai-auth.json").exists());
1510 assert!(
1511 server
1512 .received_requests()
1513 .await
1514 .expect("recorded requests")
1515 .is_empty(),
1516 "external refresh tokens must never be sent over the network"
1517 );
1518 }
1519
1520 /// #4763 root trigger. A returning xAI-OAuth user whose only material is
1521 /// an external Grok CLI grant loses readiness the moment that CLI's
1522 /// short-lived access token expires, even though a refresh token sits
1523 /// right beside it — read-only consent deliberately never refreshes or
1524 /// rewrites another CLI's file, so there is nothing to renew it with.
1525 /// `needs_api_key` therefore flips to true and onboarding reopens. That
1526 /// is the intended invariant, not a leak; this test pins it so the
1527 /// onboarding entry point stays explainable.
1528 #[test]
1529 fn expired_external_grok_grant_reads_as_missing_key_despite_refresh_token() {
1530 let _guard = crate::test_support::lock_test_env();
1531 let dir = TempDir::new().unwrap();
1532 let root = dir.path().canonicalize().expect("canonical temp root");
1533 let path = root.join("external-grok-auth.json");
1534 let scope = format!("https://auth.x.ai::{GROK_OIDC_CLIENT_ID}");
1535 fs::write(
1536 &path,
1537 serde_json::json!({
1538 scope.clone(): {
1539 "key": "expired-external-access",
1540 "refresh_token": "present-but-unusable-under-read-only-consent",
1541 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
1542 "oidc_client_id": GROK_OIDC_CLIENT_ID,
1543 }
1544 })
1545 .to_string(),
1546 )
1547 .unwrap();
1548 let _home_guard =
1549 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.join("codewhale-owned"));
1550 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
1551 let _key_guard = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
1552 let config = Config {
1553 provider: Some(ApiProvider::Xai.as_str().to_string()),
1554 providers: Some(crate::config::ProvidersConfig {
1555 xai: crate::config::ProviderConfig {
1556 auth_mode: Some("oauth".to_string()),
1557 external_credentials: Some(
1558 codewhale_config::ExternalCredentialConsentToml::read_only(
1559 codewhale_config::ProviderKind::Xai,
1560 codewhale_config::ExternalCredentialSource::GrokCli,
1561 path.clone(),
1562 ),
1563 ),
1564 ..Default::default()
1565 },
1566 ..Default::default()
1567 }),
1568 ..Default::default()
1569 };
1570
1571 assert!(
1572 !credentials_present(&config),
1573 "an expired external access token is not usable material"
1574 );
1575 assert!(
1576 !crate::config::has_api_key_for(&config, ApiProvider::Xai),
1577 "expired external xAI OAuth must fall through to the missing-key path"
1578 );
1579
1580 // The same file with a live access token is ready, so the check is
1581 // expiry-driven rather than a blanket rejection of external grants.
1582 fs::write(
1583 &path,
1584 serde_json::json!({
1585 scope: {
1586 "key": "fresh-external-access",
1587 "refresh_token": "unused",
1588 "expires_at": rfc3339_from_now(3600),
1589 "oidc_client_id": GROK_OIDC_CLIENT_ID,
1590 }
1591 })
1592 .to_string(),
1593 )
1594 .unwrap();
1595 assert!(credentials_present(&config));
1596 assert!(crate::config::has_api_key_for(&config, ApiProvider::Xai));
1597 }
1598
1599 #[test]
1600 fn native_login_storage_is_codewhale_owned() {
1601 let _guard = crate::test_support::lock_test_env();
1602 let dir = TempDir::new().unwrap();
1603 let grok_path = dir.path().join("external-grok-auth.json");
1604 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
1605 let _grok = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
1606
1607 let owned = codewhale_auth_file_path().expect("Codewhale-owned auth path");
1608 assert_eq!(owned, dir.path().join("credentials/xai-auth.json"));
1609 assert_ne!(owned, auth_file_path());
1610 }
1611
1612 fn pending_login(access: &str, refresh: &str) -> PendingXaiDeviceLogin {
1613 pending_device_login_for_test(access, refresh)
1614 }
1615
1616 fn seed_expired_owned_generation() -> String {
1617 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json".to_string();
1618 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1619 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
1620 let mut file = AuthFile::new();
1621 file.insert(
1622 scope,
1623 GrokAuthEntry {
1624 key: Some("expired-access".to_string()),
1625 refresh_token: Some("initial-refresh".to_string()),
1626 expires_at: Some("1970-01-01T00:00:00.000Z".to_string()),
1627 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
1628 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
1629 auth_mode: Some("oidc".to_string()),
1630 extra: BTreeMap::new(),
1631 },
1632 );
1633 write_auth_file_to_store(store, &generation, &file, false)
1634 })
1635 .expect("seed expired owned generation");
1636 generation
1637 }
1638
1639 fn seed_legacy_owned_credentials() -> PathBuf {
1640 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1641 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
1642 let mut legacy = AuthFile::new();
1643 legacy.insert(
1644 scope,
1645 GrokAuthEntry {
1646 key: Some("legacy-access".to_string()),
1647 refresh_token: Some("legacy-refresh".to_string()),
1648 expires_at: Some(rfc3339_from_now(3600)),
1649 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
1650 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
1651 auth_mode: Some("oidc".to_string()),
1652 extra: BTreeMap::new(),
1653 },
1654 );
1655 write_auth_file_to_store(
1656 store,
1657 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
1658 &legacy,
1659 false,
1660 )?;
1661 store.path_for(codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME)
1662 })
1663 .expect("seed legacy credentials")
1664 }
1665
1666 #[test]
1667 fn concurrent_refreshes_share_one_rotated_epoch() {
1668 let _guard = crate::test_support::lock_test_env();
1669 let dir = TempDir::new().unwrap();
1670 let home = dir
1671 .path()
1672 .canonicalize()
1673 .expect("canonical temp root")
1674 .join("owned-home");
1675 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1676 let generation = seed_expired_owned_generation();
1677 let refreshes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1678 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
1679 let (release_tx, release_rx) = std::sync::mpsc::channel();
1680
1681 let first_generation = generation.clone();
1682 let first_refreshes = refreshes.clone();
1683 let first = std::thread::spawn(move || {
1684 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1685 get_owned_credentials_locked(store, &first_generation, |_, _, refresh| {
1686 assert_eq!(refresh, "initial-refresh");
1687 first_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1688 entered_tx.send(()).unwrap();
1689 release_rx.recv().unwrap();
1690 Ok(TokenResponse {
1691 access_token: Some("rotated-access".to_string()),
1692 refresh_token: Some("rotated-refresh".to_string()),
1693 expires_in: Some(3600),
1694 error: None,
1695 })
1696 })
1697 })
1698 });
1699 entered_rx.recv().expect("first refresh reached barrier");
1700
1701 let second_generation = generation.clone();
1702 let second_refreshes = refreshes.clone();
1703 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
1704 let second = std::thread::spawn(move || {
1705 attempt_tx.send(()).unwrap();
1706 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1707 get_owned_credentials_locked(store, &second_generation, |_, _, _| {
1708 second_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1709 bail!("second refresh must observe the first thread's committed token")
1710 })
1711 })
1712 });
1713 attempt_rx.recv().expect("second refresh attempted lock");
1714 release_tx.send(()).expect("release first refresh");
1715
1716 let first = first.join().unwrap().expect("first refresh");
1717 let second = second.join().unwrap().expect("second refresh");
1718 assert_eq!(first.access_token, "rotated-access");
1719 assert_eq!(second.access_token, "rotated-access");
1720 assert_eq!(refreshes.load(std::sync::atomic::Ordering::SeqCst), 1);
1721 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1722 let mut file = load_owned_auth_file_from_store(store, &generation)?
1723 .context("generation must remain active")?;
1724 let (_, entry) = select_entry(&mut file).context("stored entry")?;
1725 assert_eq!(entry.refresh_token.as_deref(), Some("rotated-refresh"));
1726 Ok(())
1727 })
1728 .unwrap();
1729 }
1730
1731 #[test]
1732 fn logout_waits_for_refresh_then_revokes_the_committed_epoch() {
1733 let _guard = crate::test_support::lock_test_env();
1734 let dir = TempDir::new().unwrap();
1735 let home = dir
1736 .path()
1737 .canonicalize()
1738 .expect("canonical temp root")
1739 .join("owned-home");
1740 fs::create_dir_all(&home).unwrap();
1741 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1742 let generation = seed_expired_owned_generation();
1743 fs::write(
1744 home.join("config.toml"),
1745 format!(
1746 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{generation}\"\n"
1747 ),
1748 )
1749 .unwrap();
1750 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
1751 let (release_tx, release_rx) = std::sync::mpsc::channel();
1752
1753 let refresh_generation = generation.clone();
1754 let refresh = std::thread::spawn(move || {
1755 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1756 get_owned_credentials_locked(store, &refresh_generation, |_, _, _| {
1757 entered_tx.send(()).unwrap();
1758 release_rx.recv().unwrap();
1759 Ok(TokenResponse {
1760 access_token: Some("last-refresh-access".to_string()),
1761 refresh_token: Some("last-refresh-rotation".to_string()),
1762 expires_in: Some(3600),
1763 error: None,
1764 })
1765 })
1766 })
1767 });
1768 entered_rx.recv().expect("refresh reached barrier");
1769
1770 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
1771 let config_path = home.join("config.toml");
1772 let logout = std::thread::spawn(move || {
1773 attempt_tx.send(()).unwrap();
1774 codewhale_config::with_xai_oauth_revocation_transaction(|| {
1775 codewhale_config::mutate_config_document(&config_path, |document| {
1776 codewhale_config::unset_config_document_value(
1777 document,
1778 &["providers", "xai", "oauth_credential_generation"],
1779 )?;
1780 codewhale_config::unset_config_document_value(
1781 document,
1782 &["providers", "xai", "auth_mode"],
1783 )?;
1784 Ok(())
1785 })
1786 })
1787 });
1788 attempt_rx.recv().expect("logout attempted lifecycle lock");
1789 release_tx.send(()).expect("release refresh");
1790
1791 assert_eq!(
1792 refresh.join().unwrap().expect("refresh").access_token,
1793 "last-refresh-access"
1794 );
1795 logout.join().unwrap().expect("logout");
1796 let auth_path = home.join("credentials").join(&generation);
1797 assert!(
1798 !auth_path.exists(),
1799 "logout must retire the generation written by the preceding refresh"
1800 );
1801 let config = fs::read_to_string(home.join("config.toml")).unwrap();
1802 assert!(!config.contains("oauth_credential_generation"));
1803 assert!(!config.contains("auth_mode"));
1804 }
1805
1806 #[test]
1807 fn activation_commits_unique_generation_pointer_and_revokes_external_consent() {
1808 let _guard = crate::test_support::lock_test_env();
1809 let dir = TempDir::new().unwrap();
1810 let home = dir
1811 .path()
1812 .canonicalize()
1813 .expect("canonical temp root")
1814 .join("owned-home");
1815 let config_path = dir.path().join("config.toml");
1816 let external_path = dir.path().join("grok-external.json");
1817 fs::write(&external_path, "external owner bytes").unwrap();
1818 fs::write(
1819 &config_path,
1820 format!(
1821 r#"# operator note
1822 [providers.xai]
1823 model = "grok-code-fast-1" # model note
1824 future_setting = "preserve"
1825
1826 [providers.xai.external_credentials]
1827 access = "read_only"
1828 provider = "xai"
1829 source = "grok_cli"
1830 path = {}
1831 consent_version = 1
1832 "#,
1833 toml::Value::String(external_path.display().to_string())
1834 ),
1835 )
1836 .unwrap();
1837 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1838 let consent = codewhale_config::ExternalCredentialConsentToml::read_only(
1839 codewhale_config::ProviderKind::Xai,
1840 codewhale_config::ExternalCredentialSource::GrokCli,
1841 external_path.clone(),
1842 );
1843 let mut live = Config {
1844 providers: Some(crate::config::ProvidersConfig {
1845 xai: crate::config::ProviderConfig {
1846 model: Some("grok-code-fast-1".to_string()),
1847 external_credentials: Some(consent),
1848 ..Default::default()
1849 },
1850 ..Default::default()
1851 }),
1852 ..Default::default()
1853 };
1854
1855 crate::external_credentials::reset_side_effect_trap();
1856 let activation = activate_device_login(
1857 pending_login("activation-access", "activation-refresh"),
1858 Some(&config_path),
1859 Some(&mut live),
1860 )
1861 .expect("activate login");
1862
1863 assert_eq!(activation.config_path, config_path);
1864 let generation = activation
1865 .auth_path
1866 .file_name()
1867 .and_then(|name| name.to_str())
1868 .expect("generation basename");
1869 assert!(codewhale_config::is_valid_xai_oauth_generation(generation));
1870 let persisted = fs::read_to_string(&config_path).unwrap();
1871 assert!(persisted.contains("# operator note"));
1872 assert!(persisted.contains("model = \"grok-code-fast-1\" # model note"));
1873 assert!(persisted.contains("future_setting = \"preserve\""));
1874 assert!(persisted.contains("auth_mode = \"oauth\""));
1875 assert!(persisted.contains(&format!("oauth_credential_generation = \"{generation}\"")));
1876 assert!(!persisted.contains("external_credentials"));
1877 assert_eq!(
1878 fs::read_to_string(&external_path).unwrap(),
1879 "external owner bytes"
1880 );
1881 let owned = fs::read_to_string(&activation.auth_path).unwrap();
1882 assert!(owned.contains("activation-access"));
1883 assert!(owned.contains("activation-refresh"));
1884 #[cfg(unix)]
1885 assert_eq!(
1886 fs::metadata(&activation.auth_path)
1887 .unwrap()
1888 .permissions()
1889 .mode()
1890 & 0o777,
1891 0o600
1892 );
1893 let live_xai = live.provider_config_for(ApiProvider::Xai).unwrap();
1894 assert_eq!(live_xai.auth_mode.as_deref(), Some("oauth"));
1895 assert_eq!(
1896 live_xai.oauth_credential_generation.as_deref(),
1897 Some(generation)
1898 );
1899 assert!(live_xai.external_credentials.is_none());
1900 assert_eq!(
1901 crate::external_credentials::complete_side_effect_trap_counts(),
1902 (0, 0, 1, 0, 0),
1903 "activation must reach exactly the owned write sink"
1904 );
1905 }
1906
1907 #[test]
1908 fn activation_retires_legacy_owned_file_only_after_config_commit() {
1909 let _guard = crate::test_support::lock_test_env();
1910 let dir = TempDir::new().unwrap();
1911 let home = dir
1912 .path()
1913 .canonicalize()
1914 .expect("canonical temp root")
1915 .join("owned-home");
1916 let config_path = dir.path().join("config.toml");
1917 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
1918 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1919 let legacy_path = seed_legacy_owned_credentials();
1920 assert!(legacy_path.exists());
1921
1922 let activation = activate_device_login(
1923 pending_login("new-access", "new-refresh"),
1924 Some(&config_path),
1925 None,
1926 )
1927 .expect("activate replacement generation");
1928
1929 assert!(activation.auth_path.exists());
1930 assert!(
1931 !legacy_path.exists(),
1932 "legacy duplicate must be removed after the generation pointer commits"
1933 );
1934 let persisted = fs::read_to_string(config_path).unwrap();
1935 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
1936 }
1937
1938 #[test]
1939 fn activation_rotation_cleans_only_the_superseded_generation_after_commit() {
1940 let _guard = crate::test_support::lock_test_env();
1941 let dir = TempDir::new().unwrap();
1942 let home = dir
1943 .path()
1944 .canonicalize()
1945 .expect("canonical temp root")
1946 .join("owned-home");
1947 let config_path = dir.path().join("config.toml");
1948 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
1949 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1950 let mut live = Config::default();
1951
1952 let first = activate_device_login(
1953 pending_login("first-access", "first-refresh"),
1954 Some(&config_path),
1955 Some(&mut live),
1956 )
1957 .expect("first activation");
1958 assert!(first.auth_path.exists());
1959 let first_name = first
1960 .auth_path
1961 .file_name()
1962 .unwrap()
1963 .to_str()
1964 .unwrap()
1965 .to_string();
1966
1967 let second = activate_device_login(
1968 pending_login("second-access", "second-refresh"),
1969 Some(&config_path),
1970 Some(&mut live),
1971 )
1972 .expect("second activation");
1973 assert_ne!(first.auth_path, second.auth_path);
1974 assert!(second.auth_path.exists());
1975 assert!(
1976 !first.auth_path.exists(),
1977 "superseded generation must be removed only after the new pointer commits"
1978 );
1979 let persisted = fs::read_to_string(&config_path).unwrap();
1980 assert!(!persisted.contains(&first_name));
1981 assert!(persisted.contains(second.auth_path.file_name().unwrap().to_str().unwrap()));
1982 assert!(
1983 fs::read_to_string(second.auth_path)
1984 .unwrap()
1985 .contains("second-access")
1986 );
1987 }
1988
1989 #[test]
1990 fn activation_recovers_from_a_dangling_generation_pointer() {
1991 let _guard = crate::test_support::lock_test_env();
1992 let dir = TempDir::new().unwrap();
1993 let home = dir
1994 .path()
1995 .canonicalize()
1996 .expect("canonical temp root")
1997 .join("owned-home");
1998 let config_path = dir.path().join("config.toml");
1999 // A valid-looking generation pointer whose credential file does not
2000 // exist: the state Hunter's dogfood machine was bricked in (#5032).
2001 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
2002 fs::write(
2003 &config_path,
2004 format!(
2005 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
2006 ),
2007 )
2008 .unwrap();
2009 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
2010 let mut live = Config::default();
2011
2012 let activation = activate_device_login(
2013 pending_login("recovered-access", "recovered-refresh"),
2014 Some(&config_path),
2015 Some(&mut live),
2016 )
2017 .expect("a dangling generation pointer must not brick login");
2018 assert!(activation.auth_path.exists());
2019 assert!(
2020 fs::read_to_string(&activation.auth_path)
2021 .unwrap()
2022 .contains("recovered-access")
2023 );
2024 let persisted = fs::read_to_string(&config_path).unwrap();
2025 assert!(
2026 !persisted.contains(stale),
2027 "stale pointer must be replaced: {persisted}"
2028 );
2029 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
2030 assert!(persisted.contains("auth_mode = \"oauth\""));
2031 }
2032
2033 #[test]
2034 fn dangling_generation_pointer_is_detected_and_repaired() {
2035 let _guard = crate::test_support::lock_test_env();
2036 let dir = TempDir::new().unwrap();
2037 let home = dir
2038 .path()
2039 .canonicalize()
2040 .expect("canonical temp root")
2041 .join("owned-home");
2042 let config_path = dir.path().join("config.toml");
2043 // A valid-looking generation pointer whose credential file does not
2044 // exist: the state Hunter's dogfood machine was bricked in (#5032).
2045 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
2046 fs::write(
2047 &config_path,
2048 format!(
2049 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
2050 ),
2051 )
2052 .unwrap();
2053 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
2054
2055 let config = Config {
2056 provider: Some(ApiProvider::Xai.as_str().to_string()),
2057 providers: Some(crate::config::ProvidersConfig {
2058 xai: crate::config::ProviderConfig {
2059 auth_mode: Some("oauth".to_string()),
2060 oauth_credential_generation: Some(stale.to_string()),
2061 ..Default::default()
2062 },
2063 ..Default::default()
2064 }),
2065 ..Default::default()
2066 };
2067
2068 assert!(
2069 owned_generation_is_dangling(&config),
2070 "OAuth mode pointing at a missing owned file is the #5032 bricked state"
2071 );
2072 // Specificity: OAuth selected but no generation configured is the normal
2073 // "needs auth" state, not a dangling pointer.
2074 let unconfigured = Config {
2075 provider: Some(ApiProvider::Xai.as_str().to_string()),
2076 providers: Some(crate::config::ProvidersConfig {
2077 xai: crate::config::ProviderConfig {
2078 auth_mode: Some("oauth".to_string()),
2079 ..Default::default()
2080 },
2081 ..Default::default()
2082 }),
2083 ..Default::default()
2084 };
2085 assert!(
2086 !owned_generation_is_dangling(&unconfigured),
2087 "an unconfigured OAuth mode must not be reported as dangling"
2088 );
2089
2090 clear_dangling_xai_oauth_generation(Some(&config_path))
2091 .expect("best-effort repair must clear the stale pointer");
2092
2093 let persisted = fs::read_to_string(&config_path).unwrap();
2094 assert!(
2095 !persisted.contains(stale),
2096 "stale generation pointer must be cleared: {persisted}"
2097 );
2098 assert!(
2099 persisted.contains("auth_mode = \"oauth\""),
2100 "the user's OAuth mode selection must be preserved: {persisted}"
2101 );
2102 }
2103
2104 #[test]
2105 fn activation_rejects_a_non_string_generation_pointer_without_staging_credentials() {
2106 let _guard = crate::test_support::lock_test_env();
2107 let dir = TempDir::new().unwrap();
2108 let home = dir
2109 .path()
2110 .canonicalize()
2111 .expect("canonical temp root")
2112 .join("owned-home");
2113 let config_path = dir.path().join("config.toml");
2114 let original = "[providers.xai]\noauth_credential_generation = { path = \"attacker\" }\n";
2115 fs::write(&config_path, original).unwrap();
2116 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
2117
2118 let error = activate_device_login(
2119 pending_login("must-not-stage", "must-not-persist"),
2120 Some(&config_path),
2121 None,
2122 )
2123 .expect_err("non-string generation pointers must fail closed");
2124 assert!(error.to_string().contains("not activated"), "{error:#}");
2125 assert_eq!(fs::read_to_string(&config_path).unwrap(), original);
2126 let credentials = home.join("credentials");
2127 assert!(credentials.exists(), "lifecycle lock directory is durable");
2128 assert!(fs::read_dir(credentials).unwrap().all(|entry| {
2129 let name = entry.unwrap().file_name();
2130 let name = name.to_string_lossy();
2131 name != codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
2132 && !codewhale_config::is_valid_xai_oauth_generation(&name)
2133 }));
2134 }
2135
2136 #[cfg(unix)]
2137 #[test]
2138 fn activation_failure_cleans_unreferenced_stage_and_keeps_live_config_inert() {
2139 let _guard = crate::test_support::lock_test_env();
2140 let dir = TempDir::new().unwrap();
2141 let home = dir
2142 .path()
2143 .canonicalize()
2144 .expect("canonical temp root")
2145 .join("owned-home");
2146 let config_dir = dir.path().join("config-parent");
2147 fs::create_dir(&config_dir).unwrap();
2148 let config_path = config_dir.join("config.toml");
2149 fs::write(&config_path, "[providers.xai]\nauth_mode = \"api_key\"\n").unwrap();
2150 fs::write(config_dir.join("config.toml.lock"), "").unwrap();
2151 fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o500)).unwrap();
2152 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
2153 let legacy_path = seed_legacy_owned_credentials();
2154 let legacy_before = fs::read(&legacy_path).unwrap();
2155 let mut live = Config {
2156 providers: Some(crate::config::ProvidersConfig {
2157 xai: crate::config::ProviderConfig {
2158 auth_mode: Some("api_key".to_string()),
2159 api_key: Some("still-selected".to_string()),
2160 ..Default::default()
2161 },
2162 ..Default::default()
2163 }),
2164 ..Default::default()
2165 };
2166
2167 let result = activate_device_login(
2168 pending_login("must-be-cleaned", "must-not-persist"),
2169 Some(&config_path),
2170 Some(&mut live),
2171 );
2172 fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700)).unwrap();
2173 let error = result.expect_err("read-only config directory must fail activation");
2174 assert!(error.to_string().contains("not activated"), "{error:#}");
2175 let live_xai = live.provider_config_for(ApiProvider::Xai).unwrap();
2176 assert_eq!(live_xai.auth_mode.as_deref(), Some("api_key"));
2177 assert!(live_xai.oauth_credential_generation.is_none());
2178 assert_eq!(
2179 fs::read(&legacy_path).unwrap(),
2180 legacy_before,
2181 "legacy owned credentials must remain byte-identical until activation commits"
2182 );
2183 let credentials = home.join("credentials");
2184 if credentials.exists() {
2185 assert!(
2186 fs::read_dir(credentials).unwrap().all(|entry| {
2187 let name = entry.unwrap().file_name();
2188 let name = name.to_string_lossy();
2189 name == codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
2190 || !codewhale_config::is_valid_xai_oauth_generation(&name)
2191 }),
2192 "failed activation must remove every unreferenced generation but retain legacy"
2193 );
2194 }
2195 assert!(
2196 !fs::read_to_string(config_path)
2197 .unwrap()
2198 .contains("must-be-cleaned")
2199 );
2200 }
2201
2202 #[test]
2203 fn missing_file_message_mentions_oauth_paths() {
2204 let _guard = crate::test_support::lock_test_env();
2205 let msg = missing_auth_message();
2206 assert!(msg.contains("xAI OAuth credentials not found"), "{msg}");
2207 assert!(msg.contains("external-consent"), "{msg}");
2208 assert!(msg.contains("Codewhale-owned OAuth storage"), "{msg}");
2209 assert!(msg.contains("XAI_API_KEY"), "{msg}");
2210 }
2211
2212 #[test]
2213 fn parse_rfc3339_accepts_zulu() {
2214 let ts = parse_rfc3339_secs("2026-07-09T12:00:00.000Z").expect("parse");
2215 assert!(ts > 0);
2216 }
2217
2218 #[test]
2219 fn device_code_constants_match_discovery_shape() {
2220 assert_eq!(
2221 DEFAULT_SCOPES.split_whitespace().collect::<Vec<_>>(),
2222 [
2223 "openid",
2224 "profile",
2225 "email",
2226 "offline_access",
2227 "api:access",
2228 "grok-cli:access",
2229 ]
2230 );
2231 assert_eq!(XAI_OIDC_ISSUER, "https://auth.x.ai");
2232 assert_eq!(GROK_OIDC_CLIENT_ID.len(), 36);
2233 // Keep device_code_login referenced so the residual entry point stays linked.
2234 let _ = device_code_login;
2235 }
2236
2237 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2238 async fn discovers_device_authorization_and_token_endpoints() {
2239 let server = MockServer::start().await;
2240 Mock::given(method("GET"))
2241 .and(path("/.well-known/openid-configuration"))
2242 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2243 "issuer": server.uri(),
2244 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2245 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2246 })))
2247 .expect(1)
2248 .mount(&server)
2249 .await;
2250
2251 let endpoints = tokio::task::block_in_place(|| {
2252 discover_device_oauth_endpoints(&server.uri()).expect("discover endpoints")
2253 });
2254
2255 assert_eq!(
2256 endpoints,
2257 DeviceOauthEndpoints {
2258 device_authorization_endpoint: format!("{}/oauth2/device-advertised", server.uri()),
2259 token_endpoint: format!("{}/oauth2/token-advertised", server.uri()),
2260 }
2261 );
2262 }
2263
2264 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2265 async fn device_login_discovery_request_is_safe_inside_tokio_runtime() {
2266 let server = MockServer::start().await;
2267 Mock::given(method("GET"))
2268 .and(path("/.well-known/openid-configuration"))
2269 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2270 "issuer": server.uri(),
2271 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2272 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2273 })))
2274 .expect(1)
2275 .mount(&server)
2276 .await;
2277 Mock::given(method("POST"))
2278 .and(path("/oauth2/device-advertised"))
2279 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2280 "error": "invalid_scope",
2281 "error_description": "mock refusal before browser or polling"
2282 })))
2283 .expect(1)
2284 .mount(&server)
2285 .await;
2286
2287 let error = device_code_login_on_blocking_thread(
2288 server.uri(),
2289 "test-public-client".to_string(),
2290 "openid".to_string(),
2291 false,
2292 )
2293 .await
2294 .expect_err("mock device request must fail without a runtime-drop panic");
2295 let message = format!("{error:#}");
2296
2297 assert!(message.contains("invalid_scope"), "{message}");
2298 assert!(message.contains("HTTP 400"), "{message}");
2299 }
2300
2301 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2302 async fn refresh_uses_discovered_token_endpoint() {
2303 let server = MockServer::start().await;
2304 Mock::given(method("GET"))
2305 .and(path("/.well-known/openid-configuration"))
2306 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2307 "issuer": server.uri(),
2308 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2309 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2310 })))
2311 .expect(1)
2312 .mount(&server)
2313 .await;
2314 Mock::given(method("POST"))
2315 .and(path("/oauth2/token-advertised"))
2316 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2317 "access_token": "refreshed-access",
2318 "refresh_token": "rotated-refresh",
2319 "expires_in": 3600
2320 })))
2321 .expect(1)
2322 .mount(&server)
2323 .await;
2324
2325 let token = tokio::task::block_in_place(|| {
2326 refresh_access_token(&server.uri(), GROK_OIDC_CLIENT_ID, "refresh-secret")
2327 .expect("refresh token")
2328 });
2329
2330 assert_eq!(token.access_token.as_deref(), Some("refreshed-access"));
2331 assert_eq!(token.refresh_token.as_deref(), Some("rotated-refresh"));
2332 }
2333
2334 #[test]
2335 fn https_discovery_rejects_plaintext_endpoint_downgrade() {
2336 let error = validate_discovered_oauth_endpoint(
2337 Some("http://auth.x.ai/oauth2/device/code".to_string()),
2338 "device_authorization_endpoint",
2339 XAI_OIDC_ISSUER,
2340 )
2341 .expect_err("HTTPS issuer must reject an HTTP endpoint");
2342
2343 assert!(error.to_string().contains("downgrade"), "{error}");
2344 }
2345
2346 #[test]
2347 fn https_discovery_accepts_same_origin_with_explicit_default_port() {
2348 let endpoint = "https://auth.x.ai:443/oauth2/token";
2349 let validated = validate_discovered_oauth_endpoint(
2350 Some(endpoint.to_string()),
2351 "token_endpoint",
2352 XAI_OIDC_ISSUER,
2353 )
2354 .expect("URL origins normalize the explicit default HTTPS port");
2355
2356 assert_eq!(validated, endpoint);
2357 }
2358
2359 #[test]
2360 fn https_discovery_rejects_cross_origin_endpoint() {
2361 let error = validate_discovered_oauth_endpoint(
2362 Some("https://oauth.attacker.example/oauth2/token".to_string()),
2363 "token_endpoint",
2364 XAI_OIDC_ISSUER,
2365 )
2366 .expect_err("discovered OAuth endpoints must stay on the issuer origin");
2367
2368 assert!(error.to_string().contains("different origin"), "{error}");
2369 }
2370
2371 #[test]
2372 fn discovery_rejects_mismatched_issuer() {
2373 let error = validate_discovered_issuer(
2374 Some("https://attacker.example".to_string()),
2375 XAI_OIDC_ISSUER,
2376 )
2377 .expect_err("discovery issuer must bind to the request issuer");
2378
2379 assert!(error.to_string().contains("does not match"), "{error}");
2380 }
2381
2382 #[test]
2383 fn oauth_error_details_collapse_control_whitespace() {
2384 let detail = oauth_failure_detail(
2385 Some("invalid_scope\nforged"),
2386 Some("bad\t scope\r\nnext line"),
2387 reqwest::StatusCode::BAD_REQUEST,
2388 );
2389
2390 assert!(
2391 !detail
2392 .chars()
2393 .any(|character| matches!(character, '\n' | '\r' | '\t')),
2394 "{detail}"
2395 );
2396 assert!(detail.contains("invalid_scope forged"), "{detail}");
2397 assert!(detail.contains("bad scope next line"), "{detail}");
2398 }
2399
2400 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2401 async fn discovery_failure_uses_documented_endpoint_fallback() {
2402 let server = MockServer::start().await;
2403 Mock::given(method("GET"))
2404 .and(path("/.well-known/openid-configuration"))
2405 .respond_with(
2406 ResponseTemplate::new(503)
2407 .set_body_raw("<html>temporarily unavailable</html>", "text/html"),
2408 )
2409 .expect(1)
2410 .mount(&server)
2411 .await;
2412
2413 let endpoints =
2414 tokio::task::block_in_place(|| resolve_device_oauth_endpoints(&server.uri()));
2415
2416 assert_eq!(
2417 endpoints,
2418 DeviceOauthEndpoints {
2419 device_authorization_endpoint: format!("{}/oauth2/device/code", server.uri()),
2420 token_endpoint: format!("{}/oauth2/token", server.uri()),
2421 }
2422 );
2423 }
2424
2425 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2426 async fn current_device_endpoint_surfaces_structured_invalid_scope() {
2427 let server = MockServer::start().await;
2428 Mock::given(method("POST"))
2429 .and(path("/oauth2/device/code"))
2430 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2431 "error": "invalid_scope",
2432 "error_description": "Scope 'team:read' is not valid for User principals"
2433 })))
2434 .expect(1)
2435 .mount(&server)
2436 .await;
2437
2438 let error = tokio::task::block_in_place(|| {
2439 request_device_code(
2440 &format!("{}/oauth2/device/code", server.uri()),
2441 GROK_OIDC_CLIENT_ID,
2442 "openid team:read",
2443 )
2444 .expect_err("invalid scope must fail")
2445 });
2446 let message = error.to_string();
2447
2448 assert!(message.contains("invalid_scope"), "{message}");
2449 assert!(message.contains("team:read"), "{message}");
2450 assert!(message.contains("HTTP 400"), "{message}");
2451 assert!(!message.contains("missing device_code"), "{message}");
2452 }
2453
2454 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2455 async fn device_code_request_reports_non_json_without_echoing_body() {
2456 let server = MockServer::start().await;
2457 Mock::given(method("POST"))
2458 .and(path("/oauth2/device"))
2459 .respond_with(
2460 ResponseTemplate::new(404)
2461 .set_body_raw("<html>private-upstream-detail</html>", "text/html"),
2462 )
2463 .expect(1)
2464 .mount(&server)
2465 .await;
2466
2467 let error = tokio::task::block_in_place(|| {
2468 request_device_code(
2469 &format!("{}/oauth2/device", server.uri()),
2470 GROK_OIDC_CLIENT_ID,
2471 DEFAULT_SCOPES,
2472 )
2473 .expect_err("non-JSON response must fail")
2474 });
2475 let message = error.to_string();
2476
2477 assert!(message.contains("HTTP 404"), "{message}");
2478 assert!(message.contains("text/html"), "{message}");
2479 assert!(message.contains("expected JSON"), "{message}");
2480 assert!(!message.contains("private-upstream-detail"), "{message}");
2481 }
2482
2483 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2484 async fn device_code_login_exchanges_and_persists_tokens() {
2485 let server = MockServer::start().await;
2486 Mock::given(method("GET"))
2487 .and(path("/.well-known/openid-configuration"))
2488 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2489 "issuer": server.uri(),
2490 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2491 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2492 })))
2493 .expect(1)
2494 .mount(&server)
2495 .await;
2496 Mock::given(method("POST"))
2497 .and(path("/oauth2/device-advertised"))
2498 .and(header("content-type", "application/x-www-form-urlencoded"))
2499 .and(body_string_contains(format!(
2500 "client_id={GROK_OIDC_CLIENT_ID}"
2501 )))
2502 .and(body_string_contains(
2503 "scope=openid+profile+email+offline_access+api%3Aaccess+grok-cli%3Aaccess",
2504 ))
2505 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2506 "device_code": "device-token",
2507 "user_code": "CW-TEST",
2508 "verification_uri": format!("{}/verify", server.uri()),
2509 "expires_in": 60,
2510 "interval": 1
2511 })))
2512 .expect(1)
2513 .mount(&server)
2514 .await;
2515 Mock::given(method("POST"))
2516 .and(path("/oauth2/token-advertised"))
2517 .and(header("content-type", "application/x-www-form-urlencoded"))
2518 .and(body_string_contains(
2519 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
2520 ))
2521 .and(body_string_contains(format!(
2522 "client_id={GROK_OIDC_CLIENT_ID}"
2523 )))
2524 .and(body_string_contains("device_code=device-token"))
2525 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2526 "access_token": "test-xai-access",
2527 "refresh_token": "test-xai-refresh",
2528 "expires_in": 3600,
2529 "token_type": "Bearer"
2530 })))
2531 .expect(1)
2532 .mount(&server)
2533 .await;
2534
2535 let result = tokio::task::block_in_place(|| {
2536 device_code_login_with(&server.uri(), GROK_OIDC_CLIENT_ID, DEFAULT_SCOPES, false)
2537 });
2538
2539 let pending = result.expect("device login");
2540 assert_eq!(
2541 pending.token.access_token.as_deref(),
2542 Some("test-xai-access")
2543 );
2544 assert_eq!(
2545 pending.token.refresh_token.as_deref(),
2546 Some("test-xai-refresh")
2547 );
2548 }
2549
2550 /// End-to-end regression for the v0.9.4 dogfood failure (#5032): starting
2551 /// from the exact state the dogfood machine was bricked in — a
2552 /// `providers.xai.oauth_credential_generation` pointer whose credential
2553 /// file no longer exists — the full device flow (discovery, device-code
2554 /// request, token poll, activation) must succeed and replace the stale
2555 /// pointer instead of dying with "xAI login was not activated; provider
2556 /// configuration is unchanged".
2557 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2558 async fn device_login_end_to_end_recovers_from_dangling_generation_pointer() {
2559 let _guard = crate::test_support::lock_test_env();
2560 let server = MockServer::start().await;
2561 Mock::given(method("GET"))
2562 .and(path("/.well-known/openid-configuration"))
2563 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2564 "issuer": server.uri(),
2565 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2566 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2567 })))
2568 .expect(1)
2569 .mount(&server)
2570 .await;
2571 Mock::given(method("POST"))
2572 .and(path("/oauth2/device-advertised"))
2573 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2574 "device_code": "device-token",
2575 "user_code": "CW-TEST",
2576 "verification_uri": format!("{}/verify", server.uri()),
2577 "expires_in": 60,
2578 "interval": 1
2579 })))
2580 .expect(1)
2581 .mount(&server)
2582 .await;
2583 Mock::given(method("POST"))
2584 .and(path("/oauth2/token-advertised"))
2585 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2586 "access_token": "e2e-xai-access",
2587 "refresh_token": "e2e-xai-refresh",
2588 "expires_in": 3600,
2589 "token_type": "Bearer"
2590 })))
2591 .expect(1)
2592 .mount(&server)
2593 .await;
2594
2595 let dir = TempDir::new().unwrap();
2596 let home = dir
2597 .path()
2598 .canonicalize()
2599 .expect("canonical temp root")
2600 .join("owned-home");
2601 let config_path = dir.path().join("config.toml");
2602 // The exact dogfood-machine state: valid-looking generation pointer,
2603 // missing credential file.
2604 let stale = "xai-auth-39a2f3e766ab47f89490002cd04fe187.json";
2605 fs::write(
2606 &config_path,
2607 format!(
2608 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
2609 ),
2610 )
2611 .unwrap();
2612 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
2613
2614 let pending = tokio::task::block_in_place(|| {
2615 device_code_login_with(&server.uri(), GROK_OIDC_CLIENT_ID, DEFAULT_SCOPES, false)
2616 })
2617 .expect("device login against mock xAI");
2618 let mut live = Config::default();
2619 let activation = activate_device_login(pending, Some(&config_path), Some(&mut live))
2620 .expect("dangling pointer must not brick activation");
2621
2622 assert!(activation.auth_path.exists());
2623 let owned = fs::read_to_string(&activation.auth_path).unwrap();
2624 assert!(owned.contains("e2e-xai-access"));
2625 assert!(owned.contains("e2e-xai-refresh"));
2626 let persisted = fs::read_to_string(&config_path).unwrap();
2627 assert!(
2628 !persisted.contains(stale),
2629 "stale pointer must be replaced: {persisted}"
2630 );
2631 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
2632 assert!(persisted.contains("auth_mode = \"oauth\""));
2633 assert!(credentials_valid(&live), "activated login must be usable");
2634 }
2635
2636 #[test]
2637 fn device_poll_backoff_follows_rfc8628() {
2638 // authorization_pending keeps the current interval.
2639 assert_eq!(device_poll_backoff(5, "authorization_pending"), Some(5));
2640 // slow_down increases the interval by 5 seconds (RFC 8628 §3.5).
2641 assert_eq!(
2642 device_poll_backoff(5, "slow_down"),
2643 Some(5 + DEVICE_SLOW_DOWN_STEP_SECS)
2644 );
2645 // Terminal errors stop polling.
2646 assert_eq!(device_poll_backoff(5, "access_denied"), None);
2647 assert_eq!(device_poll_backoff(5, "expired_token"), None);
2648 }
2649
2650 #[test]
2651 fn apply_token_response_sets_expiry_from_expires_in() {
2652 let mut entry = GrokAuthEntry {
2653 key: None,
2654 refresh_token: None,
2655 expires_at: None,
2656 oidc_issuer: None,
2657 oidc_client_id: None,
2658 auth_mode: None,
2659 extra: BTreeMap::new(),
2660 };
2661 let token = TokenResponse {
2662 access_token: Some("fresh-access".to_string()),
2663 refresh_token: Some("fresh-refresh".to_string()),
2664 expires_in: Some(3600),
2665 error: None,
2666 };
2667 let before = now_unix_secs().expect("clock");
2668
2669 apply_token_response(&mut entry, XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID, &token)
2670 .expect("apply token");
2671
2672 assert_eq!(entry.key.as_deref(), Some("fresh-access"));
2673 assert_eq!(entry.refresh_token.as_deref(), Some("fresh-refresh"));
2674 let expires_at = entry
2675 .expires_at
2676 .as_deref()
2677 .and_then(parse_rfc3339_secs)
2678 .expect("expires_at set from expires_in");
2679 let after = now_unix_secs().expect("clock");
2680 assert!(
2681 expires_at >= before + 3600,
2682 "{expires_at} < {before} + 3600"
2683 );
2684 assert!(expires_at <= after + 3600, "{expires_at} > {after} + 3600");
2685 }
2686
2687 #[test]
2688 fn apply_token_response_rejects_missing_access_token() {
2689 let mut entry = GrokAuthEntry {
2690 key: None,
2691 refresh_token: None,
2692 expires_at: None,
2693 oidc_issuer: None,
2694 oidc_client_id: None,
2695 auth_mode: None,
2696 extra: BTreeMap::new(),
2697 };
2698 let token = TokenResponse {
2699 access_token: None,
2700 refresh_token: None,
2701 expires_in: None,
2702 error: None,
2703 };
2704
2705 let error = apply_token_response(&mut entry, XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID, &token)
2706 .expect_err("missing access_token must fail");
2707
2708 assert!(
2709 error.to_string().contains("missing access_token"),
2710 "{error}"
2711 );
2712 }
2713
2714 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2715 async fn device_code_login_polls_through_pending_and_slow_down() {
2716 let server = MockServer::start().await;
2717 Mock::given(method("GET"))
2718 .and(path("/.well-known/openid-configuration"))
2719 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2720 "issuer": server.uri(),
2721 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2722 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2723 })))
2724 .expect(1)
2725 .mount(&server)
2726 .await;
2727 Mock::given(method("POST"))
2728 .and(path("/oauth2/device-advertised"))
2729 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2730 "device_code": "device-token",
2731 "user_code": "CW-TEST",
2732 "verification_uri": format!("{}/verify", server.uri()),
2733 "expires_in": 60,
2734 "interval": 1
2735 })))
2736 .expect(1)
2737 .mount(&server)
2738 .await;
2739 // wiremock matches mocks in mount order, so mount the one-shot
2740 // transient-error responses before the terminal success response:
2741 // poll 1 -> authorization_pending, poll 2 -> slow_down, poll 3 -> ok.
2742 Mock::given(method("POST"))
2743 .and(path("/oauth2/token-advertised"))
2744 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2745 "error": "authorization_pending"
2746 })))
2747 .up_to_n_times(1)
2748 .expect(1)
2749 .mount(&server)
2750 .await;
2751 Mock::given(method("POST"))
2752 .and(path("/oauth2/token-advertised"))
2753 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2754 "error": "slow_down"
2755 })))
2756 .up_to_n_times(1)
2757 .expect(1)
2758 .mount(&server)
2759 .await;
2760 Mock::given(method("POST"))
2761 .and(path("/oauth2/token-advertised"))
2762 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2763 "access_token": "test-xai-access",
2764 "refresh_token": "test-xai-refresh",
2765 "expires_in": 3600
2766 })))
2767 .expect(1)
2768 .mount(&server)
2769 .await;
2770
2771 let result = tokio::task::block_in_place(|| {
2772 device_code_login_with(&server.uri(), GROK_OIDC_CLIENT_ID, DEFAULT_SCOPES, false)
2773 });
2774
2775 let pending = result.expect("device login after pending and slow_down");
2776 assert_eq!(
2777 pending.token.access_token.as_deref(),
2778 Some("test-xai-access")
2779 );
2780 }
2781
2782 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2783 async fn device_code_login_surfaces_user_denial() {
2784 let server = MockServer::start().await;
2785 Mock::given(method("GET"))
2786 .and(path("/.well-known/openid-configuration"))
2787 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2788 "issuer": server.uri(),
2789 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
2790 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
2791 })))
2792 .expect(1)
2793 .mount(&server)
2794 .await;
2795 Mock::given(method("POST"))
2796 .and(path("/oauth2/device-advertised"))
2797 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2798 "device_code": "device-token",
2799 "user_code": "CW-TEST",
2800 "verification_uri": format!("{}/verify", server.uri()),
2801 "expires_in": 60,
2802 "interval": 1
2803 })))
2804 .expect(1)
2805 .mount(&server)
2806 .await;
2807 Mock::given(method("POST"))
2808 .and(path("/oauth2/token-advertised"))
2809 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2810 "error": "access_denied",
2811 "error_description": "The user denied the authorization request"
2812 })))
2813 .expect(1)
2814 .mount(&server)
2815 .await;
2816
2817 let result = tokio::task::block_in_place(|| {
2818 device_code_login_with(&server.uri(), GROK_OIDC_CLIENT_ID, DEFAULT_SCOPES, false)
2819 });
2820
2821 let error = result.expect_err("user denial must stop polling");
2822 let message = format!("{error:#}");
2823 assert!(message.contains("access_denied"), "{message}");
2824 assert!(message.contains("HTTP 400"), "{message}");
2825 assert!(!message.contains("authorization_pending"), "{message}");
2826 }
2827
2828 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2829 async fn poll_device_token_surfaces_expired_token() {
2830 let server = MockServer::start().await;
2831 Mock::given(method("POST"))
2832 .and(path("/oauth2/token"))
2833 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
2834 "error": "expired_token",
2835 "error_description": "The device code has expired"
2836 })))
2837 .expect(1)
2838 .mount(&server)
2839 .await;
2840
2841 let error = tokio::task::block_in_place(|| {
2842 poll_device_token(
2843 &format!("{}/oauth2/token", server.uri()),
2844 GROK_OIDC_CLIENT_ID,
2845 "device-token",
2846 )
2847 .expect_err("expired device code must fail")
2848 });
2849 let message = error.to_string();
2850
2851 assert!(message.contains("expired_token"), "{message}");
2852 assert!(message.contains("HTTP 400"), "{message}");
2853 }
2854
2855 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2856 async fn poll_device_token_reports_non_json_without_raw_parse_failure() {
2857 let server = MockServer::start().await;
2858 Mock::given(method("POST"))
2859 .and(path("/oauth2/token"))
2860 .respond_with(
2861 ResponseTemplate::new(503)
2862 .set_body_raw("<html>upstream-maintenance-detail</html>", "text/html"),
2863 )
2864 .expect(1)
2865 .mount(&server)
2866 .await;
2867
2868 let error = tokio::task::block_in_place(|| {
2869 poll_device_token(
2870 &format!("{}/oauth2/token", server.uri()),
2871 GROK_OIDC_CLIENT_ID,
2872 "device-token",
2873 )
2874 .expect_err("non-JSON response must fail")
2875 });
2876 let message = error.to_string();
2877
2878 assert!(message.contains("HTTP 503"), "{message}");
2879 assert!(message.contains("text/html"), "{message}");
2880 assert!(message.contains("expected JSON"), "{message}");
2881 assert!(
2882 !message.contains("upstream-maintenance-detail"),
2883 "{message}"
2884 );
2885 }
2886 }
2887
2887 lines RUST