返回 CodeWhale
oauth.rs
根目录 / crates / tui / src / oauth.rs
1 //! The ONE access-route flow: Codex CLI import (read-only, consented) plus
2 //! the unified OAuth login core every subscription provider runs through.
3 //! Providers are data rows in the parameter table below, not modules.
4 //!
5 //! External Codex CLI credentials are read only after an exact, provider-scoped
6 //! consent grant. Codewhale never refreshes or rewrites that external file.
7 //!
8 //! # Security
9 //!
10 //! Token values are never logged or printed. All debug representations
11 //! redact sensitive fields.
12
13 use std::collections::BTreeMap;
14 use std::io::{Read, Write as _};
15 use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
16 use std::path::{Path, PathBuf};
17 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19 use anyhow::{Context, Result, bail};
20 use base64::Engine as _;
21 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
22 use codewhale_config::ExternalCredentialReadGrant;
23 use serde::{Deserialize, Serialize};
24 use serde_json::Value;
25 use sha2::{Digest, Sha256};
26
27 use crate::config::Config;
28
29 /// OAuth token payload stored in `auth.json`.
30 #[derive(Debug, Clone, Deserialize)]
31 #[serde(rename_all = "snake_case")]
32 struct AuthTokens {
33 access_token: Option<String>,
34 account_id: Option<String>,
35 }
36
37 /// Top-level structure of Codex CLI's `auth.json`.
38 #[derive(Debug, Clone, Deserialize)]
39 #[serde(rename_all = "snake_case")]
40 struct CodexAuthFile {
41 tokens: Option<AuthTokens>,
42 }
43
44 /// Resolved OAuth credentials ready for API use.
45 #[derive(Debug, Clone)]
46 pub struct CodexCredentials {
47 pub access_token: String,
48 pub account_id: Option<String>,
49 }
50
51 /// JWT claims subset for expiry extraction.
52 #[derive(Debug, Deserialize)]
53 struct JwtClaims {
54 exp: Option<u64>,
55 }
56
57 /// Resolve the path to the Codex auth file.
58 ///
59 /// Priority:
60 /// 1. `OPENAI_CODEX_AUTH_FILE` env var
61 /// 2. `$CODEX_HOME/auth.json`
62 /// 3. `~/.codex/auth.json`
63 pub fn auth_file_path() -> PathBuf {
64 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
65 let p = PathBuf::from(&path);
66 if !p.as_os_str().is_empty() {
67 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
68 }
69 }
70 let codex_home = std::env::var("CODEX_HOME")
71 .map(PathBuf::from)
72 .unwrap_or_else(|_| {
73 crate::config::effective_home_dir()
74 .unwrap_or_else(|| PathBuf::from("."))
75 .join(".codex")
76 });
77 let path = codex_home.join("auth.json");
78 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
79 }
80
81 /// Try to extract `exp` (epoch seconds) from a JWT without verifying
82 /// the signature. Returns `None` on any parse failure.
83 fn jwt_expiry_seconds(token: &str) -> Option<u64> {
84 let parts: Vec<&str> = token.split('.').collect();
85 if parts.len() < 2 {
86 return None;
87 }
88 let payload = parts[1];
89 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
90 let claims: JwtClaims = serde_json::from_slice(&decoded).ok()?;
91 claims.exp
92 }
93
94 /// Check whether an access token is expired, with a 60-second safety margin.
95 fn token_is_expired(access_token: &str) -> bool {
96 match jwt_expiry_seconds(access_token) {
97 Some(exp) => {
98 let now = SystemTime::now()
99 .duration_since(UNIX_EPOCH)
100 .unwrap_or(Duration::ZERO)
101 .as_secs();
102 // 60-second safety margin
103 now + 60 >= exp
104 }
105 // If we can't prove freshness, fail closed. External credentials are
106 // never refreshed by Codewhale.
107 None => true,
108 }
109 }
110
111 /// Load Codex credentials from the auth file.
112 ///
113 /// Returns `Ok(None)` if the file doesn't exist or has no usable tokens.
114 /// Returns `Err` only on parse/IO errors that aren't "file not found".
115 fn load_credentials(grant: &ExternalCredentialReadGrant) -> Result<Option<CodexCredentials>> {
116 let Some(contents) = crate::external_credentials::read_to_string(grant)? else {
117 return Ok(None);
118 };
119 let auth: CodexAuthFile = serde_json::from_str(&contents).map_err(|_| {
120 anyhow::anyhow!(
121 "Codex credential file {} is not valid credential JSON",
122 codewhale_config::quote_os_path(grant.path())
123 )
124 })?;
125 let tokens = match auth.tokens {
126 Some(t) => t,
127 None => return Ok(None),
128 };
129 let access_token = match tokens.access_token {
130 Some(t) if !t.trim().is_empty() => t,
131 _ => return Ok(None),
132 };
133 Ok(Some(CodexCredentials {
134 access_token,
135 account_id: tokens.account_id,
136 }))
137 }
138
139 /// Prompt-free, non-refreshing readiness check for picker/onboarding surfaces.
140 /// It reads process-level token variables only; no file or network access occurs.
141 #[must_use]
142 pub fn credentials_from_env() -> Option<CodexCredentials> {
143 ["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
144 .iter()
145 .find_map(|name| {
146 std::env::var(name)
147 .ok()
148 .filter(|token| !token.trim().is_empty())
149 })
150 .map(|access_token| CodexCredentials {
151 access_token,
152 account_id: codex_account_id_env(),
153 })
154 }
155
156 /// Validate only the stored OAuth file, excluding token environment
157 /// overrides so config-vs-env provenance remains truthful.
158 ///
159 /// This consumes a grant, so it can only run for a path the user explicitly
160 /// consented to. Consent is not a credential (#5772): status surfaces call
161 /// this to find out whether the consented file *still* holds a usable token,
162 /// because a record that outlives its token would otherwise read as stored.
163 #[must_use]
164 pub fn stored_credentials_present(grant: &ExternalCredentialReadGrant) -> bool {
165 load_credentials(grant)
166 .ok()
167 .flatten()
168 .is_some_and(|credentials| !token_is_expired(&credentials.access_token))
169 }
170
171 /// Load read-only credentials from the exact external path authorized by
172 /// `grant`. Expired tokens fail with guidance; they are never refreshed.
173 pub fn get_credentials(grant: &ExternalCredentialReadGrant) -> Result<CodexCredentials> {
174 let creds =
175 load_credentials(grant)?.with_context(|| missing_auth_message(OAuthProvider::Chatgpt))?;
176
177 // Check if the access token is still valid.
178 if !token_is_expired(&creds.access_token) {
179 return Ok(creds);
180 }
181
182 bail!(
183 "Codex access token in {} is expired. Read-only consent never refreshes or rewrites another CLI's credentials. Sign in with ChatGPT via `codewhale auth chatgpt`, run `codex login` again, or provide OPENAI_CODEX_ACCESS_TOKEN for this process.",
184 codewhale_config::quote_os_path(grant.path())
185 )
186 }
187
188 /// Read a ChatGPT account id from env overrides only.
189 fn codex_account_id_env() -> Option<String> {
190 for var in ["OPENAI_CODEX_ACCOUNT_ID", "CODEX_ACCOUNT_ID"] {
191 if let Ok(value) = std::env::var(var) {
192 let trimmed = value.trim();
193 if !trimmed.is_empty() {
194 return Some(trimmed.to_string());
195 }
196 }
197 }
198 None
199 }
200
201 // ── ONE access-route flow ─────────────────────────────────────────────
202 // Providers are DATA, not files. Every subscription login — xAI device
203 // code today, ChatGPT PKCE next — runs through the parameter table below
204 // and the shared device-code core; per-provider OAuth modules are deleted,
205 // not repaired.
206
207 /// How this run reaches a provider: an OAuth login Codewhale owns, a
208 /// read-only import from another CLI, a pasted key, or (reserved) an ACP
209 /// subscription bridge. The bridge arm lands with the ACP work; until then
210 /// it resolves to a clear error, never a silent fallback.
211 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
212 #[allow(
213 dead_code,
214 reason = "3b-ii wires the access-route dispatch that consumes this"
215 )]
216 pub enum AccessMethod {
217 /// Browser/device OAuth login whose tokens Codewhale stores and refreshes.
218 OwnedOAuth(OAuthProvider),
219 /// Read-only credentials owned by another CLI, behind a consent grant.
220 ExternalImport(ExternalImportSource),
221 /// A pasted API key. No login, no refresh, no storage beyond config.
222 ApiKey,
223 /// Subscription access through an ACP bridge (Antigravity, Copilot).
224 /// Reserved: no producer yet.
225 AcpBridge,
226 }
227
228 /// Providers with an OAuth login Codewhale can own.
229 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
230 pub enum OAuthProvider {
231 Xai,
232 /// No device-code producer yet; the PKCE path (3b-i(b)) constructs this.
233 #[allow(
234 dead_code,
235 reason = "3b-i(b) wires the PKCE login that constructs this"
236 )]
237 Chatgpt,
238 }
239
240 impl AccessMethod {
241 /// Short user-facing name for picker and status surfaces.
242 #[must_use]
243 #[allow(
244 dead_code,
245 reason = "3b-ii wires the access-route dispatch that consumes this"
246 )]
247 pub fn label(&self) -> &'static str {
248 match self {
249 AccessMethod::OwnedOAuth(OAuthProvider::Xai) => "xAI subscription",
250 AccessMethod::OwnedOAuth(OAuthProvider::Chatgpt) => "ChatGPT subscription",
251 AccessMethod::ExternalImport(ExternalImportSource::GrokCli) => "Grok CLI import",
252 AccessMethod::ExternalImport(ExternalImportSource::CodexCli) => "Codex CLI import",
253 AccessMethod::ExternalImport(ExternalImportSource::Antigravity) => "Antigravity import",
254 AccessMethod::ExternalImport(ExternalImportSource::Dsh) => "DSH import",
255 AccessMethod::ApiKey => "API key",
256 AccessMethod::AcpBridge => "ACP bridge",
257 }
258 }
259 }
260
261 /// Another CLI whose credentials can be imported read-only.
262 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
263 #[allow(dead_code, reason = "3b-ii wires the import table that consumes this")]
264 pub enum ExternalImportSource {
265 /// `~/.grok/auth.json` / `XAI_AUTH_PATH`, keyed by issuer::client-id.
266 GrokCli,
267 /// `~/.codex/auth.json`, `{tokens:{access_token, account_id}}`.
268 CodexCli,
269 /// Antigravity `state.vscdb` SQLite row, opened read-only.
270 Antigravity,
271 /// `$DSH_HOME/.credentials.yaml` flat mapping, `DEEPSEEK_API_KEY`.
272 Dsh,
273 }
274
275 /// Test/dev knobs a provider reads from the environment. Data, so a new
276 /// provider adds rows here instead of a new module.
277 pub struct OAuthEnvOverrides {
278 pub issuer_vars: &'static [&'static str],
279 pub client_id_vars: &'static [&'static str],
280 pub scope_vars: &'static [&'static str],
281 pub no_browser_var: &'static str,
282 }
283
284 /// Everything about one provider's OAuth login that is not logic.
285 pub struct OAuthProviderParams {
286 /// Human name for prompts and errors: "xAI", "ChatGPT".
287 pub display_name: &'static str,
288 pub default_issuer: &'static str,
289 pub default_client_id: &'static str,
290 pub default_scopes: &'static str,
291 pub env: OAuthEnvOverrides,
292 /// `Some` device-authorization path under the issuer (xAI); `None`
293 /// means the issuer offers no device flow and device login must fail
294 /// loudly instead of guessing (ChatGPT).
295 pub device_code_path: Option<&'static str>,
296 /// `Some` browser authorization path under the issuer (ChatGPT PKCE);
297 /// `None` means the issuer offers no browser flow and browser login
298 /// fails the same loud way (xAI is device-code only).
299 pub authorize_path: Option<&'static str>,
300 /// Token path under the issuer.
301 pub token_path: &'static str,
302 /// Whether the issuer was discovered (xAI) or pinned (ChatGPT paths).
303 pub discover_endpoints: bool,
304 /// Seconds the device-code poll runs past the server's `expires_in`.
305 pub device_poll_floor_secs: u64,
306 /// Extra authorize-endpoint parameters beyond the standard OAuth set,
307 /// sent verbatim so the issuer sees exactly who is calling.
308 pub authorize_extras: &'static [(&'static str, &'static str)],
309 /// Honest client identity for issuers that require one (ChatGPT's
310 /// `originator`). Never impersonate another CLI.
311 pub originator: Option<&'static str>,
312 /// Remote revoke path under the issuer, pinned rather than discovered:
313 /// revoke must still clear local credentials when the issuer is
314 /// unreachable, so a discovery fetch would only add a failure mode to a
315 /// path whose contract is to clean up regardless. `None` when
316 /// revocation is purely local (xAI).
317 pub revoke_path: Option<&'static str>,
318 /// Registered loopback redirect for browser flows.
319 pub callback_path: &'static str,
320 /// Loopback ports the public client registered, in preference order.
321 pub loopback_ports: &'static [u16],
322 /// The command that re-runs this provider's login, for error guidance.
323 pub relogin_hint: &'static str,
324 /// What to tell the user when every callback port is taken.
325 pub callback_conflict_hint: &'static str,
326 }
327
328 pub const XAI_OAUTH_PARAMS: OAuthProviderParams = OAuthProviderParams {
329 display_name: "xAI",
330 // Single source: the legacy module still owns these strings until its
331 // activation path unifies and they move here in 3b-iii.
332 default_issuer: XAI_OIDC_ISSUER,
333 default_client_id: GROK_OIDC_CLIENT_ID,
334 default_scopes: DEFAULT_SCOPES,
335 env: OAuthEnvOverrides {
336 issuer_vars: &["GROK_OIDC_ISSUER", "XAI_OIDC_ISSUER"],
337 client_id_vars: &["GROK_OIDC_CLIENT_ID", "XAI_OIDC_CLIENT_ID"],
338 scope_vars: &["GROK_OIDC_SCOPES", "XAI_OIDC_SCOPES"],
339 no_browser_var: "CODEWHALE_XAI_OAUTH_NO_BROWSER",
340 },
341 device_code_path: Some("oauth2/device/code"),
342 authorize_path: None,
343 token_path: "oauth2/token",
344 discover_endpoints: true,
345 device_poll_floor_secs: 30,
346 authorize_extras: &[],
347 originator: None,
348 revoke_path: None,
349 callback_path: "",
350 loopback_ports: &[],
351 relogin_hint: "codewhale auth xai-device",
352 callback_conflict_hint: "",
353 };
354
355 pub const CHATGPT_OAUTH_PARAMS: OAuthProviderParams = OAuthProviderParams {
356 display_name: "ChatGPT",
357 // Single source: same arrangement as the xAI row above.
358 default_issuer: CHATGPT_OAUTH_ISSUER,
359 default_client_id: CHATGPT_OAUTH_CLIENT_ID,
360 default_scopes: CHATGPT_OAUTH_SCOPE,
361 originator: Some(CHATGPT_OAUTH_ORIGINATOR),
362 env: OAuthEnvOverrides {
363 issuer_vars: &["CODEWHALE_CHATGPT_OAUTH_ISSUER"],
364 client_id_vars: &["CODEWHALE_CHATGPT_OAUTH_CLIENT_ID"],
365 scope_vars: &[],
366 no_browser_var: "CODEWHALE_CHATGPT_OAUTH_NO_BROWSER",
367 },
368 device_code_path: None,
369 authorize_path: Some("oauth/authorize"),
370 token_path: "oauth/token",
371 discover_endpoints: false,
372 device_poll_floor_secs: 30,
373 authorize_extras: &[("id_token_add_organizations", "true")],
374 revoke_path: Some("api/accounts/oauth/revoke"),
375 callback_path: "/auth/callback",
376 loopback_ports: &[1455, 1457],
377 relogin_hint: "codewhale auth chatgpt",
378 callback_conflict_hint: "Stop the process holding that port, or import Codex CLI credentials with `codewhale auth external-consent`.",
379 };
380
381 /// The parameter table. A provider login looks its row up here; adding a
382 /// provider means adding a row, never a module.
383 #[must_use]
384 pub fn oauth_provider_params(provider: OAuthProvider) -> &'static OAuthProviderParams {
385 match provider {
386 OAuthProvider::Xai => &XAI_OAUTH_PARAMS,
387 OAuthProvider::Chatgpt => &CHATGPT_OAUTH_PARAMS,
388 }
389 }
390
391 /// Resolved login inputs: schema defaults, environment-tested in order.
392 pub struct ResolvedOAuthInputs {
393 pub issuer: String,
394 pub client_id: String,
395 pub scopes: String,
396 pub open_browser: bool,
397 }
398
399 impl OAuthProviderParams {
400 /// Resolve issuer/client/scopes from the environment, first var wins.
401 #[must_use]
402 pub fn resolve_inputs(&self) -> ResolvedOAuthInputs {
403 let first_set = |vars: &[&str], fallback: &str| {
404 vars.iter()
405 .filter_map(|var| std::env::var(var).ok())
406 .find(|value| !value.trim().is_empty())
407 .unwrap_or_else(|| fallback.to_string())
408 };
409 ResolvedOAuthInputs {
410 issuer: first_set(self.env.issuer_vars, self.default_issuer),
411 client_id: first_set(self.env.client_id_vars, self.default_client_id),
412 scopes: first_set(self.env.scope_vars, self.default_scopes),
413 open_browser: std::env::var_os(self.env.no_browser_var).is_none(),
414 }
415 }
416 }
417
418 /// Token material from a completed grant. No Debug: the tokens never print.
419 /// `interval` rides along because a `slow_down` error response may carry
420 /// the server's new minimum, which the loop prefers over its own tracked
421 /// value (RFC 8628 §3.5; WSL/VM clock drift).
422 #[derive(Clone, Deserialize)]
423 pub struct OAuthTokenMaterial {
424 pub access_token: Option<String>,
425 pub refresh_token: Option<String>,
426 pub expires_in: Option<u64>,
427 /// OpenID Connect id token; carries the account claim when issued.
428 #[serde(default)]
429 pub id_token: Option<String>,
430 #[serde(default)]
431 pub interval: Option<u64>,
432 #[serde(default)]
433 pub error: Option<String>,
434 #[serde(default)]
435 pub error_description: Option<String>,
436 }
437
438 /// A completed login awaiting activation (owned-generation commit).
439 /// The provider is the table row it resolved through; unified activation
440 /// (3b-ii) matches on it.
441 pub struct PendingOAuthLogin {
442 #[allow(dead_code, reason = "3b-ii unified activation matches on this")]
443 pub provider: OAuthProvider,
444 pub issuer: String,
445 pub client_id: String,
446 pub token: OAuthTokenMaterial,
447 }
448
449 /// Device-authorization response. Error fields ride along so a refused
450 /// grant classifies instead of failing to parse.
451 #[derive(Clone, Deserialize)]
452 struct DeviceGrantResponse {
453 device_code: Option<String>,
454 user_code: Option<String>,
455 verification_uri: Option<String>,
456 verification_uri_complete: Option<String>,
457 expires_in: Option<u64>,
458 interval: Option<u64>,
459 #[serde(default)]
460 error: Option<String>,
461 #[serde(default)]
462 error_description: Option<String>,
463 }
464
465 /// Bounds copied with the behavior: 20 s requests, 64 KiB bodies, 256 B of
466 /// error detail. A server that will not fit in the budget is an error, and
467 /// error text is whitespace-collapsed so a hostile endpoint cannot smuggle
468 /// terminal controls into diagnostics.
469 const OAUTH_REQUEST_TIMEOUT_SECS: u64 = 20;
470 const OAUTH_RESPONSE_BODY_LIMIT: u64 = 64 * 1024;
471 const OAUTH_ERROR_DETAIL_LIMIT: usize = 256;
472
473 /// Apply the existing OAuth browser URI policy to the URL that reqwest will
474 /// actually use. Parsing first keeps transport and loopback interpretation
475 /// identical; an issuer override does not authorize remote plaintext forms.
476 fn oauth_endpoint_url(raw: &str) -> Result<reqwest::Url> {
477 let url = reqwest::Url::parse(raw).context("OAuth endpoint is not a valid URL")?;
478 codewhale_config::device_code::validate_browser_verification_uri(
479 url.as_str(),
480 "OAuth endpoint",
481 )
482 .context("OAuth endpoints require HTTPS, except for local loopback HTTP")?;
483 Ok(url)
484 }
485
486 fn oauth_http_client(purpose: &str) -> Result<reqwest::blocking::Client> {
487 crate::tls::reqwest_blocking_client_builder()
488 // An issuer-approved endpoint cannot delegate credential-bearing forms
489 // to a redirect destination, including HTTPS-to-HTTP downgrades.
490 .redirect(reqwest::redirect::Policy::none())
491 .timeout(Duration::from_secs(OAUTH_REQUEST_TIMEOUT_SECS))
492 .build()
493 .with_context(|| format!("Failed to build OAuth {purpose} client"))
494 }
495
496 fn parse_oauth_json<T: serde::de::DeserializeOwned>(
497 response: reqwest::blocking::Response,
498 operation: &str,
499 ) -> Result<(reqwest::StatusCode, T)> {
500 let status = response.status();
501 // Join every content-type value: some test doubles stack a second one
502 // next to the body's implicit type, and the diagnostic must name what
503 // the server actually sent, not whichever header won the map lookup.
504 let content_type = {
505 let joined = response
506 .headers()
507 .get_all(reqwest::header::CONTENT_TYPE)
508 .iter()
509 .filter_map(|value| value.to_str().ok())
510 .collect::<Vec<_>>()
511 .join(", ");
512 if joined.is_empty() {
513 "missing".to_string()
514 } else {
515 joined
516 }
517 };
518 let mut reader = response.take(OAUTH_RESPONSE_BODY_LIMIT + 1);
519 let mut body = Vec::new();
520 reader
521 .read_to_end(&mut body)
522 .with_context(|| format!("reading {operation} response"))?;
523 let truncated = body.len() as u64 > OAUTH_RESPONSE_BODY_LIMIT;
524 if truncated {
525 body.truncate(OAUTH_RESPONSE_BODY_LIMIT as usize);
526 }
527 let parsed = serde_json::from_slice(&body).map_err(|_| {
528 let limit = if truncated {
529 " (body exceeded the 64 KiB diagnostic limit)"
530 } else {
531 ""
532 };
533 anyhow::anyhow!(
534 "{operation} returned HTTP {status} with content type {content_type}; expected JSON{limit}"
535 )
536 })?;
537 Ok((status, parsed))
538 }
539
540 fn bounded_oauth_error_text(raw: &str) -> String {
541 let mut output = String::with_capacity(raw.len().min(OAUTH_ERROR_DETAIL_LIMIT));
542 let mut previous_was_space = false;
543 let mut written = 0;
544 for character in raw.chars() {
545 let character = if character.is_whitespace() {
546 ' '
547 } else if character.is_control() {
548 continue;
549 } else {
550 character
551 };
552 if character == ' ' && previous_was_space {
553 continue;
554 }
555 if written == OAUTH_ERROR_DETAIL_LIMIT {
556 break;
557 }
558 output.push(character);
559 previous_was_space = character == ' ';
560 written += 1;
561 }
562 output.trim().to_string()
563 }
564
565 fn oauth_failure_detail(
566 error: Option<&str>,
567 description: Option<&str>,
568 status: reqwest::StatusCode,
569 ) -> String {
570 let mut code = bounded_oauth_error_text(error.unwrap_or("request_failed"));
571 if code.is_empty() {
572 code = "request_failed".to_string();
573 }
574 let description = description
575 .map(bounded_oauth_error_text)
576 .filter(|description| !description.is_empty() && description != &code);
577 match description {
578 Some(description) => format!("{code}: {description}; HTTP {status}"),
579 None => format!("{code}; HTTP {status}"),
580 }
581 }
582
583 /// Resolved token + device endpoints for one login.
584 #[derive(Clone, Debug, PartialEq, Eq)]
585 struct OAuthEndpoints {
586 device_authorization_endpoint: Option<String>,
587 token_endpoint: String,
588 }
589
590 /// OIDC discovery document. Only the fields a login needs.
591 #[derive(Clone, Deserialize)]
592 struct OidcDiscoveryDocument {
593 issuer: Option<String>,
594 device_authorization_endpoint: Option<String>,
595 token_endpoint: Option<String>,
596 }
597
598 /// Resolve endpoints: OIDC discovery when the provider row asks for it,
599 /// documented-path fallback otherwise — and fallback on ANY discovery
600 /// failure, loudly logged. A hostile or broken discovery document must
601 /// never brick login; it only loses the custom endpoints.
602 fn resolve_oauth_endpoints(params: &OAuthProviderParams, issuer: &str) -> OAuthEndpoints {
603 let fallback = || fallback_oauth_endpoints(params, issuer);
604 if !params.discover_endpoints {
605 return fallback();
606 }
607 match discover_oauth_endpoints(params, issuer) {
608 Ok(endpoints) => endpoints,
609 Err(err) => {
610 tracing::warn!(
611 target: "codewhale::oauth",
612 error = %err,
613 "{} OIDC discovery failed; using documented endpoint fallback",
614 params.display_name
615 );
616 fallback()
617 }
618 }
619 }
620
621 fn discover_oauth_endpoints(params: &OAuthProviderParams, issuer: &str) -> Result<OAuthEndpoints> {
622 let name = params.display_name;
623 let discovery_url = oauth_endpoint_url(&format!(
624 "{}/.well-known/openid-configuration",
625 issuer.trim_end_matches('/')
626 ))?;
627 let client = oauth_http_client("OIDC discovery")?;
628 #[cfg(test)]
629 crate::external_credentials::record_oauth_network();
630 let response = client
631 .get(discovery_url)
632 .header(reqwest::header::ACCEPT, "application/json")
633 .send()
634 .with_context(|| format!("{name} OIDC discovery request failed"))?;
635 let (status, discovery): (_, OidcDiscoveryDocument) =
636 parse_oauth_json(response, &format!("{name} OIDC discovery"))?;
637 if !status.is_success() {
638 bail!("{name} OIDC discovery failed with HTTP {status}");
639 }
640 validate_discovered_issuer(discovery.issuer, issuer)
641 .with_context(|| format!("{name} OIDC discovery"))?;
642 Ok(OAuthEndpoints {
643 device_authorization_endpoint: params
644 .device_code_path
645 .map(|_| {
646 validate_discovered_oauth_endpoint(
647 discovery.device_authorization_endpoint,
648 "device_authorization_endpoint",
649 issuer,
650 )
651 })
652 .transpose()?,
653 token_endpoint: validate_discovered_oauth_endpoint(
654 discovery.token_endpoint,
655 "token_endpoint",
656 issuer,
657 )?,
658 })
659 }
660
661 /// Validate that an OIDC discovery document's issuer matches the requested issuer.
662 fn validate_discovered_issuer(discovered: Option<String>, expected: &str) -> Result<()> {
663 let discovered = discovered
664 .as_deref()
665 .map(str::trim)
666 .filter(|issuer| !issuer.is_empty())
667 .context("OIDC discovery missing issuer")?;
668 if discovered.trim_end_matches('/') != expected.trim_end_matches('/') {
669 bail!("OIDC discovery issuer does not match the requested issuer");
670 }
671 let _ = oauth_endpoint_url(expected).context("OIDC issuer is not a trusted URL")?;
672 Ok(())
673 }
674
675 /// Validate one discovered endpoint against the issuer: https-or-http scheme,
676 /// no plaintext downgrade, no embedded credentials, same origin.
677 fn validate_discovered_oauth_endpoint(
678 endpoint: Option<String>,
679 field: &str,
680 issuer: &str,
681 ) -> Result<String> {
682 let endpoint = endpoint
683 .as_deref()
684 .map(str::trim)
685 .filter(|endpoint| !endpoint.is_empty())
686 .with_context(|| format!("OIDC discovery missing {field}"))?;
687 let parsed = reqwest::Url::parse(endpoint)
688 .with_context(|| format!("OIDC discovery returned an invalid {field}"))?;
689 if !matches!(parsed.scheme(), "http" | "https") {
690 bail!("OIDC discovery returned unsupported {field} scheme");
691 }
692 let issuer = oauth_endpoint_url(issuer).context("OIDC issuer is not a trusted URL")?;
693 if issuer.scheme() == "https" && parsed.scheme() != "https" {
694 bail!("OIDC discovery attempted to downgrade {field} from HTTPS");
695 }
696 if !parsed.username().is_empty() || parsed.password().is_some() {
697 bail!("OIDC discovery returned credentials in {field}");
698 }
699 if parsed.origin() != issuer.origin() {
700 bail!("OIDC discovery returned {field} on a different origin than the issuer");
701 }
702 let _ = oauth_endpoint_url(parsed.as_str())?;
703 Ok(endpoint.to_string())
704 }
705
706 /// Documented-path endpoints for a provider row, no discovery.
707 fn fallback_oauth_endpoints(params: &OAuthProviderParams, issuer: &str) -> OAuthEndpoints {
708 OAuthEndpoints {
709 device_authorization_endpoint: params
710 .device_code_path
711 .map(|path| format!("{}/{}", issuer.trim_end_matches('/'), path)),
712 token_endpoint: format!("{}/{}", issuer.trim_end_matches('/'), params.token_path),
713 }
714 }
715
716 /// POST a device-authorization request. Pure transport over an explicit
717 /// endpoint: discovery (or its absence) is the caller's decision.
718 fn request_device_grant(
719 device_authorization_endpoint: &str,
720 client_id: &str,
721 scopes: &str,
722 ) -> Result<DeviceGrantResponse> {
723 let device_authorization_endpoint = oauth_endpoint_url(device_authorization_endpoint)?;
724 let client = oauth_http_client("device-code")?;
725 let params = [("client_id", client_id), ("scope", scopes)];
726 #[cfg(test)]
727 crate::external_credentials::record_oauth_network();
728 let response = client
729 .post(device_authorization_endpoint)
730 .form(&params)
731 .send()
732 .context("OAuth device-code request failed")?;
733 let (status, body): (_, DeviceGrantResponse) =
734 parse_oauth_json(response, "OAuth device-code request")?;
735 if !status.is_success() || body.error.is_some() {
736 let detail = oauth_failure_detail(
737 body.error.as_deref(),
738 body.error_description.as_deref(),
739 status,
740 );
741 bail!("OAuth device-code request failed ({detail})");
742 }
743 if body
744 .device_code
745 .as_deref()
746 .is_some_and(|code| !code.trim().is_empty())
747 && body
748 .user_code
749 .as_deref()
750 .is_some_and(|code| !code.trim().is_empty())
751 {
752 return Ok(body);
753 }
754 bail!("OAuth device-code request returned success without a device and user code");
755 }
756
757 /// Poll the token endpoint once, classifying the RFC 8628 outcome. Matches
758 /// the legacy per-provider poll so the ported tests pin identical behavior.
759 fn poll_device_grant(
760 token_endpoint: &str,
761 client_id: &str,
762 device_code: &str,
763 ) -> Result<codewhale_config::device_code::DevicePollOutcome<OAuthTokenMaterial>> {
764 use codewhale_config::device_code::DevicePollOutcome;
765 let token_endpoint = oauth_endpoint_url(token_endpoint)?;
766 let client = oauth_http_client("device-code poll")?;
767 let params = [
768 ("client_id", client_id),
769 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
770 ("device_code", device_code),
771 ];
772 #[cfg(test)]
773 crate::external_credentials::record_oauth_network();
774 let response = client
775 .post(token_endpoint)
776 .form(&params)
777 .send()
778 .context("OAuth device-code poll failed")?;
779 let (status, body): (_, OAuthTokenMaterial) =
780 parse_oauth_json(response, "OAuth device-code poll")?;
781 if status.is_success() && body.error.is_none() {
782 return Ok(DevicePollOutcome::Complete(body));
783 }
784 match body.error.as_deref().unwrap_or("") {
785 "authorization_pending" => Ok(DevicePollOutcome::Pending),
786 "slow_down" => Ok(DevicePollOutcome::SlowDown {
787 interval_seconds: body.interval,
788 }),
789 _ => {
790 let detail = oauth_failure_detail(
791 body.error.as_deref(),
792 body.error_description.as_deref(),
793 status,
794 );
795 bail!("OAuth device-code poll failed ({detail})");
796 }
797 }
798 }
799
800 /// Interactive device-code login for any provider whose row offers it.
801 /// Prints the verification URL + user code to stderr and polls until
802 /// approved. A provider with no device flow (ChatGPT) fails here with the
803 /// reason, instead of deep in transport code.
804 pub async fn device_code_login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
805 // Endpoint resolution does blocking HTTP (discovery): it must run on the
806 // blocking worker, never on the async executor. Providers with no device
807 // flow fail here, before any thread spawns and before any network.
808 let params = oauth_provider_params(provider);
809 if params.device_code_path.is_none() {
810 bail!(
811 "{} offers no device-code flow; sign in through the browser login instead",
812 params.display_name
813 );
814 }
815 let inputs = params.resolve_inputs();
816 let display_name = params.display_name;
817 tokio::task::spawn_blocking(move || device_code_login_with(provider, &inputs))
818 .await
819 .with_context(|| format!("{display_name} device-code login worker failed"))?
820 }
821
822 /// Blocking worker body for [`device_code_login`]. `pub(crate)` so the
823 /// legacy activation tests can drive the unified login end to end until
824 /// activation unifies in 3b-ii.
825 pub(crate) fn device_code_login_with(
826 provider: OAuthProvider,
827 inputs: &ResolvedOAuthInputs,
828 ) -> Result<PendingOAuthLogin> {
829 let params = oauth_provider_params(provider);
830 let display_name = params.display_name;
831 let endpoints = resolve_oauth_endpoints(params, &inputs.issuer);
832 let Some(device_endpoint) = endpoints.device_authorization_endpoint else {
833 bail!(
834 "{display_name} offers no device-code flow; sign in through the browser login instead"
835 );
836 };
837 let token_endpoint = endpoints.token_endpoint;
838 let poll_floor_secs = params.device_poll_floor_secs;
839 let grant = request_device_grant(&device_endpoint, &inputs.client_id, &inputs.scopes)?;
840 let verify = grant
841 .verification_uri_complete
842 .clone()
843 .or(grant.verification_uri.clone())
844 .unwrap_or_else(|| format!("{}/device", inputs.issuer.trim_end_matches('/')));
845 // Off the wire, headed for `webbrowser::open`: must be a bare
846 // navigation, never a scheme or credential smuggle.
847 let verify = codewhale_config::device_code::validate_browser_verification_uri(
848 &verify,
849 &format!("{display_name} device-code request"),
850 )?;
851 let user_code = grant.user_code.unwrap_or_default();
852
853 eprintln!("{display_name} device-code login");
854 eprintln!(" Open: {verify}");
855 eprintln!(" Code: {user_code}");
856 eprintln!("Waiting for approval in the browser… (Ctrl+C to abort)");
857 if inputs.open_browser
858 && let Err(err) = webbrowser::open(&verify)
859 {
860 eprintln!("Could not open the browser automatically: {err}");
861 }
862
863 let lifetime = Duration::from_secs(
864 grant
865 .expires_in
866 .unwrap_or(DEVICE_POLL_MAX_SECS)
867 .max(poll_floor_secs),
868 );
869 let token = codewhale_config::device_code::DeviceCodePoll::new(
870 lifetime,
871 format!(
872 "{display_name} device-code authorization timed out. Re-run device login \
873 and approve the code before it expires."
874 ),
875 )
876 .interval_seconds(grant.interval)
877 .wait_before_first_poll(true)
878 .slow_down_timeout_message(format!(
879 "{display_name} device-code authorization timed out after one or more slow_down \
880 responses. That is usually clock drift in a WSL or VM environment; \
881 sync the clock, then re-run device login and approve the code before \
882 it expires."
883 ))
884 .run(std::thread::sleep, || {
885 poll_device_grant(
886 &token_endpoint,
887 &inputs.client_id,
888 grant.device_code.as_deref().unwrap_or(""),
889 )
890 })?;
891
892 Ok(PendingOAuthLogin {
893 provider,
894 issuer: inputs.issuer.clone(),
895 client_id: inputs.client_id.clone(),
896 token,
897 })
898 }
899
900 /// One login entry point for every provider: the params row decides whether
901 /// the grant is device-code or browser PKCE. A provider with neither fails
902 /// here with the reason.
903 pub async fn login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
904 let params = oauth_provider_params(provider);
905 if params.device_code_path.is_some() {
906 device_code_login(provider).await
907 } else if params.authorize_path.is_some() {
908 pkce_login(provider).await
909 } else {
910 bail!("{} offers no sign-in flow", params.display_name);
911 }
912 }
913
914 // ── form-post transport seam ──────────────────────────────────────────
915 //
916 // One seam for every OAuth form post (PKCE exchange, refresh, revoke): the
917 // production client is reqwest with the shared bounds; tests substitute a
918 // mock issuer. The seam records network/refresh in test builds so the
919 // side-effect trap can still prove "zero external I/O" assertions.
920
921 pub(crate) trait OAuthFormClient {
922 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)>;
923 }
924
925 pub(crate) struct ReqwestOAuthFormClient;
926
927 impl OAuthFormClient for ReqwestOAuthFormClient {
928 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
929 let url = oauth_endpoint_url(url)?;
930 #[cfg(test)]
931 crate::external_credentials::record_oauth_network();
932 let client = oauth_http_client("form")?;
933 let response = client
934 .post(url)
935 .form(form)
936 .send()
937 .context("OAuth form request failed")?;
938 let status = response.status().as_u16();
939 let mut reader = response.take(OAUTH_RESPONSE_BODY_LIMIT + 1);
940 let mut body = Vec::new();
941 reader
942 .read_to_end(&mut body)
943 .context("reading OAuth form response")?;
944 if body.len() as u64 > OAUTH_RESPONSE_BODY_LIMIT {
945 body.truncate(OAUTH_RESPONSE_BODY_LIMIT as usize);
946 }
947 Ok((status, String::from_utf8(body).unwrap_or_default()))
948 }
949 }
950
951 /// Token/authorize endpoint URLs for one provider row.
952 pub(crate) fn form_token_url(params: &OAuthProviderParams, issuer: &str) -> String {
953 format!("{}/{}", issuer.trim_end_matches('/'), params.token_path)
954 }
955
956 /// Pinned remote revoke URL; `None` when the provider revokes locally only.
957 pub(crate) fn remote_revoke_url(params: &OAuthProviderParams, issuer: &str) -> Option<String> {
958 params
959 .revoke_path
960 .map(|path| format!("{}/{}", issuer.trim_end_matches('/'), path))
961 }
962
963 /// Parse a form-post token response. Error bodies are never echoed: the
964 /// detail names the error code only, so a hostile issuer cannot smuggle
965 /// secret-bearing text back through diagnostics.
966 pub(crate) fn parse_oauth_form_response(
967 status: u16,
968 body: &str,
969 operation: &str,
970 params: &OAuthProviderParams,
971 ) -> Result<OAuthTokenMaterial> {
972 let name = params.display_name;
973 let parsed: OAuthTokenMaterial = serde_json::from_str(body).map_err(|_| {
974 anyhow::anyhow!("{name} OAuth {operation} returned HTTP {status} that was not token JSON")
975 })?;
976 if !(200..300).contains(&status) || parsed.error.is_some() {
977 let err = parsed.error.as_deref().unwrap_or("token_error");
978 if matches!(
979 err,
980 "invalid_grant"
981 | "refresh_token_reused"
982 | "refresh_token_expired"
983 | "refresh_token_invalidated"
984 ) || status == 401
985 {
986 bail!(
987 "{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`.",
988 params.relogin_hint
989 );
990 }
991 bail!("{name} OAuth {operation} failed ({err})");
992 }
993 anyhow::ensure!(
994 parsed
995 .access_token
996 .as_deref()
997 .is_some_and(|token| !token.trim().is_empty()),
998 "{name} OAuth {operation} returned an empty access token"
999 );
1000 Ok(parsed)
1001 }
1002
1003 fn compact_form_error(body: &str) -> String {
1004 body.chars().filter(|c| !c.is_control()).take(80).collect()
1005 }
1006
1007 /// Refresh an owned token through the seam at an explicit token URL —
1008 /// discovered when the provider row demands it, pinned otherwise. Refresh is
1009 /// a Codewhale-owned credential operation only: external imports never
1010 /// refresh.
1011 pub(crate) fn refresh_access_token_via(
1012 client: &dyn OAuthFormClient,
1013 params: &OAuthProviderParams,
1014 token_url: &str,
1015 client_id: &str,
1016 refresh_token: &str,
1017 ) -> Result<OAuthTokenMaterial> {
1018 #[cfg(test)]
1019 crate::external_credentials::record_oauth_refresh();
1020 let (status, body) = client.post_form(
1021 token_url,
1022 &[
1023 ("grant_type", "refresh_token"),
1024 ("client_id", client_id),
1025 ("refresh_token", refresh_token),
1026 ],
1027 )?;
1028 parse_oauth_form_response(status, &body, "refresh", params)
1029 }
1030
1031 /// Best-effort remote revoke through the seam. Callers clear local
1032 /// credentials regardless of this outcome.
1033 pub(crate) fn revoke_remote_token_via(
1034 client: &dyn OAuthFormClient,
1035 params: &OAuthProviderParams,
1036 issuer: &str,
1037 client_id: &str,
1038 token: &str,
1039 ) -> Result<()> {
1040 let Some(revoke_url) = remote_revoke_url(params, issuer) else {
1041 bail!("{} has no remote revoke endpoint", params.display_name);
1042 };
1043 let (status, body) =
1044 client.post_form(&revoke_url, &[("token", token), ("client_id", client_id)])?;
1045 if !(200..300).contains(&status) {
1046 bail!(
1047 "{} OAuth revoke failed with HTTP {status}: {}",
1048 params.display_name,
1049 compact_form_error(&body)
1050 );
1051 }
1052 Ok(())
1053 }
1054
1055 // ── PKCE browser login ────────────────────────────────────────────────
1056
1057 /// RFC 7636 S256 PKCE pair. Custom Debug: the verifier is exchanged for
1058 /// bearer material and never prints.
1059 #[derive(Clone)]
1060 pub struct PkceChallenge {
1061 pub verifier: String,
1062 pub challenge: String,
1063 }
1064
1065 impl std::fmt::Debug for PkceChallenge {
1066 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1067 f.debug_struct("PkceChallenge")
1068 .field("verifier", &"<redacted>")
1069 .field("challenge", &self.challenge)
1070 .finish()
1071 }
1072 }
1073
1074 /// A browser authorization request in flight: state, PKCE pair, and the
1075 /// registered redirect the callback server answers on.
1076 #[derive(Clone)]
1077 pub struct BrowserAuthRequest {
1078 pub state: String,
1079 pub pkce: PkceChallenge,
1080 pub redirect_uri: String,
1081 pub authorize_url: String,
1082 }
1083
1084 impl std::fmt::Debug for BrowserAuthRequest {
1085 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086 f.debug_struct("BrowserAuthRequest")
1087 .field("state", &self.state)
1088 .field("pkce", &self.pkce)
1089 .field("redirect_uri", &self.redirect_uri)
1090 .field("authorize_url", &self.authorize_url)
1091 .finish()
1092 }
1093 }
1094
1095 /// Parsed callback query: a code+state pair, or the issuer's refusal.
1096 #[derive(Debug, Clone, PartialEq, Eq)]
1097 pub enum CallbackOutcome {
1098 Success {
1099 code: String,
1100 state: String,
1101 },
1102 Error {
1103 error: String,
1104 description: Option<String>,
1105 state: Option<String>,
1106 },
1107 }
1108
1109 /// RFC 7636 S256 PKCE pair.
1110 #[must_use]
1111 pub fn generate_pkce() -> PkceChallenge {
1112 let verifier = random_url_token(32);
1113 let digest = Sha256::digest(verifier.as_bytes());
1114 PkceChallenge {
1115 verifier,
1116 challenge: URL_SAFE_NO_PAD.encode(digest),
1117 }
1118 }
1119
1120 #[must_use]
1121 pub fn generate_state() -> String {
1122 random_url_token(16)
1123 }
1124
1125 fn random_url_token(nbytes: usize) -> String {
1126 let mut bytes = vec![0u8; nbytes.max(16)];
1127 let mut offset = 0;
1128 while offset < bytes.len() {
1129 let chunk = uuid::Uuid::new_v4();
1130 let take = (bytes.len() - offset).min(16);
1131 bytes[offset..offset + take].copy_from_slice(&chunk.as_bytes()[..take]);
1132 offset += take;
1133 }
1134 URL_SAFE_NO_PAD.encode(bytes)
1135 }
1136
1137 pub fn build_authorize_url(
1138 params: &OAuthProviderParams,
1139 issuer: &str,
1140 client_id: &str,
1141 scopes: &str,
1142 redirect_uri: &str,
1143 state: &str,
1144 pkce: &PkceChallenge,
1145 ) -> Result<String> {
1146 let Some(authorize_path) = params.authorize_path else {
1147 bail!("{} offers no browser sign-in flow", params.display_name);
1148 };
1149 // A malformed configured issuer must fail loudly. Silently redirecting
1150 // the browser to the production authorize endpoint would hand the
1151 // issuer a sign-in the user aimed somewhere else.
1152 let issuer_var = params
1153 .env
1154 .issuer_vars
1155 .first()
1156 .copied()
1157 .unwrap_or("the issuer environment variable");
1158 let mut url = oauth_endpoint_url(&format!(
1159 "{}/{}",
1160 issuer.trim_end_matches('/'),
1161 authorize_path
1162 ))
1163 .with_context(|| {
1164 format!(
1165 "{} OAuth issuer is not a valid URL or uses an insecure endpoint — check {issuer_var}",
1166 params.display_name
1167 )
1168 })?;
1169 url.query_pairs_mut()
1170 .append_pair("response_type", "code")
1171 .append_pair("client_id", client_id)
1172 .append_pair("redirect_uri", redirect_uri)
1173 .append_pair("scope", scopes)
1174 .append_pair("code_challenge", &pkce.challenge)
1175 .append_pair("code_challenge_method", "S256")
1176 .append_pair("state", state);
1177 if let Some(originator) = params.originator {
1178 url.query_pairs_mut().append_pair("originator", originator);
1179 }
1180 for (key, value) in params.authorize_extras {
1181 url.query_pairs_mut().append_pair(key, value);
1182 }
1183 Ok(url.to_string())
1184 }
1185
1186 pub fn parse_callback_query(params: &OAuthProviderParams, query: &str) -> Result<CallbackOutcome> {
1187 let parsed = reqwest::Url::parse(&format!("http://127.0.0.1{}?{query}", params.callback_path))
1188 .context("OAuth callback query is not valid")?;
1189 let mut code = None;
1190 let mut state = None;
1191 let mut error = None;
1192 let mut description = None;
1193 for (key, value) in parsed.query_pairs() {
1194 match key.as_ref() {
1195 "code" => code = Some(value.into_owned()),
1196 "state" => state = Some(value.into_owned()),
1197 "error" => error = Some(value.into_owned()),
1198 "error_description" => description = Some(value.into_owned()),
1199 _ => {}
1200 }
1201 }
1202 if let Some(error) = error {
1203 return Ok(CallbackOutcome::Error {
1204 error,
1205 description,
1206 state,
1207 });
1208 }
1209 let code = code
1210 .filter(|c| !c.trim().is_empty())
1211 .context("OAuth callback missing authorization code")?;
1212 let state = state
1213 .filter(|s| !s.trim().is_empty())
1214 .context("OAuth callback missing state")?;
1215 Ok(CallbackOutcome::Success { code, state })
1216 }
1217
1218 pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
1219 match outcome {
1220 CallbackOutcome::Success { code, state } => {
1221 anyhow::ensure!(
1222 state == expected_state,
1223 "OAuth callback state did not match the pending login"
1224 );
1225 Ok(code)
1226 }
1227 CallbackOutcome::Error {
1228 error,
1229 description,
1230 state,
1231 } => {
1232 if let Some(state) = state {
1233 anyhow::ensure!(
1234 state == expected_state,
1235 "OAuth error callback state did not match the pending login"
1236 );
1237 }
1238 let detail = description
1239 .filter(|text| !text.trim().is_empty())
1240 .unwrap_or(error);
1241 bail!("sign-in was not completed: {detail}")
1242 }
1243 }
1244 }
1245
1246 fn parse_http_request_target(request_line: &str) -> Result<String> {
1247 let mut parts = request_line.split_whitespace();
1248 let method = parts.next().unwrap_or_default();
1249 anyhow::ensure!(
1250 method.eq_ignore_ascii_case("GET"),
1251 "OAuth callback must be GET"
1252 );
1253 let target = parts
1254 .next()
1255 .context("OAuth callback missing request target")?;
1256 Ok(target.to_string())
1257 }
1258
1259 fn query_from_target<'a>(params: &OAuthProviderParams, target: &'a str) -> Result<&'a str> {
1260 let path = target.split('?').next().unwrap_or(target);
1261 anyhow::ensure!(
1262 path == params.callback_path,
1263 "OAuth callback path was not {}",
1264 params.callback_path
1265 );
1266 Ok(target.split_once('?').map(|(_, q)| q).unwrap_or(""))
1267 }
1268
1269 /// Bind the loopback callback on both IP stacks for the first free port.
1270 ///
1271 /// The redirect URI has to say `localhost` — that is what is registered with
1272 /// the authorization server, and redirect matching is exact — but `localhost`
1273 /// resolves to `::1` before `127.0.0.1` on IPv6-first hosts. Binding only
1274 /// IPv4 left the browser connecting to a closed port, which browsers paper
1275 /// over with Happy Eyeballs fallback: a working sign-in becomes a slow one,
1276 /// and a broken one wherever that fallback is disabled. Binding both is the
1277 /// fix that keeps the registered redirect URI intact.
1278 ///
1279 /// A host with only one stack available binds only that one and still works.
1280 pub fn bind_loopback_callback(params: &OAuthProviderParams) -> Result<Vec<TcpListener>> {
1281 let name = params.display_name;
1282 let mut last_error = None;
1283 for port in params.loopback_ports {
1284 let mut bound = Vec::new();
1285 for addr in [
1286 SocketAddr::from((Ipv4Addr::LOCALHOST, *port)),
1287 SocketAddr::from((Ipv6Addr::LOCALHOST, *port)),
1288 ] {
1289 match TcpListener::bind(addr) {
1290 Ok(listener) => {
1291 listener.set_nonblocking(true).with_context(|| {
1292 format!("{name} OAuth callback listener could not be set non-blocking")
1293 })?;
1294 bound.push(listener);
1295 }
1296 Err(error) => last_error = Some(error),
1297 }
1298 }
1299 if !bound.is_empty() {
1300 return Ok(bound);
1301 }
1302 }
1303 let ports = params
1304 .loopback_ports
1305 .iter()
1306 .map(u16::to_string)
1307 .collect::<Vec<_>>()
1308 .join(" or ");
1309 let hint = if params.callback_conflict_hint.is_empty() {
1310 String::new()
1311 } else {
1312 format!(" {}", params.callback_conflict_hint)
1313 };
1314 Err(last_error
1315 .map(anyhow::Error::from)
1316 .unwrap_or_else(|| anyhow::anyhow!("unable to bind {name} OAuth callback ports")))
1317 .with_context(|| format!("{name} sign-in needs loopback port {ports}.{hint}"))
1318 }
1319
1320 pub(crate) fn start_auth_request_on(
1321 listeners: &[TcpListener],
1322 params: &OAuthProviderParams,
1323 inputs: &ResolvedOAuthInputs,
1324 ) -> Result<BrowserAuthRequest> {
1325 let port = listeners
1326 .first()
1327 .with_context(|| {
1328 format!(
1329 "{} OAuth callback has no bound listener",
1330 params.display_name
1331 )
1332 })?
1333 .local_addr()
1334 .with_context(|| {
1335 format!(
1336 "{} OAuth callback listener has no local address",
1337 params.display_name
1338 )
1339 })?
1340 .port();
1341 let redirect_uri = format!("http://localhost:{port}{}", params.callback_path);
1342 let pkce = generate_pkce();
1343 let state = generate_state();
1344 let authorize_url = build_authorize_url(
1345 params,
1346 &inputs.issuer,
1347 &inputs.client_id,
1348 &inputs.scopes,
1349 &redirect_uri,
1350 &state,
1351 &pkce,
1352 )?;
1353 Ok(BrowserAuthRequest {
1354 state,
1355 pkce,
1356 redirect_uri,
1357 authorize_url,
1358 })
1359 }
1360
1361 const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
1362 const CALLBACK_HTML_OK: &str = "<!doctype html><html><body><p>Signed in to Codewhale. You can close this tab.</p></body></html>";
1363 const CALLBACK_HTML_ERR: &str = "<!doctype html><html><body><p>Sign-in did not complete. You can close this tab and retry in Codewhale.</p></body></html>";
1364
1365 fn wait_for_callback(
1366 listeners: &[TcpListener],
1367 params: &OAuthProviderParams,
1368 expected_state: &str,
1369 ) -> Result<String> {
1370 let deadline = Instant::now() + CALLBACK_TIMEOUT;
1371 loop {
1372 if Instant::now() >= deadline {
1373 bail!(
1374 "{} sign-in timed out waiting for the browser callback",
1375 params.display_name
1376 );
1377 }
1378 // Whichever stack `localhost` resolved to for the browser is the one
1379 // that gets the connection; poll them all.
1380 for listener in listeners {
1381 match listener.accept() {
1382 Ok((stream, _)) => {
1383 return handle_callback_stream(stream, params, expected_state);
1384 }
1385 Err(error)
1386 if error.kind() == std::io::ErrorKind::WouldBlock
1387 || error.kind() == std::io::ErrorKind::Interrupted => {}
1388 Err(error) => {
1389 return Err(error).context(format!(
1390 "{} OAuth callback accept failed",
1391 params.display_name
1392 ));
1393 }
1394 }
1395 }
1396 std::thread::sleep(Duration::from_millis(50));
1397 }
1398 }
1399
1400 fn handle_callback_stream(
1401 mut stream: TcpStream,
1402 params: &OAuthProviderParams,
1403 expected_state: &str,
1404 ) -> Result<String> {
1405 // BSD sockets (macOS) hand the accepted stream the listener's O_NONBLOCK;
1406 // the bounded read below needs a blocking socket with a timeout.
1407 stream.set_nonblocking(false).with_context(|| {
1408 format!(
1409 "{} OAuth callback stream could not be set blocking",
1410 params.display_name
1411 )
1412 })?;
1413 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
1414 // One read is not one request: TCP may deliver the callback in
1415 // fragments, and a truncated query parses as a missing parameter.
1416 // Read until the blank line that ends the HTTP headers.
1417 let mut buf = [0u8; 4096];
1418 let mut len = 0usize;
1419 loop {
1420 if len == buf.len() {
1421 break;
1422 }
1423 let n = stream
1424 .read(&mut buf[len..])
1425 .with_context(|| format!("reading {} OAuth callback request", params.display_name))?;
1426 if n == 0 {
1427 break;
1428 }
1429 len += n;
1430 if buf[..len].windows(4).any(|window| window == b"\r\n\r\n") {
1431 break;
1432 }
1433 }
1434 let request = String::from_utf8_lossy(&buf[..len]);
1435 let request_line = request.lines().next().unwrap_or_default();
1436 let result = (|| {
1437 let target = parse_http_request_target(request_line)?;
1438 let query = query_from_target(params, &target)?;
1439 let outcome = parse_callback_query(params, query)?;
1440 accept_callback(expected_state, outcome)
1441 })();
1442 let (status, body) = match &result {
1443 Ok(_) => ("200 OK", CALLBACK_HTML_OK),
1444 Err(_) => ("400 Bad Request", CALLBACK_HTML_ERR),
1445 };
1446 let _ = write!(
1447 stream,
1448 "HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
1449 body.len()
1450 );
1451 result
1452 }
1453
1454 pub(crate) fn exchange_authorization_code(
1455 client: &dyn OAuthFormClient,
1456 params: &OAuthProviderParams,
1457 token_endpoint: &str,
1458 client_id: &str,
1459 redirect_uri: &str,
1460 code: &str,
1461 verifier: &str,
1462 ) -> Result<OAuthTokenMaterial> {
1463 let (status, body) = client.post_form(
1464 token_endpoint,
1465 &[
1466 ("grant_type", "authorization_code"),
1467 ("client_id", client_id),
1468 ("redirect_uri", redirect_uri),
1469 ("code", code),
1470 ("code_verifier", verifier),
1471 ],
1472 )?;
1473 parse_oauth_form_response(status, &body, "authorization code exchange", params)
1474 }
1475
1476 /// Interactive PKCE browser login for any provider whose row offers it.
1477 /// Prints the authorize URL, opens a browser, and waits for the loopback
1478 /// callback. A provider with no browser flow (xAI) fails here with the
1479 /// reason, before any listener binds.
1480 pub async fn pkce_login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
1481 let params = oauth_provider_params(provider);
1482 if params.authorize_path.is_none() {
1483 bail!(
1484 "{} offers no browser sign-in flow; sign in through the device-code login instead",
1485 params.display_name
1486 );
1487 }
1488 let inputs = params.resolve_inputs();
1489 let display_name = params.display_name;
1490 tokio::task::spawn_blocking(move || pkce_login_with(provider, &inputs))
1491 .await
1492 .with_context(|| format!("{display_name} PKCE login worker failed"))?
1493 }
1494
1495 /// Blocking worker body for [`pkce_login`]. `pub(crate)` so the activation
1496 /// tests can drive the unified login end to end until activation unifies.
1497 pub(crate) fn pkce_login_with(
1498 provider: OAuthProvider,
1499 inputs: &ResolvedOAuthInputs,
1500 ) -> Result<PendingOAuthLogin> {
1501 let params = oauth_provider_params(provider);
1502 let display_name = params.display_name;
1503 let listeners = bind_loopback_callback(params)?;
1504 let request = start_auth_request_on(&listeners, params, inputs)?;
1505 eprintln!("{display_name} sign-in (PKCE)");
1506 eprintln!(" Open: {}", request.authorize_url);
1507 eprintln!("Waiting for the browser callback… (Ctrl+C to abort)");
1508 if inputs.open_browser
1509 && let Err(err) = webbrowser::open(&request.authorize_url)
1510 {
1511 eprintln!("Could not open the browser automatically: {err}");
1512 }
1513 let code = wait_for_callback(&listeners, params, &request.state)?;
1514 let token = exchange_authorization_code(
1515 &ReqwestOAuthFormClient,
1516 params,
1517 &form_token_url(params, &inputs.issuer),
1518 &inputs.client_id,
1519 &request.redirect_uri,
1520 &code,
1521 &request.pkce.verifier,
1522 )?;
1523 Ok(PendingOAuthLogin {
1524 provider,
1525 issuer: inputs.issuer.clone(),
1526 client_id: inputs.client_id.clone(),
1527 token,
1528 })
1529 }
1530
1531 // ── owned credential storage (one store, two providers) ───────────────
1532 //
1533 // Codewhale-owned OAuth generations live in the config crate's credential
1534 // store under per-provider generation prefixes. xAI historically stored the
1535 // access token as `key` (Grok CLI shape); ChatGPT as `access_token`. One
1536 // entry type reads both; new generations write the unified shape.
1537
1538 /// xAI issuer and public client (constants the params rows and the route
1539 /// surfaces share).
1540 pub const XAI_OIDC_ISSUER: &str = "https://auth.x.ai";
1541 pub const GROK_OIDC_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
1542 pub const DEFAULT_SCOPES: &str = "openid profile email offline_access api:access grok-cli:access";
1543 /// Hard ceiling past a device grant's own `expires_in`.
1544 pub(crate) const DEVICE_POLL_MAX_SECS: u64 = 900;
1545
1546 /// ChatGPT issuer and public client.
1547 pub const CHATGPT_OAUTH_ISSUER: &str = "https://auth.openai.com";
1548 pub const CHATGPT_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
1549 /// Honest originator; never impersonate `codex_cli_rs`.
1550 pub const CHATGPT_OAUTH_ORIGINATOR: &str = "codewhale";
1551 pub const CHATGPT_OAUTH_SCOPE: &str = "openid profile email offline_access";
1552
1553 impl OAuthProvider {
1554 /// The engine provider this OAuth row belongs to.
1555 #[must_use]
1556 pub fn api(self) -> crate::config::ApiProvider {
1557 match self {
1558 OAuthProvider::Xai => crate::config::ApiProvider::Xai,
1559 OAuthProvider::Chatgpt => crate::config::ApiProvider::OpenaiCodex,
1560 }
1561 }
1562
1563 /// `[providers.<key>]` table this provider's auth state lives under.
1564 fn config_key(self) -> &'static str {
1565 crate::config::provider_config_key(self.api()).unwrap_or(match self {
1566 OAuthProvider::Xai => "xai",
1567 OAuthProvider::Chatgpt => "openai_codex",
1568 })
1569 }
1570
1571 fn legacy_file_name(self) -> &'static str {
1572 match self {
1573 OAuthProvider::Xai => codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
1574 OAuthProvider::Chatgpt => codewhale_config::LEGACY_CHATGPT_OAUTH_FILE_NAME,
1575 }
1576 }
1577
1578 #[must_use]
1579 pub fn is_valid_generation(self, name: &str) -> bool {
1580 match self {
1581 OAuthProvider::Xai => codewhale_config::is_valid_xai_oauth_generation(name),
1582 OAuthProvider::Chatgpt => codewhale_config::is_valid_chatgpt_oauth_generation(name),
1583 }
1584 }
1585
1586 fn validate_generation(self, name: &str) -> Result<()> {
1587 match self {
1588 OAuthProvider::Xai => codewhale_config::validate_xai_oauth_generation(name),
1589 OAuthProvider::Chatgpt => codewhale_config::validate_chatgpt_oauth_generation(name),
1590 }
1591 .map(|_| ())
1592 }
1593
1594 fn generation_path(self, name: &str) -> Result<PathBuf> {
1595 match self {
1596 OAuthProvider::Xai => codewhale_config::xai_oauth_generation_path(name),
1597 OAuthProvider::Chatgpt => codewhale_config::chatgpt_oauth_generation_path(name),
1598 }
1599 }
1600
1601 /// A fresh, uniquely named generation file name for this provider.
1602 fn new_generation(self) -> String {
1603 let (prefix, suffix) = match self {
1604 OAuthProvider::Xai => (
1605 codewhale_config::XAI_OAUTH_GENERATION_PREFIX,
1606 codewhale_config::XAI_OAUTH_GENERATION_SUFFIX,
1607 ),
1608 OAuthProvider::Chatgpt => (
1609 codewhale_config::CHATGPT_OAUTH_GENERATION_PREFIX,
1610 codewhale_config::CHATGPT_OAUTH_GENERATION_SUFFIX,
1611 ),
1612 };
1613 format!("{prefix}{}{suffix}", uuid::Uuid::new_v4().simple())
1614 }
1615 }
1616
1617 /// One entry in a Codewhale-owned auth generation. Reads both historical
1618 /// on-disk shapes; `key` was the xAI/Grok field name for the access token.
1619 #[derive(Clone, Serialize, Deserialize)]
1620 pub struct OwnedAuthEntry {
1621 #[serde(default, alias = "key", skip_serializing_if = "Option::is_none")]
1622 pub access_token: Option<String>,
1623 #[serde(default, skip_serializing_if = "Option::is_none")]
1624 pub refresh_token: Option<String>,
1625 #[serde(default, skip_serializing_if = "Option::is_none")]
1626 pub expires_at: Option<String>,
1627 #[serde(default, skip_serializing_if = "Option::is_none")]
1628 pub id_token: Option<String>,
1629 #[serde(default, skip_serializing_if = "Option::is_none")]
1630 pub account_id: Option<String>,
1631 #[serde(default, skip_serializing_if = "Option::is_none")]
1632 pub oidc_issuer: Option<String>,
1633 #[serde(default, skip_serializing_if = "Option::is_none")]
1634 pub oidc_client_id: Option<String>,
1635 #[serde(default, skip_serializing_if = "Option::is_none")]
1636 pub originator: Option<String>,
1637 #[serde(default, skip_serializing_if = "Option::is_none")]
1638 pub auth_mode: Option<String>,
1639 #[serde(flatten)]
1640 pub extra: BTreeMap<String, Value>,
1641 }
1642
1643 impl std::fmt::Debug for OwnedAuthEntry {
1644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1645 f.debug_struct("OwnedAuthEntry")
1646 .field("access_token", &redacted(self.access_token.is_some()))
1647 .field("refresh_token", &redacted(self.refresh_token.is_some()))
1648 .field("expires_at", &self.expires_at)
1649 .field("id_token", &redacted(self.id_token.is_some()))
1650 .field("account_id", &self.account_id)
1651 .field("oidc_issuer", &self.oidc_issuer)
1652 .field("oidc_client_id", &self.oidc_client_id)
1653 .field("originator", &self.originator)
1654 .field("auth_mode", &self.auth_mode)
1655 .field("extra_keys", &self.extra.keys().collect::<Vec<_>>())
1656 .finish()
1657 }
1658 }
1659
1660 fn redacted(present: bool) -> &'static str {
1661 if present { "<redacted>" } else { "<none>" }
1662 }
1663
1664 /// Resolved owned credentials ready for API use. No Debug: bearer material
1665 /// never prints; consumers redact explicitly.
1666 #[derive(Clone)]
1667 pub struct OwnedOAuthCredentials {
1668 pub access_token: String,
1669 pub account_id: Option<String>,
1670 #[allow(dead_code, reason = "read by provider routes and tests as needed")]
1671 pub refresh_token: Option<String>,
1672 #[allow(dead_code, reason = "diagnostic surface only")]
1673 pub expires_at: Option<String>,
1674 #[allow(dead_code, reason = "route provenance only")]
1675 pub issuer: String,
1676 #[allow(dead_code, reason = "route provenance only")]
1677 pub client_id: String,
1678 }
1679
1680 /// Receipt for a committed Codewhale-owned OAuth generation.
1681 pub struct OAuthActivation {
1682 #[expect(dead_code)]
1683 pub credentials: OwnedOAuthCredentials,
1684 pub config_path: PathBuf,
1685 pub auth_path: PathBuf,
1686 }
1687
1688 impl std::fmt::Debug for OAuthActivation {
1689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1690 f.debug_struct("OAuthActivation")
1691 .field("credentials", &redacted(true))
1692 .field("config_path", &self.config_path)
1693 .field("auth_path", &self.auth_path)
1694 .finish()
1695 }
1696 }
1697
1698 type AuthFile = BTreeMap<String, OwnedAuthEntry>;
1699
1700 fn load_owned_auth_file(path: &Path) -> Result<Option<AuthFile>> {
1701 let Some(raw) = crate::external_credentials::read_codewhale_owned_to_string(path)? else {
1702 return Ok(None);
1703 };
1704 parse_auth_file(&raw, path).map(Some)
1705 }
1706
1707 fn load_owned_auth_file_from_store(
1708 store: &codewhale_config::XaiOAuthCredentialStore,
1709 name: &str,
1710 ) -> Result<Option<AuthFile>> {
1711 let Some(raw) = store.read_to_string(name)? else {
1712 return Ok(None);
1713 };
1714 parse_auth_file(&raw, &store.path_for(name)?).map(Some)
1715 }
1716
1717 fn parse_auth_file(raw: &str, path: &Path) -> Result<AuthFile> {
1718 let value: Value = serde_json::from_str(raw).map_err(|_| {
1719 anyhow::anyhow!(
1720 "credential file {} is not valid credential JSON",
1721 codewhale_config::quote_os_path(path)
1722 )
1723 })?;
1724 let obj = value.as_object().ok_or_else(|| {
1725 anyhow::anyhow!(
1726 "credential file {} must be a JSON object of entries",
1727 codewhale_config::quote_os_path(path)
1728 )
1729 })?;
1730 let mut out = BTreeMap::new();
1731 for (k, v) in obj {
1732 match serde_json::from_value::<OwnedAuthEntry>(v.clone()) {
1733 Ok(entry) => {
1734 out.insert(k.clone(), entry);
1735 }
1736 Err(_) => {
1737 tracing::warn!(
1738 target: "codewhale::oauth",
1739 "skipping unreadable owned auth entry"
1740 );
1741 }
1742 }
1743 }
1744 Ok(out)
1745 }
1746
1747 fn write_auth_file_to_store(
1748 store: &codewhale_config::XaiOAuthCredentialStore,
1749 name: &str,
1750 file: &AuthFile,
1751 allow_replace: bool,
1752 ) -> Result<()> {
1753 let serialized =
1754 serde_json::to_vec_pretty(file).context("serializing owned OAuth credentials")?;
1755 store
1756 .write(name, &serialized, allow_replace)
1757 .with_context(|| {
1758 format!(
1759 "writing owned OAuth credentials to {}",
1760 codewhale_config::quote_os_path(&store.directory().join(name))
1761 )
1762 })?;
1763 #[cfg(test)]
1764 crate::external_credentials::record_owned_credential_write();
1765 Ok(())
1766 }
1767
1768 /// Read-only parse of another CLI's granted credential file.
1769 fn load_external_auth_file(
1770 grant: &codewhale_config::ExternalCredentialReadGrant,
1771 ) -> Result<AuthFile> {
1772 let Some(raw) = crate::external_credentials::read_to_string(grant)? else {
1773 bail!(
1774 "external credential file not found at {}",
1775 codewhale_config::quote_os_path(grant.path())
1776 );
1777 };
1778 parse_auth_file(&raw, grant.path())
1779 }
1780
1781 fn select_entry(provider: OAuthProvider, file: &mut AuthFile) -> Option<(String, OwnedAuthEntry)> {
1782 // Prefer this provider's registered client-id scope when present.
1783 let preferred_suffix = format!("::{}", oauth_provider_params(provider).default_client_id);
1784 if let Some((k, v)) = file
1785 .iter()
1786 .find(|(k, e)| k.ends_with(&preferred_suffix) && entry_has_usable_secret(e))
1787 {
1788 return Some((k.clone(), v.clone()));
1789 }
1790 file.iter()
1791 .find(|(_, e)| entry_has_usable_secret(e))
1792 .map(|(k, v)| (k.clone(), v.clone()))
1793 }
1794
1795 fn entry_has_usable_secret(entry: &OwnedAuthEntry) -> bool {
1796 entry
1797 .access_token
1798 .as_deref()
1799 .is_some_and(|t| !t.trim().is_empty())
1800 || entry
1801 .refresh_token
1802 .as_deref()
1803 .is_some_and(|t| !t.trim().is_empty())
1804 }
1805
1806 const REFRESH_SKEW_SECS: i64 = 60;
1807
1808 fn entry_access_token_is_fresh(entry: &OwnedAuthEntry) -> bool {
1809 let Some(token) = entry
1810 .access_token
1811 .as_deref()
1812 .filter(|t| !t.trim().is_empty())
1813 else {
1814 return false;
1815 };
1816 let stored_expiry = entry.expires_at.as_deref().and_then(parse_rfc3339_secs);
1817 let token_expiry = jwt_expiry_seconds(token).and_then(|exp| i64::try_from(exp).ok());
1818 // A later stored expiry must not hide an already-expired access token.
1819 // Opaque tokens still use stored expiry; no known expiry remains stale.
1820 stored_expiry
1821 .into_iter()
1822 .chain(token_expiry)
1823 .min()
1824 .is_some_and(|exp| exp.saturating_sub(now_unix_secs().unwrap_or(0)) > REFRESH_SKEW_SECS)
1825 }
1826
1827 fn credentials_from_entry(
1828 provider: OAuthProvider,
1829 scope: &str,
1830 entry: &OwnedAuthEntry,
1831 access_token: String,
1832 ) -> OwnedOAuthCredentials {
1833 OwnedOAuthCredentials {
1834 access_token,
1835 account_id: entry.account_id.clone(),
1836 refresh_token: entry.refresh_token.clone(),
1837 expires_at: entry.expires_at.clone(),
1838 issuer: entry
1839 .oidc_issuer
1840 .clone()
1841 .filter(|s| !s.trim().is_empty())
1842 .unwrap_or_else(|| issuer_from_scope(provider, scope)),
1843 client_id: entry
1844 .oidc_client_id
1845 .clone()
1846 .filter(|s| !s.trim().is_empty())
1847 .unwrap_or_else(|| client_id_from_scope(provider, scope)),
1848 }
1849 }
1850
1851 fn issuer_from_scope(provider: OAuthProvider, scope: &str) -> String {
1852 scope
1853 .split_once("::")
1854 .map(|(issuer, _)| issuer.to_string())
1855 .unwrap_or_else(|| oauth_provider_params(provider).default_issuer.to_string())
1856 }
1857
1858 fn client_id_from_scope(provider: OAuthProvider, scope: &str) -> String {
1859 scope
1860 .split_once("::")
1861 .map(|(_, id)| id.to_string())
1862 .unwrap_or_else(|| {
1863 oauth_provider_params(provider)
1864 .default_client_id
1865 .to_string()
1866 })
1867 }
1868
1869 fn apply_token_response(
1870 provider: OAuthProvider,
1871 entry: &mut OwnedAuthEntry,
1872 issuer: &str,
1873 client_id: &str,
1874 token: &OAuthTokenMaterial,
1875 ) -> Result<()> {
1876 let access = token
1877 .access_token
1878 .as_deref()
1879 .filter(|t| !t.trim().is_empty())
1880 .context("token response missing access_token")?;
1881 entry.access_token = Some(access.to_string());
1882 if let Some(rt) = token
1883 .refresh_token
1884 .as_deref()
1885 .filter(|t| !t.trim().is_empty())
1886 {
1887 entry.refresh_token = Some(rt.to_string());
1888 }
1889 entry.oidc_issuer = Some(issuer.to_string());
1890 entry.oidc_client_id = Some(client_id.to_string());
1891 entry.auth_mode = Some("oidc".to_string());
1892 entry.originator = oauth_provider_params(provider)
1893 .originator
1894 .map(ToOwned::to_owned);
1895 if let Some(id_token) = token.id_token.clone() {
1896 if let Some(account_id) = account_id_from_id_token(&id_token) {
1897 entry.account_id = Some(account_id);
1898 }
1899 entry.id_token = Some(id_token);
1900 }
1901 if let Some(expires_in) = token.expires_in {
1902 entry.expires_at = Some(rfc3339_from_now(expires_in));
1903 } else if let Some(exp) = jwt_expiry_seconds(access) {
1904 entry.expires_at = Some(rfc3339_from_unix(exp as i64));
1905 }
1906 Ok(())
1907 }
1908
1909 fn account_id_from_id_token(token: &str) -> Option<String> {
1910 let payload = jwt_payload(token)?;
1911 if let Some(id) = payload.get("chatgpt_account_id").and_then(Value::as_str) {
1912 let trimmed = id.trim();
1913 if !trimmed.is_empty() {
1914 return Some(trimmed.to_string());
1915 }
1916 }
1917 payload
1918 .get("https://api.openai.com/auth")
1919 .and_then(|auth| auth.get("chatgpt_account_id"))
1920 .and_then(Value::as_str)
1921 .map(str::trim)
1922 .filter(|id| !id.is_empty())
1923 .map(ToOwned::to_owned)
1924 }
1925
1926 fn jwt_payload(token: &str) -> Option<Value> {
1927 let mut parts = token.split('.');
1928 let _header = parts.next()?;
1929 let payload = parts.next()?;
1930 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
1931 serde_json::from_slice(&decoded).ok()
1932 }
1933
1934 fn now_unix_secs() -> Option<i64> {
1935 SystemTime::now()
1936 .duration_since(UNIX_EPOCH)
1937 .ok()
1938 .and_then(|d| i64::try_from(d.as_secs()).ok())
1939 }
1940
1941 fn parse_rfc3339_secs(raw: &str) -> Option<i64> {
1942 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
1943 return Some(dt.timestamp());
1944 }
1945 // Simple UTC forms chrono's strict parser rejects, e.g. a missing
1946 // fractional second on an offset-less timestamp.
1947 let trimmed = raw.trim().trim_end_matches('Z');
1948 let (date, time) = trimmed.split_once('T')?;
1949 let mut d = date.split('-');
1950 let y: i32 = d.next()?.parse().ok()?;
1951 let m: u32 = d.next()?.parse().ok()?;
1952 let day: u32 = d.next()?.parse().ok()?;
1953 let time = time.split('+').next()?.split('-').next()?;
1954 let mut t = time.split(':');
1955 let hh: u32 = t.next()?.parse().ok()?;
1956 let mm: u32 = t.next()?.parse().ok()?;
1957 let ss: u32 = t
1958 .next()
1959 .and_then(|s| s.split('.').next())
1960 .and_then(|s| s.parse().ok())
1961 .unwrap_or(0);
1962 let ndt = chrono::NaiveDate::from_ymd_opt(y, m, day)?.and_hms_opt(hh, mm, ss)?;
1963 Some(ndt.and_utc().timestamp())
1964 }
1965
1966 fn rfc3339_from_now(expires_in: u64) -> String {
1967 let ts = now_unix_secs().unwrap_or(0) + expires_in as i64;
1968 rfc3339_from_unix(ts)
1969 }
1970
1971 fn rfc3339_from_unix(ts: i64) -> String {
1972 chrono::DateTime::from_timestamp(ts, 0)
1973 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
1974 .unwrap_or_else(|| format!("{ts}"))
1975 }
1976
1977 /// Refresh through the seam, resolving the token endpoint the provider's
1978 /// row demands (discovered for xAI, pinned for ChatGPT).
1979 fn refresh_for_provider(
1980 provider: OAuthProvider,
1981 client: &dyn OAuthFormClient,
1982 issuer: &str,
1983 client_id: &str,
1984 refresh_token: &str,
1985 ) -> Result<OAuthTokenMaterial> {
1986 let params = oauth_provider_params(provider);
1987 let token_url = if params.discover_endpoints {
1988 resolve_oauth_endpoints(params, issuer).token_endpoint
1989 } else {
1990 form_token_url(params, issuer)
1991 };
1992 refresh_access_token_via(client, params, &token_url, client_id, refresh_token)
1993 }
1994
1995 fn configured_owned_auth_file_path(
1996 provider: OAuthProvider,
1997 config: &Config,
1998 ) -> Result<Option<PathBuf>> {
1999 let generation = config
2000 .provider_config_for(provider.api())
2001 .and_then(|entry| entry.oauth_credential_generation.as_deref());
2002 match generation {
2003 Some(generation) => provider.generation_path(generation).map(Some),
2004 None => Ok(None),
2005 }
2006 }
2007
2008 /// Prompt-free structural check for owned OAuth material. Never refreshes,
2009 /// writes, or makes network requests. External storage is not inspected
2010 /// until exact read-only consent has been persisted, and then only at the
2011 /// exact consented path — no ambient candidate is ever resolved or opened
2012 /// (#5772).
2013 #[must_use]
2014 pub fn credentials_valid(provider: OAuthProvider, config: &Config) -> bool {
2015 // Codewhale-owned OAuth bytes are inert until the provider route
2016 // explicitly selects OAuth. A failed post-login config finalization can
2017 // therefore never make a newly written token silently ready on the next
2018 // launch.
2019 if provider == OAuthProvider::Xai
2020 && !config
2021 .provider_config_for(provider.api())
2022 .and_then(|entry| entry.auth_mode.as_deref())
2023 .is_some_and(auth_mode_uses_xai_oauth)
2024 {
2025 return false;
2026 }
2027 if let Ok(Some(path)) = configured_owned_auth_file_path(provider, config)
2028 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
2029 && let Some((_, entry)) = select_entry(provider, &mut file)
2030 && (entry_access_token_is_fresh(&entry)
2031 || entry
2032 .refresh_token
2033 .as_deref()
2034 .is_some_and(|token| !token.trim().is_empty()))
2035 {
2036 return true;
2037 }
2038 if config
2039 .provider_config_for(provider.api())
2040 .and_then(|entry| entry.oauth_credential_generation.as_deref())
2041 .is_some()
2042 {
2043 // A configured generation is authoritative. Invalid, missing, unsafe,
2044 // or malformed owned storage must not fall through to an external CLI.
2045 return false;
2046 }
2047 if provider == OAuthProvider::Xai {
2048 // The pre-generation legacy file is the last owned location.
2049 if let Ok(path) = codewhale_config::legacy_xai_oauth_path()
2050 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
2051 && let Some((_, entry)) = select_entry(provider, &mut file)
2052 && (entry_access_token_is_fresh(&entry)
2053 || entry
2054 .refresh_token
2055 .as_deref()
2056 .is_some_and(|token| !token.trim().is_empty()))
2057 {
2058 return true;
2059 }
2060 // #5772: with no persisted consent record there is no external path
2061 // to resolve and nothing to open.
2062 if let Some(consent_path) = config
2063 .provider_config_for(provider.api())
2064 .and_then(|entry| entry.external_credentials.as_ref())
2065 .map(|consent| consent.path.clone())
2066 && let Ok(grant) = config.external_credential_read_grant(
2067 provider.api(),
2068 codewhale_config::ExternalCredentialSource::GrokCli,
2069 &consent_path,
2070 )
2071 && let Ok(mut file) = load_external_auth_file(&grant)
2072 {
2073 return select_entry(provider, &mut file)
2074 .is_some_and(|(_, entry)| entry_access_token_is_fresh(&entry));
2075 }
2076 }
2077 false
2078 }
2079
2080 #[must_use]
2081 pub fn credentials_present(provider: OAuthProvider, config: &Config) -> bool {
2082 credentials_valid(provider, config)
2083 }
2084
2085 /// Grant-time validation for an external Grok CLI credential file (#5772).
2086 ///
2087 /// Reads exactly the granted path through the secure adapter and requires a
2088 /// usable, unexpired entry. Never refreshes, rewrites, or makes a network
2089 /// request. Consent is persisted only after this succeeds, so a consent
2090 /// record can never be written for a file that holds nothing usable.
2091 pub fn validate_grok_external_credentials(
2092 grant: &codewhale_config::ExternalCredentialReadGrant,
2093 ) -> Result<()> {
2094 let mut file = load_external_auth_file(grant)?;
2095 let (_, entry) = select_entry(OAuthProvider::Xai, &mut file).ok_or_else(|| {
2096 anyhow::anyhow!(
2097 "xAI OAuth credentials at {} have no usable entry. Run `grok login` again or use `codewhale auth xai-device` for Codewhale-owned storage.",
2098 codewhale_config::quote_os_path(grant.path())
2099 )
2100 })?;
2101 if !entry_access_token_is_fresh(&entry) {
2102 bail!(
2103 "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`.",
2104 codewhale_config::quote_os_path(grant.path())
2105 );
2106 }
2107 Ok(())
2108 }
2109
2110 /// Load xAI OAuth credentials with full precedence: configured generation,
2111 /// legacy owned file, then the consented Grok CLI import. Codewhale-owned
2112 /// credentials may refresh and rewrite Codewhale-owned storage; external
2113 /// credentials are read-only.
2114 pub fn get_xai_credentials(config: &Config) -> Result<OwnedOAuthCredentials> {
2115 anyhow::ensure!(
2116 config.api_provider() == crate::config::ApiProvider::Xai
2117 && config
2118 .provider_config_for(crate::config::ApiProvider::Xai)
2119 .and_then(|entry| entry.auth_mode.as_deref())
2120 .is_some_and(auth_mode_uses_xai_oauth),
2121 "Codewhale-owned xAI OAuth credentials are inactive until the xAI route explicitly selects OAuth"
2122 );
2123 if let Some(owned_path) = configured_owned_auth_file_path(OAuthProvider::Xai, config)? {
2124 return get_owned_credentials_at(OAuthProvider::Xai, &owned_path);
2125 }
2126 let owned_path = codewhale_config::legacy_xai_oauth_path()?;
2127 if load_owned_auth_file(&owned_path)?.is_some() {
2128 return get_owned_credentials_at(OAuthProvider::Xai, &owned_path);
2129 }
2130
2131 let external_path = grok_auth_file_path();
2132 let grant = config.external_credential_read_grant(
2133 crate::config::ApiProvider::Xai,
2134 codewhale_config::ExternalCredentialSource::GrokCli,
2135 &external_path,
2136 )?;
2137 let mut file = load_external_auth_file(&grant)?;
2138 let (scope, entry) = select_entry(OAuthProvider::Xai, &mut file).ok_or_else(|| {
2139 anyhow::anyhow!(
2140 "xAI OAuth credentials at {} have no usable entry. Run `grok login` again or use `codewhale auth xai-device` for Codewhale-owned storage.",
2141 codewhale_config::quote_os_path(grant.path())
2142 )
2143 })?;
2144 if !entry_access_token_is_fresh(&entry) {
2145 bail!(
2146 "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`.",
2147 codewhale_config::quote_os_path(grant.path())
2148 );
2149 }
2150 let token = entry
2151 .access_token
2152 .clone()
2153 .filter(|token| !token.trim().is_empty())
2154 .context("xAI OAuth access token is empty")?;
2155 Ok(credentials_from_entry(
2156 OAuthProvider::Xai,
2157 &scope,
2158 &entry,
2159 token,
2160 ))
2161 }
2162
2163 pub fn get_xai_access_token(config: &Config) -> Result<String> {
2164 Ok(get_xai_credentials(config)?.access_token)
2165 }
2166
2167 /// Load ChatGPT owned credentials from the configured generation,
2168 /// refreshing through the seam when stale.
2169 pub fn get_owned_credentials(
2170 provider: OAuthProvider,
2171 config: &Config,
2172 ) -> Result<OwnedOAuthCredentials> {
2173 get_owned_credentials_with(provider, config, &ReqwestOAuthFormClient)
2174 }
2175
2176 fn get_owned_credentials_with(
2177 provider: OAuthProvider,
2178 config: &Config,
2179 client: &dyn OAuthFormClient,
2180 ) -> Result<OwnedOAuthCredentials> {
2181 let Some(path) = configured_owned_auth_file_path(provider, config)? else {
2182 bail!(
2183 "Codewhale-owned {} OAuth credentials are not configured",
2184 oauth_provider_params(provider).display_name
2185 );
2186 };
2187 let name = path
2188 .file_name()
2189 .and_then(|name| name.to_str())
2190 .context("Codewhale-owned OAuth path must have a UTF-8 basename")?;
2191 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
2192 get_owned_credentials_locked(provider, store, name, |issuer, client_id, refresh| {
2193 refresh_for_provider(provider, client, issuer, client_id, refresh)
2194 })
2195 })
2196 }
2197
2198 fn get_owned_credentials_at(provider: OAuthProvider, path: &Path) -> Result<OwnedOAuthCredentials> {
2199 let directory = codewhale_config::xai_oauth_credentials_dir()?;
2200 anyhow::ensure!(
2201 path.parent() == Some(directory.as_path()),
2202 "Codewhale-owned OAuth path escaped the credentials directory"
2203 );
2204 let name = path
2205 .file_name()
2206 .and_then(|name| name.to_str())
2207 .context("Codewhale-owned OAuth path must have a UTF-8 basename")?;
2208 anyhow::ensure!(
2209 name == provider.legacy_file_name() || provider.is_valid_generation(name),
2210 "Codewhale-owned OAuth path has an invalid basename"
2211 );
2212 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
2213 get_owned_credentials_locked(provider, store, name, |issuer, client_id, refresh| {
2214 refresh_for_provider(
2215 provider,
2216 &ReqwestOAuthFormClient,
2217 issuer,
2218 client_id,
2219 refresh,
2220 )
2221 })
2222 })
2223 }
2224
2225 fn get_owned_credentials_locked<F>(
2226 provider: OAuthProvider,
2227 store: &codewhale_config::XaiOAuthCredentialStore,
2228 name: &str,
2229 refresh_access: F,
2230 ) -> Result<OwnedOAuthCredentials>
2231 where
2232 F: FnOnce(&str, &str, &str) -> Result<OAuthTokenMaterial>,
2233 {
2234 let hint = oauth_provider_params(provider).relogin_hint;
2235 let path = store.path_for(name)?;
2236 let mut file = load_owned_auth_file_from_store(store, name)?.ok_or_else(|| {
2237 anyhow::anyhow!(
2238 "Codewhale-owned OAuth credentials were not found at {}. Run `{hint}` again.",
2239 codewhale_config::quote_os_path(&path)
2240 )
2241 })?;
2242 let (scope, mut entry) = select_entry(provider, &mut file).ok_or_else(|| {
2243 anyhow::anyhow!(
2244 "Codewhale-owned OAuth credentials at {} have no usable entry. Run `{hint}` again.",
2245 codewhale_config::quote_os_path(&path)
2246 )
2247 })?;
2248
2249 if entry_access_token_is_fresh(&entry) {
2250 let token = entry
2251 .access_token
2252 .clone()
2253 .filter(|t| !t.trim().is_empty())
2254 .context("OAuth access token is empty")?;
2255 return Ok(credentials_from_entry(provider, &scope, &entry, token));
2256 }
2257
2258 let refresh = entry
2259 .refresh_token
2260 .as_deref()
2261 .filter(|t| !t.trim().is_empty())
2262 .context(format!(
2263 "OAuth access token expired and no refresh_token is stored. Run `{hint}` again."
2264 ))?;
2265 let issuer = entry
2266 .oidc_issuer
2267 .clone()
2268 .filter(|s| !s.trim().is_empty())
2269 .unwrap_or_else(|| issuer_from_scope(provider, &scope));
2270 let client_id = entry
2271 .oidc_client_id
2272 .clone()
2273 .filter(|s| !s.trim().is_empty())
2274 .unwrap_or_else(|| client_id_from_scope(provider, &scope));
2275
2276 let refreshed = refresh_access(&issuer, &client_id, refresh)?;
2277 apply_token_response(provider, &mut entry, &issuer, &client_id, &refreshed)?;
2278 file.insert(scope.clone(), entry.clone());
2279 write_auth_file_to_store(store, name, &file, true)?;
2280
2281 let token = entry
2282 .access_token
2283 .clone()
2284 .filter(|t| !t.trim().is_empty())
2285 .context("OAuth refresh returned an empty access token")?;
2286 Ok(credentials_from_entry(provider, &scope, &entry, token))
2287 }
2288
2289 /// Commit a pending login as a uniquely named owned generation and
2290 /// atomically point the provider's config table at it under the shared
2291 /// config lock.
2292 ///
2293 /// The credential file is staged while the config lock is held. If config
2294 /// persistence fails, the unreferenced stage is removed. Only after the new
2295 /// pointer commits is the previously selected generation removed best-effort.
2296 pub fn activate_login(
2297 pending: PendingOAuthLogin,
2298 config_path: Option<&Path>,
2299 live_config: Option<&mut Config>,
2300 ) -> Result<OAuthActivation> {
2301 codewhale_config::with_xai_oauth_lifecycle_lock(move |store| {
2302 activate_login_locked(pending, config_path, live_config, store)
2303 })
2304 }
2305
2306 fn activate_login_locked(
2307 pending: PendingOAuthLogin,
2308 config_path: Option<&Path>,
2309 live_config: Option<&mut Config>,
2310 store: &codewhale_config::XaiOAuthCredentialStore,
2311 ) -> Result<OAuthActivation> {
2312 let provider = pending.provider;
2313 let display_name = oauth_provider_params(provider).display_name;
2314 let config_path = crate::config_persistence::config_toml_path(config_path)?;
2315 let generation = provider.new_generation();
2316 provider.validate_generation(&generation)?;
2317 let auth_path = store.path_for(&generation)?;
2318 let key_inside = provider.config_key();
2319 let mut stage_written = false;
2320
2321 let activation = codewhale_config::mutate_config_document(&config_path, |document| {
2322 let previous_generation_item = document
2323 .get("providers")
2324 .and_then(toml_edit::Item::as_table_like)
2325 .and_then(|providers| providers.get(key_inside))
2326 .and_then(toml_edit::Item::as_table_like)
2327 .and_then(|provider| provider.get("oauth_credential_generation"));
2328 let previous_generation = previous_generation_item
2329 .map(|item| {
2330 item.as_str()
2331 .context(format!(
2332 "refusing {display_name} login because the existing credential generation pointer is not a string"
2333 ))
2334 .map(ToOwned::to_owned)
2335 })
2336 .transpose()?;
2337 if let Some(previous) = previous_generation.as_deref() {
2338 provider.validate_generation(previous).with_context(|| {
2339 format!(
2340 "refusing {display_name} login because the existing credential generation pointer is invalid"
2341 )
2342 })?;
2343 }
2344
2345 let previous_owned_name = match previous_generation.as_deref() {
2346 Some(previous) => Some(previous.to_string()),
2347 None if store.read_to_string(provider.legacy_file_name())?.is_some() => {
2348 Some(provider.legacy_file_name().to_string())
2349 }
2350 None => None,
2351 };
2352 // Carry the previous generation's other scopes forward. A valid
2353 // pointer whose file is gone (interrupted revocation, external
2354 // cleanup) must not brick login: only a successful activation can
2355 // ever rewrite the pointer, so treat the missing generation like a
2356 // fresh start instead of failing (#5032).
2357 let mut file = match previous_owned_name.as_deref() {
2358 Some(name) => load_owned_auth_file_from_store(store, name)?.unwrap_or_else(|| {
2359 tracing::warn!(
2360 target: "codewhale::oauth",
2361 generation = name,
2362 "config pointed at a missing owned OAuth generation; starting a fresh credential file"
2363 );
2364 BTreeMap::new()
2365 }),
2366 None => BTreeMap::new(),
2367 };
2368 let scope = format!("{}::{}", pending.issuer, pending.client_id);
2369 let mut entry = file.remove(&scope).unwrap_or_else(|| OwnedAuthEntry {
2370 access_token: None,
2371 refresh_token: None,
2372 expires_at: None,
2373 id_token: None,
2374 account_id: None,
2375 oidc_issuer: Some(pending.issuer.clone()),
2376 oidc_client_id: Some(pending.client_id.clone()),
2377 originator: None,
2378 auth_mode: Some("oidc".to_string()),
2379 extra: BTreeMap::new(),
2380 });
2381 apply_token_response(
2382 provider,
2383 &mut entry,
2384 &pending.issuer,
2385 &pending.client_id,
2386 &pending.token,
2387 )?;
2388 let access = entry
2389 .access_token
2390 .clone()
2391 .filter(|token| !token.trim().is_empty())
2392 .context(format!(
2393 "{display_name} login returned an empty access token"
2394 ))?;
2395 file.insert(scope.clone(), entry.clone());
2396 write_auth_file_to_store(store, &generation, &file, false)?;
2397 stage_written = true;
2398
2399 codewhale_config::set_config_document_value(
2400 document,
2401 &["providers", key_inside, "auth_mode"],
2402 "oauth",
2403 )?;
2404 codewhale_config::set_config_document_value(
2405 document,
2406 &["providers", key_inside, "oauth_credential_generation"],
2407 generation.clone(),
2408 )?;
2409 codewhale_config::unset_config_document_value(
2410 document,
2411 &["providers", key_inside, "external_credentials"],
2412 )?;
2413 Ok((
2414 previous_owned_name,
2415 credentials_from_entry(provider, &scope, &entry, access),
2416 ))
2417 });
2418
2419 let (previous_owned_name, credentials) = match activation {
2420 Ok(activation) => activation,
2421 Err(error) => {
2422 if stage_written && let Err(cleanup_error) = store.remove(&generation) {
2423 return Err(error).context(format!(
2424 "{display_name} login was not activated; also failed to remove unreferenced staged credentials at {}: {cleanup_error}",
2425 codewhale_config::quote_os_path(&auth_path)
2426 ));
2427 }
2428 return Err(error).context(format!(
2429 "{display_name} login was not activated; provider configuration is unchanged"
2430 ));
2431 }
2432 };
2433
2434 if let Some(config) = live_config {
2435 match provider {
2436 OAuthProvider::Xai => config.mark_codewhale_owned_xai_oauth(generation.clone()),
2437 OAuthProvider::Chatgpt => {
2438 config.mark_codewhale_owned_chatgpt_oauth(generation.clone());
2439 }
2440 }
2441 }
2442 if let Some(previous) = previous_owned_name
2443 && previous != generation
2444 && let Err(error) = store.remove(&previous)
2445 {
2446 tracing::warn!(
2447 target: "codewhale::oauth",
2448 error = %error,
2449 "new OAuth generation committed but superseded generation cleanup failed"
2450 );
2451 }
2452 eprintln!(
2453 "Signed in with {display_name}. Codewhale-owned credentials activated at {}.",
2454 codewhale_config::quote_os_path(&auth_path)
2455 );
2456 Ok(OAuthActivation {
2457 credentials,
2458 config_path,
2459 auth_path,
2460 })
2461 }
2462
2463 /// Remove Codewhale-owned tokens and the config pointer for one provider.
2464 ///
2465 /// ChatGPT's remote revoke is best-effort against the row's pinned revoke
2466 /// path (see [`OAuthProviderParams::revoke_path`]): a failed or unreachable
2467 /// revoke must never stop the local credentials from being removed. xAI
2468 /// revokes locally only. External CLI consent is left untouched.
2469 pub fn revoke_owned_login(
2470 provider: OAuthProvider,
2471 config_path: Option<&Path>,
2472 live_config: Option<&mut Config>,
2473 ) -> Result<()> {
2474 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
2475 revoke_owned_login_locked(provider, config_path, live_config, store)
2476 })
2477 }
2478
2479 fn revoke_owned_login_locked(
2480 provider: OAuthProvider,
2481 config_path: Option<&Path>,
2482 live_config: Option<&mut Config>,
2483 store: &codewhale_config::XaiOAuthCredentialStore,
2484 ) -> Result<()> {
2485 revoke_owned_login_locked_with(
2486 provider,
2487 config_path,
2488 live_config,
2489 store,
2490 &ReqwestOAuthFormClient,
2491 )
2492 }
2493
2494 fn revoke_owned_login_locked_with(
2495 provider: OAuthProvider,
2496 config_path: Option<&Path>,
2497 live_config: Option<&mut Config>,
2498 store: &codewhale_config::XaiOAuthCredentialStore,
2499 client: &dyn OAuthFormClient,
2500 ) -> Result<()> {
2501 let config_path = crate::config_persistence::config_toml_path(config_path)?;
2502 let key_inside = provider.config_key();
2503 let previous = codewhale_config::mutate_config_document(&config_path, |document| {
2504 let previous = document
2505 .get("providers")
2506 .and_then(toml_edit::Item::as_table_like)
2507 .and_then(|providers| providers.get(key_inside))
2508 .and_then(toml_edit::Item::as_table_like)
2509 .and_then(|provider| provider.get("oauth_credential_generation"))
2510 .and_then(toml_edit::Item::as_str)
2511 .map(ToOwned::to_owned);
2512 codewhale_config::unset_config_document_value(
2513 document,
2514 &["providers", key_inside, "oauth_credential_generation"],
2515 )?;
2516 let auth_mode_is_oauth = document
2517 .get("providers")
2518 .and_then(toml_edit::Item::as_table_like)
2519 .and_then(|providers| providers.get(key_inside))
2520 .and_then(toml_edit::Item::as_table_like)
2521 .and_then(|provider| provider.get("auth_mode"))
2522 .and_then(toml_edit::Item::as_str)
2523 == Some("oauth");
2524 if auth_mode_is_oauth {
2525 codewhale_config::unset_config_document_value(
2526 document,
2527 &["providers", key_inside, "auth_mode"],
2528 )?;
2529 }
2530 Ok(previous)
2531 })?;
2532 if let Some(config) = live_config
2533 && provider == OAuthProvider::Chatgpt
2534 {
2535 config.clear_codewhale_owned_chatgpt_oauth();
2536 }
2537 let names = match previous.as_deref() {
2538 Some(generation) if provider.is_valid_generation(generation) => {
2539 vec![generation.to_string()]
2540 }
2541 _ => vec![provider.legacy_file_name().to_string()],
2542 };
2543 for name in names {
2544 if let Ok(Some(raw)) = store.read_to_string(&name)
2545 && let Ok(file) = parse_auth_file(&raw, &store.path_for(&name)?)
2546 {
2547 for entry in file.values() {
2548 if let Some(token) = entry
2549 .refresh_token
2550 .as_deref()
2551 .or(entry.access_token.as_deref())
2552 .filter(|token| !token.trim().is_empty())
2553 {
2554 let issuer = entry
2555 .oidc_issuer
2556 .as_deref()
2557 .unwrap_or_else(|| oauth_provider_params(provider).default_issuer);
2558 let client_id = entry
2559 .oidc_client_id
2560 .as_deref()
2561 .unwrap_or_else(|| oauth_provider_params(provider).default_client_id);
2562 if let Err(error) = revoke_remote_token_via(
2563 client,
2564 oauth_provider_params(provider),
2565 issuer,
2566 client_id,
2567 token,
2568 ) {
2569 tracing::warn!(
2570 target: "codewhale::oauth",
2571 error = %error,
2572 "remote OAuth revoke failed; local credentials will still be removed"
2573 );
2574 }
2575 }
2576 }
2577 }
2578 let _ = store.remove(&name);
2579 }
2580 Ok(())
2581 }
2582
2583 /// Detect the [#5032] bricked-launch state: the provider's config selects
2584 /// OAuth and points `oauth_credential_generation` at a Codewhale-owned
2585 /// credential file that no longer exists. This is a distinct, more specific
2586 /// failure than "unconfigured" — the pointer is present and authoritative,
2587 /// so [`credentials_valid`] returns false and cannot fall through to a
2588 /// legacy or external credential, which is exactly what bricked the
2589 /// dogfood machine.
2590 ///
2591 /// Returns false for any other state: OAuth not selected, no generation
2592 /// configured, a malformed generation pointer (a different, already
2593 /// fail-closed failure), or a generation whose owned file is present.
2594 ///
2595 /// [#5032]: https://github.com/Hmbown/CodeWhale/issues/5032
2596 #[must_use]
2597 pub fn owned_generation_is_dangling(provider: OAuthProvider, config: &Config) -> bool {
2598 if provider == OAuthProvider::Xai
2599 && !config
2600 .provider_config_for(provider.api())
2601 .and_then(|entry| entry.auth_mode.as_deref())
2602 .is_some_and(auth_mode_uses_xai_oauth)
2603 {
2604 return false;
2605 }
2606 match configured_owned_auth_file_path(provider, config) {
2607 Ok(Some(path)) => !path.exists(),
2608 // `None` => no generation configured (not a dangling pointer). `Err` =>
2609 // the generation is malformed/invalid; that is a different, already
2610 // fail-closed failure, not the missing-file state this detects.
2611 _ => false,
2612 }
2613 }
2614
2615 /// Best-effort repair for the [#5032] bricked-launch state: remove the stale
2616 /// `oauth_credential_generation` pointer from the PERSISTED config file so
2617 /// the next launch is no longer bricked. Mirrors the document edits in
2618 /// [`activate_login_locked`] (which replaces the pointer under the config
2619 /// lock) and [`crate::config::clear_api_key`]'s unlocked scrub.
2620 ///
2621 /// Leaves `auth_mode = "oauth"` intact: the user still wants OAuth, they
2622 /// simply need to re-authenticate. The launch-path caller must treat any
2623 /// error as non-fatal — log a warning and continue. Returns `Ok(())` when
2624 /// the stale pointer was removed (or was already absent).
2625 ///
2626 /// [#5032]: https://github.com/Hmbown/CodeWhale/issues/5032
2627 pub fn clear_dangling_generation(
2628 provider: OAuthProvider,
2629 config_path: Option<&Path>,
2630 ) -> Result<()> {
2631 let config_path = crate::config_persistence::config_toml_path(config_path)?;
2632 let key_inside = provider.config_key();
2633 codewhale_config::mutate_config_document(&config_path, |document| {
2634 codewhale_config::unset_config_document_value(
2635 document,
2636 &["providers", key_inside, "oauth_credential_generation"],
2637 )?;
2638 Ok(())
2639 })
2640 }
2641
2642 /// Whether `[providers.xai] auth_mode` selects the OAuth path.
2643 #[must_use]
2644 pub fn auth_mode_uses_xai_oauth(mode: &str) -> bool {
2645 matches!(
2646 normalize_auth_mode(mode).as_str(),
2647 "oauth"
2648 | "xai_oauth"
2649 | "xai"
2650 | "grok"
2651 | "grok_oauth"
2652 | "grok_cli"
2653 | "device"
2654 | "device_code"
2655 | "device_auth"
2656 )
2657 }
2658
2659 fn normalize_auth_mode(mode: &str) -> String {
2660 mode.trim().to_ascii_lowercase().replace(['-', ' '], "_")
2661 }
2662
2663 /// Resolve the Grok CLI auth file path.
2664 ///
2665 /// Priority:
2666 /// 1. `GROK_AUTH_PATH` / `XAI_AUTH_PATH`
2667 /// 2. `$GROK_HOME/auth.json`
2668 /// 3. `~/.grok/auth.json`
2669 #[must_use]
2670 pub fn grok_auth_file_path() -> PathBuf {
2671 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
2672 if let Ok(path) = std::env::var(key) {
2673 let p = PathBuf::from(path.trim());
2674 if !p.as_os_str().is_empty() {
2675 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
2676 }
2677 }
2678 }
2679 if let Ok(home) = std::env::var("GROK_HOME") {
2680 let p = PathBuf::from(home.trim());
2681 if !p.as_os_str().is_empty() {
2682 let path = p.join("auth.json");
2683 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
2684 }
2685 }
2686 let path = crate::config::effective_home_dir()
2687 .unwrap_or_else(|| PathBuf::from("."))
2688 .join(".grok")
2689 .join("auth.json");
2690 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
2691 }
2692
2693 #[must_use]
2694 pub fn missing_auth_message(provider: OAuthProvider) -> String {
2695 match provider {
2696 OAuthProvider::Xai => format!(
2697 "xAI OAuth credentials not found.\n\
2698 Options:\n\
2699 1. Run `codewhale auth xai-device` for Codewhale-owned OAuth storage\n\
2700 2. To read an existing Grok CLI login without changing it, run \
2701 `codewhale auth external-consent --provider xai --mode read-only --path {}`\n\
2702 3. Or use API-key auth: export XAI_API_KEY=... / \
2703 codewhale auth set --provider xai",
2704 codewhale_config::quote_os_path(&grok_auth_file_path())
2705 ),
2706 OAuthProvider::Chatgpt => format!(
2707 "OpenAI Codex OAuth credentials are unavailable.\n\
2708 \n\
2709 Sign in with ChatGPT (subscription billing, Codewhale-owned tokens):\n\
2710 `codewhale auth chatgpt` or /provider setup openai-codex.\n\
2711 The openai API-key route is a different billing owner.\n\
2712 \n\
2713 Alternatives:\n\
2714 - Process token: OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN\n\
2715 - Explicit Codex CLI import (not a prerequisite): after `codex login`, run \
2716 `codewhale auth external-consent --provider openai-codex --mode read-only --path {}`\n\
2717 Read-only access never refreshes or rewrites the Codex CLI file.\n\
2718 Revoke Codewhale-owned tokens with `codewhale auth chatgpt-revoke`.",
2719 codewhale_config::quote_os_path(&auth_file_path())
2720 ),
2721 }
2722 }
2723
2724 /// Pending-login test constructor shared by the activation tests.
2725 #[cfg(test)]
2726 pub(crate) fn pending_login_for_test(
2727 provider: OAuthProvider,
2728 access_token: &str,
2729 refresh_token: &str,
2730 ) -> PendingOAuthLogin {
2731 pending_login_with_id_token_for_test(provider, access_token, refresh_token, None)
2732 }
2733
2734 #[cfg(test)]
2735 pub(crate) fn pending_login_with_id_token_for_test(
2736 provider: OAuthProvider,
2737 access_token: &str,
2738 refresh_token: &str,
2739 id_token: Option<&str>,
2740 ) -> PendingOAuthLogin {
2741 PendingOAuthLogin {
2742 provider,
2743 issuer: oauth_provider_params(provider).default_issuer.to_string(),
2744 client_id: oauth_provider_params(provider)
2745 .default_client_id
2746 .to_string(),
2747 token: OAuthTokenMaterial {
2748 access_token: Some(access_token.to_string()),
2749 refresh_token: Some(refresh_token.to_string()),
2750 expires_in: Some(3600),
2751 id_token: id_token.map(ToOwned::to_owned),
2752 interval: None,
2753 error: None,
2754 error_description: None,
2755 },
2756 }
2757 }
2758
2759 #[cfg(test)]
2760 mod tests {
2761 use super::*;
2762 use crate::config::ApiProvider;
2763 use std::fs;
2764 #[cfg(unix)]
2765 use std::os::unix::fs::PermissionsExt;
2766
2767 #[test]
2768 fn oauth_endpoint_requires_https_or_parsed_loopback_http() {
2769 for raw in [
2770 "https://issuer.example/token",
2771 "http://localhost:8123/token",
2772 "http://127.0.0.1:8123/token",
2773 "http://127.0.0.2/token",
2774 "http://[::1]:8123/token",
2775 ] {
2776 assert!(oauth_endpoint_url(raw).is_ok());
2777 }
2778 for raw in [
2779 "http://issuer.example/token",
2780 "http://localhost.example/token",
2781 "http://127.0.0.1.example/token",
2782 "http://192.0.2.1/token",
2783 "https://user:example@issuer.example/token",
2784 "file:///tmp/token",
2785 "not a URL",
2786 ] {
2787 assert!(oauth_endpoint_url(raw).is_err());
2788 }
2789 }
2790
2791 #[test]
2792 fn oauth_discovery_rejects_an_initial_plaintext_remote_issuer() {
2793 let issuer = "http://issuer.example";
2794 assert!(validate_discovered_issuer(Some(issuer.into()), issuer).is_err());
2795 assert!(
2796 validate_discovered_oauth_endpoint(
2797 Some(format!("{issuer}/token")),
2798 "token_endpoint",
2799 issuer,
2800 )
2801 .is_err()
2802 );
2803 let fallback = fallback_oauth_endpoints(&XAI_OAUTH_PARAMS, issuer);
2804 assert!(oauth_endpoint_url(&fallback.token_endpoint).is_err());
2805 assert!(
2806 oauth_endpoint_url(fallback.device_authorization_endpoint.as_deref().unwrap()).is_err()
2807 );
2808 }
2809
2810 #[test]
2811 fn oauth_authorize_url_refuses_plaintext_remote_issuer_before_browser_use() {
2812 let pkce = PkceChallenge {
2813 verifier: "test-verifier".into(),
2814 challenge: "test-challenge".into(),
2815 };
2816 for issuer in [
2817 "http://issuer.example",
2818 "https://user:example@issuer.example",
2819 ] {
2820 assert!(
2821 build_authorize_url(
2822 &CHATGPT_OAUTH_PARAMS,
2823 issuer,
2824 "test-client",
2825 "openid",
2826 "http://localhost:1455/auth/callback",
2827 "test-state",
2828 &pkce,
2829 )
2830 .is_err()
2831 );
2832 }
2833 }
2834
2835 fn grant(path: &std::path::Path) -> ExternalCredentialReadGrant {
2836 codewhale_config::ExternalCredentialConsentToml::read_only(
2837 codewhale_config::ProviderKind::OpenaiCodex,
2838 codewhale_config::ExternalCredentialSource::CodexCli,
2839 path.to_path_buf(),
2840 )
2841 .read_grant(
2842 codewhale_config::ProviderKind::OpenaiCodex,
2843 codewhale_config::ExternalCredentialSource::CodexCli,
2844 path,
2845 )
2846 .expect("test read grant")
2847 }
2848
2849 #[test]
2850 fn jwt_expiry_parses_valid_token() {
2851 // A minimal JWT with {"exp": 9999999999} as payload.
2852 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
2853 let token = format!("header.{payload}.signature");
2854 assert_eq!(jwt_expiry_seconds(&token), Some(9999999999));
2855 }
2856
2857 #[test]
2858 fn jwt_expiry_returns_none_for_malformed() {
2859 assert_eq!(jwt_expiry_seconds("not.a.jwt"), None);
2860 assert_eq!(jwt_expiry_seconds(""), None);
2861 assert_eq!(jwt_expiry_seconds("x"), None);
2862 }
2863
2864 #[test]
2865 fn token_is_expired_detects_future() {
2866 // Far future — should not be expired.
2867 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
2868 let token = format!("header.{payload}.sig");
2869 assert!(!token_is_expired(&token));
2870 }
2871
2872 #[test]
2873 fn token_is_expired_detects_past() {
2874 // Way in the past.
2875 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
2876 let token = format!("header.{payload}.sig");
2877 assert!(token_is_expired(&token));
2878 }
2879
2880 #[test]
2881 fn owned_token_freshness_honors_both_expiries_and_preserves_fallbacks() {
2882 let now = now_unix_secs().expect("clock");
2883 let future = rfc3339_from_unix(now + 3600);
2884 let past = rfc3339_from_unix(now - 3600);
2885 let near = rfc3339_from_unix(now + 30);
2886 let fresh_token = jwt_with_exp((now + 3600) as u64);
2887 let expired_token = jwt_with_exp((now - 3600) as u64);
2888 let near_token = jwt_with_exp((now + 30) as u64);
2889
2890 for (stored, token, expected) in [
2891 (Some(future.as_str()), expired_token.as_str(), false),
2892 (Some(past.as_str()), fresh_token.as_str(), false),
2893 (Some(future.as_str()), fresh_token.as_str(), true),
2894 (Some(future.as_str()), near_token.as_str(), false),
2895 (Some(near.as_str()), fresh_token.as_str(), false),
2896 (None, fresh_token.as_str(), true),
2897 (None, expired_token.as_str(), false),
2898 (Some("invalid-date"), fresh_token.as_str(), true),
2899 (Some(future.as_str()), "opaque-token", true),
2900 (Some(past.as_str()), "opaque-token", false),
2901 (None, "opaque-token", false),
2902 (Some("invalid-date"), "opaque-token", false),
2903 (Some(future.as_str()), "", false),
2904 ] {
2905 let entry: OwnedAuthEntry = serde_json::from_value(serde_json::json!({
2906 "access_token": token,
2907 "expires_at": stored,
2908 }))
2909 .expect("synthetic owned entry");
2910 assert_eq!(
2911 entry_access_token_is_fresh(&entry),
2912 expected,
2913 "stored={stored:?}, JWT expiry={:?}",
2914 jwt_expiry_seconds(token),
2915 );
2916 }
2917 }
2918
2919 #[test]
2920 fn credential_presence_rejects_empty_and_malformed_files_without_refresh() {
2921 let _lock = crate::test_support::lock_test_env();
2922 let home = tempfile::tempdir().expect("temp Codex home");
2923 let auth_path = home
2924 .path()
2925 .canonicalize()
2926 .expect("canonical temp root")
2927 .join("auth.json");
2928 let _auth = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &auth_path);
2929 let _access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
2930 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
2931 let grant = grant(&auth_path);
2932
2933 std::fs::write(&auth_path, "{}").expect("empty auth");
2934 crate::external_credentials::reset_side_effect_trap();
2935 assert!(!stored_credentials_present(&grant));
2936 assert_eq!(
2937 crate::external_credentials::side_effect_trap_counts(),
2938 (1, 1)
2939 );
2940 assert_eq!(
2941 crate::external_credentials::complete_side_effect_trap_counts(),
2942 (1, 1, 0, 0, 0)
2943 );
2944 std::fs::write(&auth_path, "{not-json").expect("malformed auth");
2945 crate::external_credentials::reset_side_effect_trap();
2946 assert!(!stored_credentials_present(&grant));
2947 assert_eq!(
2948 crate::external_credentials::side_effect_trap_counts(),
2949 (1, 1)
2950 );
2951
2952 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
2953 let access_token = format!("header.{payload}.signature");
2954 std::fs::write(
2955 &auth_path,
2956 serde_json::to_vec(&serde_json::json!({
2957 "tokens": {"access_token": access_token}
2958 }))
2959 .expect("valid auth json"),
2960 )
2961 .expect("valid auth");
2962 crate::external_credentials::reset_side_effect_trap();
2963 assert!(stored_credentials_present(&grant));
2964 assert_eq!(
2965 crate::external_credentials::side_effect_trap_counts(),
2966 (1, 1)
2967 );
2968 }
2969
2970 #[test]
2971 fn expired_external_token_fails_without_refresh_or_rewrite() {
2972 let _lock = crate::test_support::lock_test_env();
2973 let home = tempfile::tempdir().expect("temp Codex home");
2974 let auth_path = home
2975 .path()
2976 .canonicalize()
2977 .expect("canonical temp root")
2978 .join("auth.json");
2979 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
2980 let access_token = format!("header.{payload}.signature");
2981 let raw = serde_json::to_string_pretty(&serde_json::json!({
2982 "tokens": {
2983 "access_token": access_token,
2984 "refresh_token": "must-never-be-used",
2985 "account_id": "acct-test",
2986 "future_field": {"preserve": true}
2987 },
2988 "future_top_level": [1, 2, 3]
2989 }))
2990 .expect("auth fixture");
2991 std::fs::write(&auth_path, &raw).expect("expired auth fixture");
2992
2993 crate::external_credentials::reset_side_effect_trap();
2994 let error = get_credentials(&grant(&auth_path))
2995 .expect_err("read-only external tokens must not refresh");
2996 assert!(error.to_string().contains("never refreshes or rewrites"));
2997 assert_eq!(
2998 crate::external_credentials::side_effect_trap_counts(),
2999 (1, 1)
3000 );
3001 assert_eq!(
3002 std::fs::read_to_string(&auth_path).expect("unchanged auth file"),
3003 raw
3004 );
3005 }
3006
3007 #[test]
3008 fn auth_file_path_respects_env() {
3009 // Just verify it returns a path without panicking.
3010 let path = auth_file_path();
3011 assert!(path.to_string_lossy().contains("auth.json"));
3012 }
3013
3014 #[test]
3015 fn missing_auth_message_explains_disabled_default_and_explicit_consent() {
3016 let _lock = crate::test_support::lock_test_env();
3017 let message = missing_auth_message(OAuthProvider::Chatgpt);
3018
3019 assert!(message.contains("OpenAI Codex OAuth credentials are unavailable"));
3020 assert!(message.contains("OPENAI_CODEX_ACCESS_TOKEN"));
3021 assert!(message.contains("CODEX_ACCESS_TOKEN"));
3022 assert!(message.contains(&codewhale_config::quote_os_path(&auth_file_path())));
3023 assert!(message.contains("codewhale auth chatgpt"));
3024 assert!(message.contains("subscription billing"));
3025 assert!(message.contains("openai API-key"));
3026 assert!(message.contains("codex login"));
3027 assert!(message.contains("external-consent"));
3028 assert!(message.contains("chatgpt-revoke"));
3029 }
3030
3031 #[test]
3032 fn provider_table_separates_device_flow_from_browser_flow() {
3033 let xai = oauth_provider_params(OAuthProvider::Xai);
3034 assert_eq!(xai.device_code_path, Some("oauth2/device/code"));
3035 assert!(xai.discover_endpoints);
3036 let chatgpt = oauth_provider_params(OAuthProvider::Chatgpt);
3037 assert_eq!(chatgpt.device_code_path, None);
3038 assert!(!chatgpt.discover_endpoints);
3039 assert_ne!(xai.default_client_id, chatgpt.default_client_id);
3040 }
3041
3042 #[test]
3043 fn provider_inputs_resolve_from_defaults_without_env() {
3044 let _lock = crate::test_support::lock_test_env();
3045 let _guards: Vec<_> = [
3046 "GROK_OIDC_ISSUER",
3047 "XAI_OIDC_ISSUER",
3048 "GROK_OIDC_CLIENT_ID",
3049 "XAI_OIDC_CLIENT_ID",
3050 "GROK_OIDC_SCOPES",
3051 "XAI_OIDC_SCOPES",
3052 "CODEWHALE_XAI_OAUTH_NO_BROWSER",
3053 ]
3054 .into_iter()
3055 .map(crate::test_support::EnvVarGuard::remove)
3056 .collect();
3057 let inputs = XAI_OAUTH_PARAMS.resolve_inputs();
3058 assert_eq!(inputs.issuer, "https://auth.x.ai");
3059 assert!(inputs.scopes.contains("grok-cli:access"));
3060 assert!(inputs.open_browser);
3061 }
3062
3063 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3064 async fn request_device_grant_round_trips_a_mock_grant() {
3065 use wiremock::matchers::{method, path};
3066 use wiremock::{Mock, MockServer, ResponseTemplate};
3067 let server = MockServer::start().await;
3068 Mock::given(method("POST"))
3069 .and(path("/oauth2/device/code"))
3070 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3071 "device_code": "device-123",
3072 "user_code": "USER-456",
3073 "verification_uri": "https://example.com/device",
3074 "expires_in": 900
3075 })))
3076 .expect(1)
3077 .mount(&server)
3078 .await;
3079 let grant = tokio::task::block_in_place(|| {
3080 request_device_grant(
3081 &format!("{}/oauth2/device/code", server.uri()),
3082 "test-client",
3083 "openid",
3084 )
3085 })
3086 .expect("mock grant");
3087 assert_eq!(grant.device_code.as_deref(), Some("device-123"));
3088 assert_eq!(grant.user_code.as_deref(), Some("USER-456"));
3089 }
3090
3091 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3092 async fn request_device_grant_rejects_success_without_codes() {
3093 use wiremock::matchers::{method, path};
3094 use wiremock::{Mock, MockServer, ResponseTemplate};
3095 let server = MockServer::start().await;
3096 Mock::given(method("POST"))
3097 .and(path("/oauth2/device/code"))
3098 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3099 .expect(1)
3100 .mount(&server)
3101 .await;
3102 let result = tokio::task::block_in_place(|| {
3103 request_device_grant(
3104 &format!("{}/oauth2/device/code", server.uri()),
3105 "test-client",
3106 "openid",
3107 )
3108 });
3109 let Err(error) = result else {
3110 panic!("a grant without codes must fail");
3111 };
3112 assert!(error.to_string().contains("without a device and user code"));
3113 }
3114
3115 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3116 async fn poll_device_grant_classifies_rfc8628_states() {
3117 use codewhale_config::device_code::DevicePollOutcome;
3118 use wiremock::matchers::{method, path};
3119 use wiremock::{Mock, MockServer, ResponseTemplate};
3120 async fn outcome(
3121 body: serde_json::Value,
3122 status: u16,
3123 ) -> Result<DevicePollOutcome<OAuthTokenMaterial>> {
3124 let server = MockServer::start().await;
3125 Mock::given(method("POST"))
3126 .and(path("/oauth2/token"))
3127 .respond_with(ResponseTemplate::new(status).set_body_json(body))
3128 .expect(1)
3129 .mount(&server)
3130 .await;
3131 tokio::task::block_in_place(|| {
3132 poll_device_grant(
3133 &format!("{}/oauth2/token", server.uri()),
3134 "test-client",
3135 "device-token",
3136 )
3137 })
3138 }
3139 assert!(matches!(
3140 outcome(serde_json::json!({ "error": "authorization_pending" }), 400).await,
3141 Ok(DevicePollOutcome::Pending)
3142 ));
3143 assert!(matches!(
3144 outcome(
3145 serde_json::json!({ "error": "slow_down", "interval": 12 }),
3146 400
3147 )
3148 .await,
3149 Ok(DevicePollOutcome::SlowDown {
3150 interval_seconds: Some(12)
3151 })
3152 ));
3153 for error in ["access_denied", "expired_token"] {
3154 let result = outcome(serde_json::json!({ "error": error }), 400).await;
3155 let Err(failure) = result else {
3156 panic!("{error} must stop polling");
3157 };
3158 assert!(failure.to_string().contains(error), "{failure}");
3159 }
3160 let result = outcome(
3161 serde_json::json!({ "access_token": "at", "expires_in": 3600 }),
3162 200,
3163 )
3164 .await;
3165 let Ok(DevicePollOutcome::Complete(material)) = result else {
3166 panic!("success must complete");
3167 };
3168 assert_eq!(material.access_token.as_deref(), Some("at"));
3169 }
3170
3171 #[tokio::test]
3172 async fn device_login_without_a_device_flow_fails_before_network() {
3173 let result = device_code_login(OAuthProvider::Chatgpt).await;
3174 let Err(error) = result else {
3175 panic!("ChatGPT has no device flow");
3176 };
3177 assert!(
3178 error.to_string().contains("no device-code flow"),
3179 "{error:#}"
3180 );
3181 }
3182
3183 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3184 async fn oauth_transports_never_forward_forms_to_redirect_destinations() {
3185 use wiremock::matchers::{method, path};
3186 use wiremock::{Mock, MockServer, ResponseTemplate};
3187
3188 let issuer = MockServer::start().await;
3189 let destination = MockServer::start().await;
3190 Mock::given(method("POST"))
3191 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3192 "access_token": "synthetic-access",
3193 })))
3194 .expect(0)
3195 .mount(&destination)
3196 .await;
3197 for status in [301, 302, 303, 307, 308] {
3198 let endpoint = format!("{}/redirect-{status}", issuer.uri());
3199 Mock::given(path(format!("/redirect-{status}")))
3200 .respond_with(
3201 ResponseTemplate::new(status)
3202 .insert_header("Location", format!("{}/token", destination.uri()))
3203 .set_body_json(serde_json::json!({})),
3204 )
3205 .expect(3)
3206 .mount(&issuer)
3207 .await;
3208 tokio::task::block_in_place(|| {
3209 assert!(request_device_grant(&endpoint, "synthetic-client", "scope").is_err());
3210 assert!(
3211 poll_device_grant(&endpoint, "synthetic-client", "synthetic-device").is_err()
3212 );
3213 let (actual_status, _) = ReqwestOAuthFormClient
3214 .post_form(&endpoint, &[("refresh_token", "synthetic-refresh")])
3215 .unwrap();
3216 assert_eq!(actual_status, status);
3217 });
3218 }
3219 // An explicitly selected issuer remains usable without a redirect.
3220 Mock::given(path("/token"))
3221 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3222 "device_code": "synthetic-device",
3223 "user_code": "synthetic-user",
3224 "access_token": "synthetic-access",
3225 })))
3226 .expect(3)
3227 .mount(&issuer)
3228 .await;
3229 let endpoint = format!("{}/token", issuer.uri());
3230 tokio::task::block_in_place(|| {
3231 assert!(request_device_grant(&endpoint, "synthetic-client", "scope").is_ok());
3232 assert!(poll_device_grant(&endpoint, "synthetic-client", "synthetic-device").is_ok());
3233 assert_eq!(
3234 ReqwestOAuthFormClient
3235 .post_form(&endpoint, &[("refresh_token", "synthetic-refresh")])
3236 .unwrap()
3237 .0,
3238 200
3239 );
3240 });
3241 assert!(destination.received_requests().await.unwrap().is_empty());
3242 }
3243
3244 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3245 async fn discovery_honors_advertised_endpoints() {
3246 use wiremock::matchers::{method, path};
3247 use wiremock::{Mock, MockServer, ResponseTemplate};
3248 let server = MockServer::start().await;
3249 Mock::given(method("GET"))
3250 .and(path("/.well-known/openid-configuration"))
3251 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3252 "issuer": server.uri(),
3253 "device_authorization_endpoint": format!("{}/custom/device", server.uri()),
3254 "token_endpoint": format!("{}/custom/token", server.uri()),
3255 })))
3256 .expect(1)
3257 .mount(&server)
3258 .await;
3259 let endpoints = tokio::task::block_in_place(|| {
3260 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
3261 });
3262 assert_eq!(
3263 endpoints.device_authorization_endpoint.as_deref(),
3264 Some(format!("{}/custom/device", server.uri()).as_str())
3265 );
3266 assert_eq!(
3267 endpoints.token_endpoint,
3268 format!("{}/custom/token", server.uri())
3269 );
3270 }
3271
3272 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3273 async fn discovery_issuer_mismatch_falls_back_to_documented_paths() {
3274 use wiremock::matchers::{method, path};
3275 use wiremock::{Mock, MockServer, ResponseTemplate};
3276 let server = MockServer::start().await;
3277 Mock::given(method("GET"))
3278 .and(path("/.well-known/openid-configuration"))
3279 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3280 "issuer": "https://someone-else.example",
3281 "device_authorization_endpoint": "https://someone-else.example/device",
3282 "token_endpoint": "https://someone-else.example/token",
3283 })))
3284 .expect(1)
3285 .mount(&server)
3286 .await;
3287 let endpoints = tokio::task::block_in_place(|| {
3288 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
3289 });
3290 assert_eq!(
3291 endpoints.device_authorization_endpoint.as_deref(),
3292 Some(format!("{}/oauth2/device/code", server.uri()).as_str())
3293 );
3294 assert_eq!(
3295 endpoints.token_endpoint,
3296 format!("{}/oauth2/token", server.uri())
3297 );
3298 }
3299
3300 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3301 async fn device_login_aborts_on_untrusted_verification_uri() {
3302 use wiremock::matchers::{method, path};
3303 use wiremock::{Mock, MockServer, ResponseTemplate};
3304 let server = MockServer::start().await;
3305 Mock::given(method("GET"))
3306 .and(path("/.well-known/openid-configuration"))
3307 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3308 "issuer": server.uri(),
3309 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
3310 "token_endpoint": format!("{}/oauth2/token", server.uri()),
3311 })))
3312 .mount(&server)
3313 .await;
3314 Mock::given(method("POST"))
3315 .and(path("/oauth2/device-advertised"))
3316 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3317 "device_code": "device-token",
3318 "user_code": "CW-TEST",
3319 "verification_uri": "https://auth.x.ai/device",
3320 "verification_uri_complete": "vscode://attacker/run?code=CW-TEST",
3321 "expires_in": 60,
3322 "interval": 1
3323 })))
3324 .expect(1)
3325 .mount(&server)
3326 .await;
3327 // No token-endpoint mock: the flow must fail before it ever polls.
3328 let inputs = ResolvedOAuthInputs {
3329 issuer: server.uri(),
3330 client_id: "test-client".to_string(),
3331 scopes: "openid".to_string(),
3332 open_browser: false,
3333 };
3334 let result =
3335 tokio::task::block_in_place(|| device_code_login_with(OAuthProvider::Xai, &inputs));
3336 let Err(error) = result else {
3337 panic!("a non-web verification URI must abort login");
3338 };
3339 assert!(
3340 format!("{error:#}").contains("untrusted verification URI"),
3341 "{error:#}"
3342 );
3343 }
3344
3345 /// Discovery + device grant run on the blocking worker: this fails with
3346 /// the grant refusal, never with a runtime-drop panic.
3347 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3348 async fn device_login_runs_blocking_http_off_the_executor() {
3349 use wiremock::matchers::{method, path};
3350 use wiremock::{Mock, MockServer, ResponseTemplate};
3351 let _lock = crate::test_support::lock_test_env();
3352 let server = MockServer::start().await;
3353 Mock::given(method("GET"))
3354 .and(path("/.well-known/openid-configuration"))
3355 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3356 "issuer": server.uri(),
3357 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
3358 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
3359 })))
3360 .expect(1)
3361 .mount(&server)
3362 .await;
3363 Mock::given(method("POST"))
3364 .and(path("/oauth2/device-advertised"))
3365 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
3366 "error": "invalid_scope",
3367 "error_description": "mock refusal before browser or polling"
3368 })))
3369 .expect(1)
3370 .mount(&server)
3371 .await;
3372 let _issuer =
3373 crate::test_support::EnvVarGuard::set("GROK_OIDC_ISSUER", server.uri().as_str());
3374 let _no_browser =
3375 crate::test_support::EnvVarGuard::set("CODEWHALE_XAI_OAUTH_NO_BROWSER", "1");
3376
3377 let result = device_code_login(OAuthProvider::Xai).await;
3378 let Err(error) = result else {
3379 panic!("mock device request must fail without a runtime-drop panic");
3380 };
3381 let message = format!("{error:#}");
3382 assert!(message.contains("invalid_scope"), "{message}");
3383 assert!(message.contains("HTTP 400"), "{message}");
3384 }
3385
3386 /// Full orchestration against mocks: discovery, grant, one poll, done.
3387 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3388 async fn device_login_exchanges_and_returns_token_material() {
3389 use wiremock::matchers::{method, path};
3390 use wiremock::{Mock, MockServer, ResponseTemplate};
3391 let server = MockServer::start().await;
3392 Mock::given(method("GET"))
3393 .and(path("/.well-known/openid-configuration"))
3394 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3395 "issuer": server.uri(),
3396 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
3397 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
3398 })))
3399 .expect(1)
3400 .mount(&server)
3401 .await;
3402 Mock::given(method("POST"))
3403 .and(path("/oauth2/device-advertised"))
3404 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3405 "device_code": "device-token",
3406 "user_code": "CW-TEST",
3407 "verification_uri": format!("{}/verify", server.uri()),
3408 "expires_in": 60,
3409 "interval": 1
3410 })))
3411 .expect(1)
3412 .mount(&server)
3413 .await;
3414 Mock::given(method("POST"))
3415 .and(path("/oauth2/token-advertised"))
3416 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3417 "access_token": "unified-access",
3418 "refresh_token": "unified-refresh",
3419 "expires_in": 3600
3420 })))
3421 .expect(1)
3422 .mount(&server)
3423 .await;
3424 let inputs = ResolvedOAuthInputs {
3425 issuer: server.uri(),
3426 client_id: "test-client".to_string(),
3427 scopes: "openid".to_string(),
3428 open_browser: false,
3429 };
3430 let pending =
3431 tokio::task::block_in_place(|| device_code_login_with(OAuthProvider::Xai, &inputs))
3432 .expect("mock login exchanges");
3433 assert_eq!(pending.issuer, server.uri());
3434 assert_eq!(
3435 pending.token.access_token.as_deref(),
3436 Some("unified-access")
3437 );
3438 assert_eq!(
3439 pending.token.refresh_token.as_deref(),
3440 Some("unified-refresh")
3441 );
3442 }
3443
3444 /// The shared poll loop walks pending and slow_down to completion.
3445 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3446 async fn device_login_polls_through_pending_and_slow_down() {
3447 use codewhale_config::device_code::DeviceCodePoll;
3448 use wiremock::matchers::{method, path};
3449 use wiremock::{Mock, MockServer, ResponseTemplate};
3450 let server = MockServer::start().await;
3451 // wiremock matches mocks in mount order, so mount the one-shot
3452 // transient-error responses before the terminal success response:
3453 // poll 1 -> authorization_pending, poll 2 -> slow_down, poll 3 -> ok.
3454 for (body, status) in [
3455 (serde_json::json!({ "error": "authorization_pending" }), 400),
3456 (serde_json::json!({ "error": "slow_down" }), 400),
3457 ] {
3458 Mock::given(method("POST"))
3459 .and(path("/oauth2/token"))
3460 .respond_with(ResponseTemplate::new(status).set_body_json(body))
3461 .up_to_n_times(1)
3462 .expect(1)
3463 .mount(&server)
3464 .await;
3465 }
3466 Mock::given(method("POST"))
3467 .and(path("/oauth2/token"))
3468 .respond_with(ResponseTemplate::new(200).set_body_json(
3469 serde_json::json!({ "access_token": "loop-access", "expires_in": 3600 }),
3470 ))
3471 .expect(1)
3472 .mount(&server)
3473 .await;
3474 let endpoint = format!("{}/oauth2/token", server.uri());
3475 let material = tokio::task::block_in_place(|| {
3476 DeviceCodePoll::new(
3477 std::time::Duration::from_secs(60),
3478 "mock poll must complete",
3479 )
3480 .run(
3481 |_| {},
3482 || poll_device_grant(&endpoint, "test-client", "device-token"),
3483 )
3484 })
3485 .expect("poll loop completes");
3486 assert!(matches!(
3487 material.access_token.as_deref(),
3488 Some("loop-access")
3489 ));
3490 }
3491
3492 /// A denied grant stops the full login with the server's reason.
3493 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3494 async fn device_login_surfaces_user_denial() {
3495 use wiremock::matchers::{method, path};
3496 use wiremock::{Mock, MockServer, ResponseTemplate};
3497 let server = MockServer::start().await;
3498 Mock::given(method("GET"))
3499 .and(path("/.well-known/openid-configuration"))
3500 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3501 "issuer": server.uri(),
3502 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
3503 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
3504 })))
3505 .mount(&server)
3506 .await;
3507 Mock::given(method("POST"))
3508 .and(path("/oauth2/device-advertised"))
3509 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3510 "device_code": "device-token",
3511 "user_code": "CW-TEST",
3512 "verification_uri": format!("{}/verify", server.uri()),
3513 "expires_in": 60,
3514 "interval": 1
3515 })))
3516 .mount(&server)
3517 .await;
3518 Mock::given(method("POST"))
3519 .and(path("/oauth2/token-advertised"))
3520 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
3521 "error": "access_denied",
3522 "error_description": "The user denied the authorization request"
3523 })))
3524 .expect(1)
3525 .mount(&server)
3526 .await;
3527 let inputs = ResolvedOAuthInputs {
3528 issuer: server.uri(),
3529 client_id: "test-client".to_string(),
3530 scopes: "openid".to_string(),
3531 open_browser: false,
3532 };
3533 let result =
3534 tokio::task::block_in_place(|| device_code_login_with(OAuthProvider::Xai, &inputs));
3535 let Err(error) = result else {
3536 panic!("user denial must stop the login");
3537 };
3538 let message = format!("{error:#}");
3539 assert!(message.contains("access_denied"), "{message}");
3540 assert!(message.contains("HTTP 400"), "{message}");
3541 }
3542
3543 /// Non-JSON answers name the content type, never the body.
3544 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3545 async fn device_transport_reports_non_json_without_echoing_body() {
3546 use wiremock::matchers::{method, path};
3547 use wiremock::{Mock, MockServer, ResponseTemplate};
3548 let server = MockServer::start().await;
3549 // set_body_bytes carries no implicit content type, so the inserted
3550 // text/html is the only one on the wire (set_body_string would
3551 // stack text/plain next to it and the diagnostic would name both).
3552 Mock::given(method("POST"))
3553 .and(path("/oauth2/device-code"))
3554 .respond_with(
3555 ResponseTemplate::new(200)
3556 .set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
3557 .insert_header("content-type", "text/html"),
3558 )
3559 .expect(1)
3560 .mount(&server)
3561 .await;
3562 Mock::given(method("POST"))
3563 .and(path("/oauth2/token"))
3564 .respond_with(
3565 ResponseTemplate::new(200)
3566 .set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
3567 .insert_header("content-type", "text/html"),
3568 )
3569 .expect(1)
3570 .mount(&server)
3571 .await;
3572 let grant = tokio::task::block_in_place(|| {
3573 request_device_grant(
3574 &format!("{}/oauth2/device-code", server.uri()),
3575 "test-client",
3576 "openid",
3577 )
3578 });
3579 let Err(grant_error) = grant else {
3580 panic!("non-JSON grant must fail");
3581 };
3582 let poll = tokio::task::block_in_place(|| {
3583 poll_device_grant(
3584 &format!("{}/oauth2/token", server.uri()),
3585 "test-client",
3586 "device-token",
3587 )
3588 });
3589 let Err(poll_error) = poll else {
3590 panic!("non-JSON poll must fail");
3591 };
3592 for message in [format!("{grant_error:#}"), format!("{poll_error:#}")] {
3593 assert!(message.contains("text/html"), "{message}");
3594 assert!(!message.contains("sentinel-body-bytes"), "{message}");
3595 }
3596 }
3597
3598 /// The wire format is a contract: form-encoded posts carrying the exact
3599 /// client, scope, and grant-type parameters the issuers expect.
3600 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3601 async fn device_transport_posts_exact_oauth_form_parameters() {
3602 use wiremock::matchers::{body_string_contains, header, method, path};
3603 use wiremock::{Mock, MockServer, ResponseTemplate};
3604 let server = MockServer::start().await;
3605 Mock::given(method("GET"))
3606 .and(path("/.well-known/openid-configuration"))
3607 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3608 "issuer": server.uri(),
3609 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
3610 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
3611 })))
3612 .mount(&server)
3613 .await;
3614 Mock::given(method("POST"))
3615 .and(path("/oauth2/device-advertised"))
3616 .and(header("content-type", "application/x-www-form-urlencoded"))
3617 .and(body_string_contains("client_id=test-client"))
3618 .and(body_string_contains("scope=openid"))
3619 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3620 "device_code": "device-token",
3621 "user_code": "CW-TEST",
3622 "verification_uri": format!("{}/verify", server.uri()),
3623 "expires_in": 60,
3624 "interval": 1
3625 })))
3626 .expect(1)
3627 .mount(&server)
3628 .await;
3629 Mock::given(method("POST"))
3630 .and(path("/oauth2/token-advertised"))
3631 .and(header("content-type", "application/x-www-form-urlencoded"))
3632 .and(body_string_contains(
3633 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
3634 ))
3635 .and(body_string_contains("client_id=test-client"))
3636 .and(body_string_contains("device_code=device-token"))
3637 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
3638 "access_token": "form-access",
3639 "expires_in": 3600
3640 })))
3641 .expect(1)
3642 .mount(&server)
3643 .await;
3644 let inputs = ResolvedOAuthInputs {
3645 issuer: server.uri(),
3646 client_id: "test-client".to_string(),
3647 scopes: "openid".to_string(),
3648 open_browser: false,
3649 };
3650 let pending =
3651 tokio::task::block_in_place(|| device_code_login_with(OAuthProvider::Xai, &inputs))
3652 .expect("mock login exchanges");
3653 assert_eq!(pending.token.access_token.as_deref(), Some("form-access"));
3654 }
3655
3656 #[test]
3657 fn access_method_labels_name_every_route() {
3658 assert_eq!(
3659 AccessMethod::OwnedOAuth(OAuthProvider::Xai).label(),
3660 "xAI subscription"
3661 );
3662 assert_eq!(
3663 AccessMethod::ExternalImport(ExternalImportSource::CodexCli).label(),
3664 "Codex CLI import"
3665 );
3666 assert_eq!(AccessMethod::ApiKey.label(), "API key");
3667 assert_eq!(AccessMethod::AcpBridge.label(), "ACP bridge");
3668 }
3669
3670 #[test]
3671 fn malformed_codex_credential_errors_never_echo_file_contents() {
3672 let _lock = crate::test_support::lock_test_env();
3673 let home = tempfile::tempdir().expect("temp Codex home");
3674 let path = home.path().canonicalize().unwrap().join("auth.json");
3675 let sentinel = "must-not-appear-in-diagnostics";
3676 std::fs::write(
3677 &path,
3678 format!(r#"{{"tokens":{{"access_token":{{"secret":"{sentinel}"}}}}}}"#),
3679 )
3680 .unwrap();
3681
3682 let error = load_credentials(&grant(&path)).expect_err("malformed schema");
3683 let message = format!("{error:#}");
3684 assert!(message.contains("not valid credential JSON"), "{message}");
3685 assert!(!message.contains(sentinel), "{message}");
3686 }
3687
3688 // ── unified PKCE core (ported from the deleted chatgpt_oauth flow) ──
3689
3690 use std::sync::Mutex;
3691
3692 type MockForm = Vec<(String, String)>;
3693 type MockPost = (String, MockForm);
3694
3695 struct MockFormClient {
3696 responses: Mutex<Vec<(u16, String)>>,
3697 posts: Mutex<Vec<MockPost>>,
3698 }
3699
3700 impl MockFormClient {
3701 fn new(responses: Vec<(u16, String)>) -> Self {
3702 Self {
3703 responses: Mutex::new(responses),
3704 posts: Mutex::new(Vec::new()),
3705 }
3706 }
3707 }
3708
3709 impl OAuthFormClient for MockFormClient {
3710 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
3711 self.posts.lock().expect("posts").push((
3712 url.to_string(),
3713 form.iter()
3714 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
3715 .collect(),
3716 ));
3717 let mut responses = self.responses.lock().expect("responses");
3718 anyhow::ensure!(
3719 !responses.is_empty(),
3720 "mock issuer has no remaining responses"
3721 );
3722 Ok(responses.remove(0))
3723 }
3724 }
3725
3726 fn jwt_with_account(account: &str) -> String {
3727 let payload = URL_SAFE_NO_PAD.encode(format!(
3728 r#"{{"https://api.openai.com/auth":{{"chatgpt_account_id":"{account}"}}}}"#
3729 ));
3730 format!("header.{payload}.sig")
3731 }
3732
3733 fn chatgpt() -> &'static OAuthProviderParams {
3734 oauth_provider_params(OAuthProvider::Chatgpt)
3735 }
3736
3737 #[test]
3738 fn pkce_verifier_and_challenge_are_s256() {
3739 let pkce = generate_pkce();
3740 assert!(pkce.verifier.len() >= 43);
3741 assert_eq!(
3742 pkce.challenge,
3743 URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier.as_bytes()))
3744 );
3745 let other = generate_pkce();
3746 assert_ne!(pkce.verifier, other.verifier);
3747 assert_ne!(generate_state(), generate_state());
3748 }
3749
3750 #[test]
3751 fn malformed_issuer_fails_loudly_not_to_production() {
3752 let pkce = PkceChallenge {
3753 verifier: "verifier".into(),
3754 challenge: "challenge".into(),
3755 };
3756 let err = build_authorize_url(
3757 chatgpt(),
3758 "not a url \\ ",
3759 "client",
3760 "openid",
3761 "http://localhost:1455/auth/callback",
3762 "state-1",
3763 &pkce,
3764 )
3765 .expect_err("malformed issuer must not produce an authorize URL");
3766 assert!(
3767 format!("{err:#}").contains("CODEWHALE_CHATGPT_OAUTH_ISSUER"),
3768 "{err:#}"
3769 );
3770 }
3771
3772 #[test]
3773 fn authorize_url_is_honest_originator_and_pkce() {
3774 let pkce = PkceChallenge {
3775 verifier: "verifier".into(),
3776 challenge: "challenge".into(),
3777 };
3778 let url = build_authorize_url(
3779 chatgpt(),
3780 CHATGPT_OAUTH_ISSUER,
3781 CHATGPT_OAUTH_CLIENT_ID,
3782 CHATGPT_OAUTH_SCOPE,
3783 "http://localhost:1455/auth/callback",
3784 "state-1",
3785 &pkce,
3786 )
3787 .expect("static issuer parses");
3788 assert!(url.starts_with("https://auth.openai.com/oauth/authorize?"));
3789 assert!(url.contains("code_challenge=challenge"));
3790 assert!(url.contains("code_challenge_method=S256"));
3791 assert!(url.contains("originator=codewhale"));
3792 assert!(!url.contains("codex_cli_rs"));
3793 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
3794 assert!(url.contains("id_token_add_organizations=true"));
3795 }
3796
3797 #[test]
3798 fn authorize_url_rejects_providers_without_a_browser_flow() {
3799 let pkce = PkceChallenge {
3800 verifier: "verifier".into(),
3801 challenge: "challenge".into(),
3802 };
3803 let err = build_authorize_url(
3804 oauth_provider_params(OAuthProvider::Xai),
3805 "https://auth.x.ai",
3806 "client",
3807 "openid",
3808 "http://localhost:1455/auth/callback",
3809 "state-1",
3810 &pkce,
3811 )
3812 .expect_err("xAI has no browser flow");
3813 assert!(
3814 format!("{err:#}").contains("no browser sign-in flow"),
3815 "{err:#}"
3816 );
3817 }
3818
3819 #[test]
3820 fn callback_success_requires_matching_state() {
3821 let ok = parse_callback_query(chatgpt(), "code=abc&state=s1").unwrap();
3822 assert_eq!(accept_callback("s1", ok).unwrap(), "abc");
3823 let mismatch = parse_callback_query(chatgpt(), "code=abc&state=other").unwrap();
3824 let err = accept_callback("s1", mismatch).unwrap_err().to_string();
3825 assert!(err.contains("state did not match"), "{err}");
3826 }
3827
3828 #[test]
3829 fn callback_error_is_user_visible_without_code() {
3830 let outcome = parse_callback_query(
3831 chatgpt(),
3832 "error=access_denied&error_description=nope&state=s1",
3833 )
3834 .unwrap();
3835 let err = accept_callback("s1", outcome).unwrap_err().to_string();
3836 assert!(err.contains("nope"), "{err}");
3837 assert!(!err.contains("access_token"));
3838 }
3839
3840 #[test]
3841 fn callback_missing_code_fails() {
3842 let err = parse_callback_query(chatgpt(), "state=s1")
3843 .unwrap_err()
3844 .to_string();
3845 assert!(err.contains("missing authorization code"), "{err}");
3846 }
3847
3848 #[test]
3849 fn token_exchange_uses_pkce_verifier_against_mock_issuer() {
3850 let client = MockFormClient::new(vec![(
3851 200,
3852 serde_json::json!({
3853 "access_token": "at-1",
3854 "refresh_token": "rt-1",
3855 "expires_in": 3600,
3856 "id_token": jwt_with_account("acct-9")
3857 })
3858 .to_string(),
3859 )]);
3860 let token = exchange_authorization_code(
3861 &client,
3862 chatgpt(),
3863 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
3864 CHATGPT_OAUTH_CLIENT_ID,
3865 "http://localhost:1455/auth/callback",
3866 "auth-code",
3867 "verifier",
3868 )
3869 .unwrap();
3870 assert_eq!(token.access_token.as_deref(), Some("at-1"));
3871 let posts = client.posts.lock().unwrap();
3872 assert_eq!(posts.len(), 1);
3873 assert_eq!(posts[0].0, "https://auth.openai.com/oauth/token");
3874 let form: std::collections::BTreeMap<_, _> = posts[0].1.iter().cloned().collect();
3875 assert_eq!(form["grant_type"], "authorization_code");
3876 assert_eq!(form["code_verifier"], "verifier");
3877 assert_eq!(form["code"], "auth-code");
3878 }
3879
3880 #[test]
3881 fn token_exchange_error_does_not_echo_body_secrets() {
3882 let client = MockFormClient::new(vec![(
3883 400,
3884 serde_json::json!({
3885 "error": "invalid_grant",
3886 "error_description": "secret-must-not-leak"
3887 })
3888 .to_string(),
3889 )]);
3890 // OAuthTokenMaterial is deliberately Debug-free; extract the error
3891 // without demanding a Debug bound on the success type.
3892 let err = match exchange_authorization_code(
3893 &client,
3894 chatgpt(),
3895 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
3896 CHATGPT_OAUTH_CLIENT_ID,
3897 "http://localhost:1455/auth/callback",
3898 "bad",
3899 "verifier",
3900 ) {
3901 Ok(_) => panic!("invalid_grant must fail"),
3902 Err(err) => err.to_string(),
3903 };
3904 assert!(err.contains("permanently"), "{err}");
3905 assert!(!err.contains("secret-must-not-leak"), "{err}");
3906 }
3907
3908 #[test]
3909 fn callback_server_handles_success_and_error_requests() {
3910 use std::io::Write as _;
3911 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test port");
3912 listener.set_nonblocking(false).unwrap();
3913 let addr = listener.local_addr().unwrap();
3914 let state = "state-xyz".to_string();
3915 let expected = state.clone();
3916 let params = chatgpt();
3917 let server = std::thread::spawn(move || {
3918 let (stream, _) = listener.accept().expect("accept");
3919 handle_callback_stream(stream, params, &expected)
3920 });
3921 let mut client = std::net::TcpStream::connect(addr).expect("connect");
3922 write!(
3923 client,
3924 "GET /auth/callback?code=tok&state={state} HTTP/1.1\r\nHost: localhost\r\n\r\n"
3925 )
3926 .unwrap();
3927 let code = server.join().expect("server").expect("callback ok");
3928 assert_eq!(code, "tok");
3929
3930 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind error port");
3931 listener.set_nonblocking(false).unwrap();
3932 let addr = listener.local_addr().unwrap();
3933 let params = chatgpt();
3934 let server = std::thread::spawn(move || {
3935 let (stream, _) = listener.accept().expect("accept");
3936 handle_callback_stream(stream, params, "state-xyz")
3937 });
3938 let mut client = std::net::TcpStream::connect(addr).expect("connect");
3939 write!(
3940 client,
3941 "GET /auth/callback?error=access_denied&state=state-xyz HTTP/1.1\r\nHost: localhost\r\n\r\n"
3942 )
3943 .unwrap();
3944 let err = server.join().expect("server").unwrap_err().to_string();
3945 assert!(err.contains("not completed"), "{err}");
3946 }
3947
3948 /// The registered redirect URI says `localhost`, which resolves to `::1`
3949 /// as readily as `127.0.0.1`. A callback arriving on the IPv6 listener has
3950 /// to be accepted, or an IPv6-first browser hangs until the timeout.
3951 #[test]
3952 fn callback_is_accepted_on_either_loopback_family() {
3953 use std::io::Write as _;
3954 for addr in [
3955 SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
3956 SocketAddr::from((Ipv6Addr::LOCALHOST, 0)),
3957 ] {
3958 let Ok(target) = TcpListener::bind(addr) else {
3959 // A host without this stack cannot exercise it; the other arm
3960 // still covers the polling loop.
3961 continue;
3962 };
3963 target.set_nonblocking(true).unwrap();
3964 let target_addr = target.local_addr().unwrap();
3965
3966 // A second, permanently idle listener stands in for the family the
3967 // browser did not pick: `wait_for_callback` must poll past it.
3968 let idle = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
3969 .expect("bind idle listener");
3970 idle.set_nonblocking(true).unwrap();
3971
3972 let listeners = vec![idle, target];
3973 let params = chatgpt();
3974 let server =
3975 std::thread::spawn(move || wait_for_callback(&listeners, params, "state-xyz"));
3976 let mut client = std::net::TcpStream::connect(target_addr).expect("connect");
3977 write!(
3978 client,
3979 "GET /auth/callback?code=tok&state=state-xyz HTTP/1.1\r\nHost: localhost\r\n\r\n"
3980 )
3981 .unwrap();
3982 let code = server
3983 .join()
3984 .expect("server")
3985 .unwrap_or_else(|error| panic!("callback on {target_addr} rejected: {error}"));
3986 assert_eq!(code, "tok", "callback on {target_addr}");
3987 }
3988 }
3989
3990 #[test]
3991 fn form_refresh_and_revoke_target_the_row_endpoints() {
3992 let client = MockFormClient::new(vec![
3993 (
3994 200,
3995 serde_json::json!({"access_token": "fresh", "expires_in": 3600}).to_string(),
3996 ),
3997 (200, String::new()),
3998 ]);
3999 let refreshed = refresh_access_token_via(
4000 &client,
4001 chatgpt(),
4002 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
4003 CHATGPT_OAUTH_CLIENT_ID,
4004 "rt-1",
4005 )
4006 .expect("refresh");
4007 assert_eq!(refreshed.access_token.as_deref(), Some("fresh"));
4008 revoke_remote_token_via(
4009 &client,
4010 chatgpt(),
4011 CHATGPT_OAUTH_ISSUER,
4012 CHATGPT_OAUTH_CLIENT_ID,
4013 "rt-1",
4014 )
4015 .expect("revoke");
4016 let posts = client.posts.lock().unwrap();
4017 assert_eq!(posts.len(), 2, "{posts:?}");
4018 assert!(posts[0].0.ends_with("/oauth/token"), "{posts:?}");
4019 assert!(
4020 posts[0]
4021 .1
4022 .iter()
4023 .any(|(k, v)| k == "grant_type" && v == "refresh_token")
4024 );
4025 assert!(
4026 posts[1].0.ends_with("/api/accounts/oauth/revoke"),
4027 "{posts:?}"
4028 );
4029 }
4030
4031 // ────────────────────────────────────────────────────────────────────
4032 // Ported from the deleted per-provider modules (§D security pins).
4033 // ────────────────────────────────────────────────────────────────────
4034
4035 use tempfile::TempDir;
4036 use wiremock::matchers::{method, path};
4037 use wiremock::{Mock, MockServer, ResponseTemplate};
4038
4039 #[test]
4040 fn auth_mode_accepts_oauth_aliases() {
4041 for mode in [
4042 "oauth",
4043 "xai_oauth",
4044 "XAI-OAuth",
4045 "grok",
4046 "grok_cli",
4047 "device_code",
4048 "device-auth",
4049 ] {
4050 assert!(
4051 auth_mode_uses_xai_oauth(mode),
4052 "expected oauth mode: {mode}"
4053 );
4054 }
4055 assert!(!auth_mode_uses_xai_oauth("api_key"));
4056 assert!(!auth_mode_uses_xai_oauth("keyring"));
4057 }
4058
4059 #[test]
4060 fn loads_fresh_token_from_grok_auth_json() {
4061 let _guard = crate::test_support::lock_test_env();
4062 let dir = TempDir::new().unwrap();
4063 let root = dir.path().canonicalize().expect("canonical temp root");
4064 let path = root.join("auth.json");
4065 let future = rfc3339_from_now(3600);
4066 let scope = format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}");
4067 let file = serde_json::json!({
4068 scope: {
4069 "key": "test-access-token",
4070 "refresh_token": "test-refresh",
4071 "expires_at": future,
4072 "oidc_issuer": XAI_OIDC_ISSUER,
4073 "oidc_client_id": GROK_OIDC_CLIENT_ID,
4074 "auth_mode": "oidc"
4075 }
4076 });
4077 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
4078 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
4079 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
4080 let config = Config {
4081 provider: Some(ApiProvider::Xai.as_str().to_string()),
4082 providers: Some(crate::config::ProvidersConfig {
4083 xai: crate::config::ProviderConfig {
4084 auth_mode: Some("oauth".to_string()),
4085 external_credentials: Some(
4086 codewhale_config::ExternalCredentialConsentToml::read_only(
4087 codewhale_config::ProviderKind::Xai,
4088 codewhale_config::ExternalCredentialSource::GrokCli,
4089 path.clone(),
4090 ),
4091 ),
4092 ..Default::default()
4093 },
4094 ..Default::default()
4095 }),
4096 ..Default::default()
4097 };
4098 crate::external_credentials::reset_side_effect_trap();
4099 let result = get_xai_credentials(&config);
4100 let creds = result.expect("load");
4101 assert_eq!(creds.access_token, "test-access-token");
4102 assert_eq!(creds.client_id, GROK_OIDC_CLIENT_ID);
4103 assert_eq!(
4104 crate::external_credentials::side_effect_trap_counts(),
4105 (1, 1)
4106 );
4107 }
4108
4109 #[test]
4110 fn disabled_external_grok_credentials_cause_zero_external_io() {
4111 let _guard = crate::test_support::lock_test_env();
4112 let dir = TempDir::new().unwrap();
4113 let root = dir.path().canonicalize().expect("canonical temp root");
4114 let path = root.join("external-grok-auth.json");
4115 let raw = serde_json::json!({
4116 format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}"): {
4117 "key": "must-never-be-read",
4118 "refresh_token": "must-never-be-used",
4119 "expires_at": rfc3339_from_now(3600),
4120 "future_field": {"preserve": true}
4121 }
4122 })
4123 .to_string();
4124 fs::write(&path, &raw).unwrap();
4125 let owned_home = root.join("codewhale-owned");
4126 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
4127 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
4128 let config = Config {
4129 provider: Some(ApiProvider::Xai.as_str().to_string()),
4130 providers: Some(crate::config::ProvidersConfig {
4131 xai: crate::config::ProviderConfig {
4132 auth_mode: Some("oauth".to_string()),
4133 ..Default::default()
4134 },
4135 ..Default::default()
4136 }),
4137 ..Default::default()
4138 };
4139
4140 crate::external_credentials::reset_side_effect_trap();
4141 assert!(!credentials_valid(OAuthProvider::Xai, &config));
4142 let error = match get_xai_credentials(&config) {
4143 Ok(_) => panic!("external access is disabled"),
4144 Err(e) => e,
4145 };
4146 assert!(error.to_string().contains("are disabled"));
4147 assert_eq!(
4148 crate::external_credentials::side_effect_trap_counts(),
4149 (0, 0)
4150 );
4151 assert_eq!(
4152 crate::external_credentials::complete_side_effect_trap_counts(),
4153 (0, 0, 0, 0, 0),
4154 "disabled external authority must reach no credential or OAuth sink"
4155 );
4156 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
4157 }
4158
4159 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4160 async fn expired_read_only_external_credentials_never_refresh_rewrite_or_network() {
4161 let _guard = crate::test_support::lock_test_env();
4162 let server = MockServer::start().await;
4163 let dir = TempDir::new().unwrap();
4164 let root = dir.path().canonicalize().expect("canonical temp root");
4165 let path = root.join("external-grok-auth.json");
4166 let scope = format!("{}::{GROK_OIDC_CLIENT_ID}", server.uri());
4167 let raw = serde_json::json!({
4168 scope: {
4169 "key": "expired-external-access",
4170 "refresh_token": "must-never-be-submitted",
4171 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
4172 "oidc_issuer": server.uri(),
4173 "oidc_client_id": GROK_OIDC_CLIENT_ID,
4174 "future_field": {"preserve": true}
4175 }
4176 })
4177 .to_string();
4178 fs::write(&path, &raw).unwrap();
4179 let owned_home = root.join("codewhale-owned");
4180 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
4181 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
4182 let config = Config {
4183 provider: Some(ApiProvider::Xai.as_str().to_string()),
4184 providers: Some(crate::config::ProvidersConfig {
4185 xai: crate::config::ProviderConfig {
4186 auth_mode: Some("oauth".to_string()),
4187 external_credentials: Some(
4188 codewhale_config::ExternalCredentialConsentToml::read_only(
4189 codewhale_config::ProviderKind::Xai,
4190 codewhale_config::ExternalCredentialSource::GrokCli,
4191 path.clone(),
4192 ),
4193 ),
4194 ..Default::default()
4195 },
4196 ..Default::default()
4197 }),
4198 ..Default::default()
4199 };
4200
4201 crate::external_credentials::reset_side_effect_trap();
4202 let error = match tokio::task::block_in_place(|| get_xai_credentials(&config)) {
4203 Ok(_) => panic!("read-only external credentials must fail instead of refreshing"),
4204 Err(e) => e,
4205 };
4206 assert!(
4207 error
4208 .to_string()
4209 .contains("Read-only consent never refreshes")
4210 );
4211 assert_eq!(
4212 crate::external_credentials::side_effect_trap_counts(),
4213 (1, 1)
4214 );
4215 assert_eq!(
4216 crate::external_credentials::complete_side_effect_trap_counts(),
4217 (1, 1, 0, 0, 0),
4218 "read-only external expiry must not reach write, refresh, or network sinks"
4219 );
4220 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
4221 assert!(!owned_home.join("credentials/xai-auth.json").exists());
4222 assert!(
4223 server
4224 .received_requests()
4225 .await
4226 .expect("recorded requests")
4227 .is_empty(),
4228 "external refresh tokens must never be sent over the network"
4229 );
4230 }
4231
4232 /// #4763 root trigger, re-pinned for #5772. A returning xAI-OAuth user
4233 /// whose only material is an external Grok CLI grant loses readiness the
4234 /// moment that CLI's short-lived access token expires, even though a
4235 /// refresh token sits right beside it — read-only consent deliberately
4236 /// never refreshes or rewrites another CLI's file, so there is nothing to
4237 /// renew it with. `needs_api_key` therefore flips to true and onboarding
4238 /// reopens. That is the intended invariant, not a leak, and it is what
4239 /// stops a surviving consent record from reading as a stored credential;
4240 /// this test pins it so the onboarding entry point stays explainable.
4241 #[test]
4242 fn expired_external_grok_grant_reads_as_missing_key_despite_refresh_token() {
4243 let _guard = crate::test_support::lock_test_env();
4244 let dir = TempDir::new().unwrap();
4245 let root = dir.path().canonicalize().expect("canonical temp root");
4246 let path = root.join("external-grok-auth.json");
4247 let scope = format!("https://auth.x.ai::{GROK_OIDC_CLIENT_ID}");
4248 fs::write(
4249 &path,
4250 serde_json::json!({
4251 scope.clone(): {
4252 "key": "expired-external-access",
4253 "refresh_token": "present-but-unusable-under-read-only-consent",
4254 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
4255 "oidc_client_id": GROK_OIDC_CLIENT_ID,
4256 }
4257 })
4258 .to_string(),
4259 )
4260 .unwrap();
4261 let _home_guard =
4262 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.join("codewhale-owned"));
4263 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
4264 let _key_guard = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
4265 let config = Config {
4266 provider: Some(ApiProvider::Xai.as_str().to_string()),
4267 providers: Some(crate::config::ProvidersConfig {
4268 xai: crate::config::ProviderConfig {
4269 auth_mode: Some("oauth".to_string()),
4270 external_credentials: Some(
4271 codewhale_config::ExternalCredentialConsentToml::read_only(
4272 codewhale_config::ProviderKind::Xai,
4273 codewhale_config::ExternalCredentialSource::GrokCli,
4274 path.clone(),
4275 ),
4276 ),
4277 ..Default::default()
4278 },
4279 ..Default::default()
4280 }),
4281 ..Default::default()
4282 };
4283
4284 crate::external_credentials::reset_side_effect_trap();
4285 assert!(
4286 !credentials_present(OAuthProvider::Xai, &config),
4287 "an expired external access token is not usable material"
4288 );
4289 assert!(
4290 !crate::config::has_api_key_for(&config, ApiProvider::Xai),
4291 "expired external xAI OAuth must fall through to the missing-key path"
4292 );
4293 assert_eq!(
4294 crate::external_credentials::complete_side_effect_trap_counts().2,
4295 0,
4296 "a consented read never rewrites Codewhale-owned storage"
4297 );
4298 assert_eq!(
4299 crate::external_credentials::complete_side_effect_trap_counts().3,
4300 0,
4301 "a consented read never refreshes another CLI's token"
4302 );
4303
4304 // The same file with a live access token is ready, so the check is
4305 // expiry-driven rather than a blanket rejection of external grants.
4306 fs::write(
4307 &path,
4308 serde_json::json!({
4309 scope: {
4310 "key": "fresh-external-access",
4311 "refresh_token": "unused",
4312 "expires_at": rfc3339_from_now(3600),
4313 "oidc_client_id": GROK_OIDC_CLIENT_ID,
4314 }
4315 })
4316 .to_string(),
4317 )
4318 .unwrap();
4319 assert!(credentials_present(OAuthProvider::Xai, &config));
4320 assert!(crate::config::has_api_key_for(&config, ApiProvider::Xai));
4321 }
4322
4323 #[test]
4324 fn native_login_storage_is_codewhale_owned() {
4325 let _guard = crate::test_support::lock_test_env();
4326 let dir = TempDir::new().unwrap();
4327 let grok_path = dir.path().join("external-grok-auth.json");
4328 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
4329 let _grok = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
4330
4331 let owned = codewhale_config::legacy_xai_oauth_path().expect("Codewhale-owned auth path");
4332 assert_eq!(owned, dir.path().join("credentials/xai-auth.json"));
4333 assert_ne!(owned, grok_auth_file_path());
4334 }
4335
4336 /// #4257 storage contract: the on-disk credential file is a JSON object
4337 /// keyed `{issuer}::{client_id}` whose entries use the Grok CLI's field
4338 /// names. Consolidating the device-code poller must not touch it, so pin
4339 /// the format with literal bytes rather than a round-trip through the
4340 /// writer — a round-trip would follow the code if the code drifted.
4341 #[test]
4342 fn a_token_stored_in_the_current_on_disk_format_still_loads() {
4343 let _guard = crate::test_support::lock_test_env();
4344 let dir = TempDir::new().unwrap();
4345 let home = dir
4346 .path()
4347 .canonicalize()
4348 .expect("canonical temp root")
4349 .join("owned-home");
4350 fs::create_dir_all(&home).unwrap();
4351 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4352
4353 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
4354 let expires_at = rfc3339_from_now(3600);
4355 let stored = format!(
4356 r#"{{
4357 "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
4358 "key": "stored-access-token",
4359 "refresh_token": "stored-refresh-token",
4360 "expires_at": "{expires_at}",
4361 "oidc_issuer": "https://auth.x.ai",
4362 "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828",
4363 "auth_mode": "oidc",
4364 "unknown_cli_field": "preserved"
4365 }}
4366 }}"#
4367 );
4368
4369 let credentials = codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4370 store.write(generation, stored.as_bytes(), false)?;
4371 // A fresh stored token must be used as-is: refreshing here would
4372 // mean an existing login stopped working offline.
4373 get_owned_credentials_locked(OAuthProvider::Xai, store, generation, |_, _, _| {
4374 panic!("a fresh stored token must not be refreshed")
4375 })
4376 })
4377 .expect("read back a credential stored in the current format");
4378
4379 assert_eq!(credentials.access_token, "stored-access-token");
4380 assert_eq!(
4381 credentials.refresh_token.as_deref(),
4382 Some("stored-refresh-token")
4383 );
4384 assert_eq!(credentials.issuer, XAI_OIDC_ISSUER);
4385 assert_eq!(credentials.client_id, GROK_OIDC_CLIENT_ID);
4386 }
4387
4388 /// `Debug` reaches production through tracing's `?` sigil, anyhow context,
4389 /// and panic messages. Nothing that holds bearer material may print it.
4390 #[test]
4391 fn debug_output_never_contains_bearer_material() {
4392 let entry = OwnedAuthEntry {
4393 access_token: Some("secret-access-token".to_string()),
4394 refresh_token: Some("secret-refresh-token".to_string()),
4395 expires_at: Some("2030-01-01T00:00:00.000Z".to_string()),
4396 id_token: None,
4397 account_id: None,
4398 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
4399 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
4400 originator: None,
4401 auth_mode: Some("oidc".to_string()),
4402 extra: BTreeMap::new(),
4403 };
4404 let credentials = credentials_from_entry(
4405 OAuthProvider::Xai,
4406 &format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}"),
4407 &entry,
4408 "secret-access-token".to_string(),
4409 );
4410 let activation = OAuthActivation {
4411 credentials: credentials.clone(),
4412 config_path: PathBuf::from("/tmp/config.toml"),
4413 auth_path: PathBuf::from("/tmp/auth.json"),
4414 };
4415
4416 let rendered = format!("{entry:?} {activation:?}");
4417 for secret in ["secret-access-token", "secret-refresh-token"] {
4418 assert!(!rendered.contains(secret), "{secret} leaked: {rendered}");
4419 }
4420 // The shape stays useful for diagnosis.
4421 assert!(rendered.contains("<redacted>"), "{rendered}");
4422 assert!(rendered.contains(GROK_OIDC_CLIENT_ID), "{rendered}");
4423 }
4424
4425 fn pending_login(access: &str, refresh: &str) -> PendingOAuthLogin {
4426 pending_login_for_test(OAuthProvider::Xai, access, refresh)
4427 }
4428
4429 fn seed_expired_owned_generation() -> String {
4430 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json".to_string();
4431 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4432 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
4433 let mut file = AuthFile::new();
4434 file.insert(
4435 scope,
4436 OwnedAuthEntry {
4437 access_token: Some("expired-access".to_string()),
4438 refresh_token: Some("initial-refresh".to_string()),
4439 expires_at: Some("1970-01-01T00:00:00.000Z".to_string()),
4440 id_token: None,
4441 account_id: None,
4442 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
4443 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
4444 originator: None,
4445 auth_mode: Some("oidc".to_string()),
4446 extra: BTreeMap::new(),
4447 },
4448 );
4449 write_auth_file_to_store(store, &generation, &file, false)
4450 })
4451 .expect("seed expired owned generation");
4452 generation
4453 }
4454
4455 fn seed_legacy_owned_credentials() -> PathBuf {
4456 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4457 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
4458 let mut legacy = AuthFile::new();
4459 legacy.insert(
4460 scope,
4461 OwnedAuthEntry {
4462 access_token: Some("legacy-access".to_string()),
4463 refresh_token: Some("legacy-refresh".to_string()),
4464 expires_at: Some(rfc3339_from_now(3600)),
4465 id_token: None,
4466 account_id: None,
4467 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
4468 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
4469 originator: None,
4470 auth_mode: Some("oidc".to_string()),
4471 extra: BTreeMap::new(),
4472 },
4473 );
4474 write_auth_file_to_store(
4475 store,
4476 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
4477 &legacy,
4478 false,
4479 )?;
4480 store.path_for(codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME)
4481 })
4482 .expect("seed legacy credentials")
4483 }
4484
4485 #[test]
4486 fn concurrent_refreshes_share_one_rotated_epoch() {
4487 let _guard = crate::test_support::lock_test_env();
4488 let dir = TempDir::new().unwrap();
4489 let home = dir
4490 .path()
4491 .canonicalize()
4492 .expect("canonical temp root")
4493 .join("owned-home");
4494 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4495 let generation = seed_expired_owned_generation();
4496 let refreshes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
4497 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
4498 let (release_tx, release_rx) = std::sync::mpsc::channel();
4499
4500 let first_generation = generation.clone();
4501 let first_refreshes = refreshes.clone();
4502 let first = std::thread::spawn(move || {
4503 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4504 get_owned_credentials_locked(
4505 OAuthProvider::Xai,
4506 store,
4507 &first_generation,
4508 |_, _, refresh| {
4509 assert_eq!(refresh, "initial-refresh");
4510 first_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4511 entered_tx.send(()).unwrap();
4512 release_rx.recv().unwrap();
4513 Ok(OAuthTokenMaterial {
4514 id_token: None,
4515 access_token: Some("rotated-access".to_string()),
4516 refresh_token: Some("rotated-refresh".to_string()),
4517 expires_in: Some(3600),
4518 error: None,
4519 error_description: None,
4520 interval: None,
4521 })
4522 },
4523 )
4524 })
4525 });
4526 entered_rx.recv().expect("first refresh reached barrier");
4527
4528 let second_generation = generation.clone();
4529 let second_refreshes = refreshes.clone();
4530 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
4531 let second = std::thread::spawn(move || {
4532 attempt_tx.send(()).unwrap();
4533 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4534 get_owned_credentials_locked(
4535 OAuthProvider::Xai,
4536 store,
4537 &second_generation,
4538 |_, _, _| {
4539 second_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4540 bail!("second refresh must observe the first thread's committed token")
4541 },
4542 )
4543 })
4544 });
4545 attempt_rx.recv().expect("second refresh attempted lock");
4546 release_tx.send(()).expect("release first refresh");
4547
4548 let first = first.join().unwrap().expect("first refresh");
4549 let second = second.join().unwrap().expect("second refresh");
4550 assert_eq!(first.access_token, "rotated-access");
4551 assert_eq!(second.access_token, "rotated-access");
4552 assert_eq!(refreshes.load(std::sync::atomic::Ordering::SeqCst), 1);
4553 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4554 let mut file = load_owned_auth_file_from_store(store, &generation)?
4555 .context("generation must remain active")?;
4556 let (_, entry) = select_entry(OAuthProvider::Xai, &mut file).context("stored entry")?;
4557 assert_eq!(entry.refresh_token.as_deref(), Some("rotated-refresh"));
4558 Ok(())
4559 })
4560 .unwrap();
4561 }
4562
4563 #[test]
4564 fn logout_waits_for_refresh_then_revokes_the_committed_epoch() {
4565 let _guard = crate::test_support::lock_test_env();
4566 let dir = TempDir::new().unwrap();
4567 let home = dir
4568 .path()
4569 .canonicalize()
4570 .expect("canonical temp root")
4571 .join("owned-home");
4572 fs::create_dir_all(&home).unwrap();
4573 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4574 let generation = seed_expired_owned_generation();
4575 fs::write(
4576 home.join("config.toml"),
4577 format!(
4578 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{generation}\"\n"
4579 ),
4580 )
4581 .unwrap();
4582 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
4583 let (release_tx, release_rx) = std::sync::mpsc::channel();
4584
4585 let refresh_generation = generation.clone();
4586 let refresh = std::thread::spawn(move || {
4587 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
4588 get_owned_credentials_locked(
4589 OAuthProvider::Xai,
4590 store,
4591 &refresh_generation,
4592 |_, _, _| {
4593 entered_tx.send(()).unwrap();
4594 release_rx.recv().unwrap();
4595 Ok(OAuthTokenMaterial {
4596 id_token: None,
4597 access_token: Some("last-refresh-access".to_string()),
4598 refresh_token: Some("last-refresh-rotation".to_string()),
4599 expires_in: Some(3600),
4600 error: None,
4601 error_description: None,
4602 interval: None,
4603 })
4604 },
4605 )
4606 })
4607 });
4608 entered_rx.recv().expect("refresh reached barrier");
4609
4610 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
4611 let config_path = home.join("config.toml");
4612 let logout = std::thread::spawn(move || {
4613 attempt_tx.send(()).unwrap();
4614 codewhale_config::with_xai_oauth_revocation_transaction(|| {
4615 codewhale_config::mutate_config_document(&config_path, |document| {
4616 codewhale_config::unset_config_document_value(
4617 document,
4618 &["providers", "xai", "oauth_credential_generation"],
4619 )?;
4620 codewhale_config::unset_config_document_value(
4621 document,
4622 &["providers", "xai", "auth_mode"],
4623 )?;
4624 Ok(())
4625 })
4626 })
4627 });
4628 attempt_rx.recv().expect("logout attempted lifecycle lock");
4629 release_tx.send(()).expect("release refresh");
4630
4631 assert_eq!(
4632 refresh.join().unwrap().expect("refresh").access_token,
4633 "last-refresh-access"
4634 );
4635 logout.join().unwrap().expect("logout");
4636 let auth_path = home.join("credentials").join(&generation);
4637 assert!(
4638 !auth_path.exists(),
4639 "logout must retire the generation written by the preceding refresh"
4640 );
4641 let config = fs::read_to_string(home.join("config.toml")).unwrap();
4642 assert!(!config.contains("oauth_credential_generation"));
4643 assert!(!config.contains("auth_mode"));
4644 }
4645
4646 #[test]
4647 fn activation_commits_unique_generation_pointer_and_revokes_external_consent() {
4648 let _guard = crate::test_support::lock_test_env();
4649 let dir = TempDir::new().unwrap();
4650 let home = dir
4651 .path()
4652 .canonicalize()
4653 .expect("canonical temp root")
4654 .join("owned-home");
4655 let config_path = dir.path().join("config.toml");
4656 let external_path = dir.path().join("grok-external.json");
4657 fs::write(&external_path, "external owner bytes").unwrap();
4658 fs::write(
4659 &config_path,
4660 format!(
4661 r#"# operator note
4662 [providers.xai]
4663 model = "grok-code-fast-1" # model note
4664 future_setting = "preserve"
4665
4666 [providers.xai.external_credentials]
4667 access = "read_only"
4668 provider = "xai"
4669 source = "grok_cli"
4670 path = {}
4671 consent_version = 1
4672 "#,
4673 toml::Value::String(external_path.display().to_string())
4674 ),
4675 )
4676 .unwrap();
4677 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4678 let consent = codewhale_config::ExternalCredentialConsentToml::read_only(
4679 codewhale_config::ProviderKind::Xai,
4680 codewhale_config::ExternalCredentialSource::GrokCli,
4681 external_path.clone(),
4682 );
4683 let mut live = Config {
4684 providers: Some(crate::config::ProvidersConfig {
4685 xai: crate::config::ProviderConfig {
4686 model: Some("grok-code-fast-1".to_string()),
4687 external_credentials: Some(consent),
4688 ..Default::default()
4689 },
4690 ..Default::default()
4691 }),
4692 ..Default::default()
4693 };
4694
4695 crate::external_credentials::reset_side_effect_trap();
4696 let activation = activate_login(
4697 pending_login("activation-access", "activation-refresh"),
4698 Some(&config_path),
4699 Some(&mut live),
4700 )
4701 .expect("activate login");
4702
4703 assert_eq!(activation.config_path, config_path);
4704 let generation = activation
4705 .auth_path
4706 .file_name()
4707 .and_then(|name| name.to_str())
4708 .expect("generation basename");
4709 assert!(codewhale_config::is_valid_xai_oauth_generation(generation));
4710 let persisted = fs::read_to_string(&config_path).unwrap();
4711 assert!(persisted.contains("# operator note"));
4712 assert!(persisted.contains("model = \"grok-code-fast-1\" # model note"));
4713 assert!(persisted.contains("future_setting = \"preserve\""));
4714 assert!(persisted.contains("auth_mode = \"oauth\""));
4715 assert!(persisted.contains(&format!("oauth_credential_generation = \"{generation}\"")));
4716 assert!(!persisted.contains("external_credentials"));
4717 assert_eq!(
4718 fs::read_to_string(&external_path).unwrap(),
4719 "external owner bytes"
4720 );
4721 let owned = fs::read_to_string(&activation.auth_path).unwrap();
4722 assert!(owned.contains("activation-access"));
4723 assert!(owned.contains("activation-refresh"));
4724 #[cfg(unix)]
4725 assert_eq!(
4726 fs::metadata(&activation.auth_path)
4727 .unwrap()
4728 .permissions()
4729 .mode()
4730 & 0o777,
4731 0o600
4732 );
4733 let live_xai = live.provider_config_for(ApiProvider::Xai).unwrap();
4734 assert_eq!(live_xai.auth_mode.as_deref(), Some("oauth"));
4735 assert_eq!(
4736 live_xai.oauth_credential_generation.as_deref(),
4737 Some(generation)
4738 );
4739 assert!(live_xai.external_credentials.is_none());
4740 assert_eq!(
4741 crate::external_credentials::complete_side_effect_trap_counts(),
4742 (0, 0, 1, 0, 0),
4743 "activation must reach exactly the owned write sink"
4744 );
4745 }
4746
4747 #[test]
4748 fn activation_retires_legacy_owned_file_only_after_config_commit() {
4749 let _guard = crate::test_support::lock_test_env();
4750 let dir = TempDir::new().unwrap();
4751 let home = dir
4752 .path()
4753 .canonicalize()
4754 .expect("canonical temp root")
4755 .join("owned-home");
4756 let config_path = dir.path().join("config.toml");
4757 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
4758 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4759 let legacy_path = seed_legacy_owned_credentials();
4760 assert!(legacy_path.exists());
4761
4762 let activation = activate_login(
4763 pending_login("new-access", "new-refresh"),
4764 Some(&config_path),
4765 None,
4766 )
4767 .expect("activate replacement generation");
4768
4769 assert!(activation.auth_path.exists());
4770 assert!(
4771 !legacy_path.exists(),
4772 "legacy duplicate must be removed after the generation pointer commits"
4773 );
4774 let persisted = fs::read_to_string(config_path).unwrap();
4775 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
4776 }
4777
4778 #[test]
4779 fn activation_rotation_cleans_only_the_superseded_generation_after_commit() {
4780 let _guard = crate::test_support::lock_test_env();
4781 let dir = TempDir::new().unwrap();
4782 let home = dir
4783 .path()
4784 .canonicalize()
4785 .expect("canonical temp root")
4786 .join("owned-home");
4787 let config_path = dir.path().join("config.toml");
4788 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
4789 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4790 let mut live = Config::default();
4791
4792 let first = activate_login(
4793 pending_login("first-access", "first-refresh"),
4794 Some(&config_path),
4795 Some(&mut live),
4796 )
4797 .expect("first activation");
4798 assert!(first.auth_path.exists());
4799 let first_name = first
4800 .auth_path
4801 .file_name()
4802 .unwrap()
4803 .to_str()
4804 .unwrap()
4805 .to_string();
4806
4807 let second = activate_login(
4808 pending_login("second-access", "second-refresh"),
4809 Some(&config_path),
4810 Some(&mut live),
4811 )
4812 .expect("second activation");
4813 assert_ne!(first.auth_path, second.auth_path);
4814 assert!(second.auth_path.exists());
4815 assert!(
4816 !first.auth_path.exists(),
4817 "superseded generation must be removed only after the new pointer commits"
4818 );
4819 let persisted = fs::read_to_string(&config_path).unwrap();
4820 assert!(!persisted.contains(&first_name));
4821 assert!(persisted.contains(second.auth_path.file_name().unwrap().to_str().unwrap()));
4822 assert!(
4823 fs::read_to_string(second.auth_path)
4824 .unwrap()
4825 .contains("second-access")
4826 );
4827 }
4828
4829 #[test]
4830 fn activation_recovers_from_a_dangling_generation_pointer() {
4831 let _guard = crate::test_support::lock_test_env();
4832 let dir = TempDir::new().unwrap();
4833 let home = dir
4834 .path()
4835 .canonicalize()
4836 .expect("canonical temp root")
4837 .join("owned-home");
4838 let config_path = dir.path().join("config.toml");
4839 // A valid-looking generation pointer whose credential file does not
4840 // exist: the state Hunter's dogfood machine was bricked in (#5032).
4841 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
4842 fs::write(
4843 &config_path,
4844 format!(
4845 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
4846 ),
4847 )
4848 .unwrap();
4849 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4850 let mut live = Config::default();
4851
4852 let activation = activate_login(
4853 pending_login("recovered-access", "recovered-refresh"),
4854 Some(&config_path),
4855 Some(&mut live),
4856 )
4857 .expect("a dangling generation pointer must not brick login");
4858 assert!(activation.auth_path.exists());
4859 assert!(
4860 fs::read_to_string(&activation.auth_path)
4861 .unwrap()
4862 .contains("recovered-access")
4863 );
4864 let persisted = fs::read_to_string(&config_path).unwrap();
4865 assert!(
4866 !persisted.contains(stale),
4867 "stale pointer must be replaced: {persisted}"
4868 );
4869 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
4870 assert!(persisted.contains("auth_mode = \"oauth\""));
4871 }
4872
4873 #[test]
4874 fn dangling_generation_pointer_is_detected_and_repaired() {
4875 let _guard = crate::test_support::lock_test_env();
4876 let dir = TempDir::new().unwrap();
4877 let home = dir
4878 .path()
4879 .canonicalize()
4880 .expect("canonical temp root")
4881 .join("owned-home");
4882 let config_path = dir.path().join("config.toml");
4883 // A valid-looking generation pointer whose credential file does not
4884 // exist: the state Hunter's dogfood machine was bricked in (#5032).
4885 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
4886 fs::write(
4887 &config_path,
4888 format!(
4889 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
4890 ),
4891 )
4892 .unwrap();
4893 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4894
4895 let config = Config {
4896 provider: Some(ApiProvider::Xai.as_str().to_string()),
4897 providers: Some(crate::config::ProvidersConfig {
4898 xai: crate::config::ProviderConfig {
4899 auth_mode: Some("oauth".to_string()),
4900 oauth_credential_generation: Some(stale.to_string()),
4901 ..Default::default()
4902 },
4903 ..Default::default()
4904 }),
4905 ..Default::default()
4906 };
4907
4908 assert!(
4909 owned_generation_is_dangling(OAuthProvider::Xai, &config),
4910 "OAuth mode pointing at a missing owned file is the #5032 bricked state"
4911 );
4912 // Specificity: OAuth selected but no generation configured is the normal
4913 // "needs auth" state, not a dangling pointer.
4914 let unconfigured = Config {
4915 provider: Some(ApiProvider::Xai.as_str().to_string()),
4916 providers: Some(crate::config::ProvidersConfig {
4917 xai: crate::config::ProviderConfig {
4918 auth_mode: Some("oauth".to_string()),
4919 ..Default::default()
4920 },
4921 ..Default::default()
4922 }),
4923 ..Default::default()
4924 };
4925 assert!(
4926 !owned_generation_is_dangling(OAuthProvider::Xai, &unconfigured),
4927 "an unconfigured OAuth mode must not be reported as dangling"
4928 );
4929
4930 clear_dangling_generation(OAuthProvider::Xai, Some(&config_path))
4931 .expect("best-effort repair must clear the stale pointer");
4932
4933 let persisted = fs::read_to_string(&config_path).unwrap();
4934 assert!(
4935 !persisted.contains(stale),
4936 "stale generation pointer must be cleared: {persisted}"
4937 );
4938 assert!(
4939 persisted.contains("auth_mode = \"oauth\""),
4940 "the user's OAuth mode selection must be preserved: {persisted}"
4941 );
4942 }
4943
4944 #[test]
4945 fn activation_rejects_a_non_string_generation_pointer_without_staging_credentials() {
4946 let _guard = crate::test_support::lock_test_env();
4947 let dir = TempDir::new().unwrap();
4948 let home = dir
4949 .path()
4950 .canonicalize()
4951 .expect("canonical temp root")
4952 .join("owned-home");
4953 let config_path = dir.path().join("config.toml");
4954 let original = "[providers.xai]\noauth_credential_generation = { path = \"attacker\" }\n";
4955 fs::write(&config_path, original).unwrap();
4956 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4957
4958 let error = activate_login(
4959 pending_login("must-not-stage", "must-not-persist"),
4960 Some(&config_path),
4961 None,
4962 )
4963 .expect_err("non-string generation pointers must fail closed");
4964 assert!(error.to_string().contains("not activated"), "{error:#}");
4965 assert_eq!(fs::read_to_string(&config_path).unwrap(), original);
4966 let credentials = home.join("credentials");
4967 assert!(credentials.exists(), "lifecycle lock directory is durable");
4968 assert!(fs::read_dir(credentials).unwrap().all(|entry| {
4969 let name = entry.unwrap().file_name();
4970 let name = name.to_string_lossy();
4971 name != codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
4972 && !codewhale_config::is_valid_xai_oauth_generation(&name)
4973 }));
4974 }
4975
4976 #[cfg(unix)]
4977 #[test]
4978 fn activation_failure_cleans_unreferenced_stage_and_keeps_live_config_inert() {
4979 let _guard = crate::test_support::lock_test_env();
4980 let dir = TempDir::new().unwrap();
4981 let home = dir
4982 .path()
4983 .canonicalize()
4984 .expect("canonical temp root")
4985 .join("owned-home");
4986 let config_dir = dir.path().join("config-parent");
4987 fs::create_dir(&config_dir).unwrap();
4988 let config_path = config_dir.join("config.toml");
4989 fs::write(&config_path, "[providers.xai]\nauth_mode = \"api_key\"\n").unwrap();
4990 fs::create_dir(config_dir.join("config.toml.bak")).unwrap();
4991 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
4992 let legacy_path = seed_legacy_owned_credentials();
4993 let legacy_before = fs::read(&legacy_path).unwrap();
4994 let mut live = Config {
4995 providers: Some(crate::config::ProvidersConfig {
4996 xai: crate::config::ProviderConfig {
4997 auth_mode: Some("api_key".to_string()),
4998 api_key: Some("still-selected".to_string()),
4999 ..Default::default()
5000 },
5001 ..Default::default()
5002 }),
5003 ..Default::default()
5004 };
5005
5006 let result = activate_login(
5007 pending_login("must-be-cleaned", "must-not-persist"),
5008 Some(&config_path),
5009 Some(&mut live),
5010 );
5011 let error = result.expect_err("invalid backup path must fail activation");
5012 assert!(error.to_string().contains("not activated"), "{error:#}");
5013 let live_xai = live.provider_config_for(ApiProvider::Xai).unwrap();
5014 assert_eq!(live_xai.auth_mode.as_deref(), Some("api_key"));
5015 assert!(live_xai.oauth_credential_generation.is_none());
5016 assert_eq!(
5017 fs::read(&legacy_path).unwrap(),
5018 legacy_before,
5019 "legacy owned credentials must remain byte-identical until activation commits"
5020 );
5021 let credentials = home.join("credentials");
5022 if credentials.exists() {
5023 assert!(
5024 fs::read_dir(credentials).unwrap().all(|entry| {
5025 let name = entry.unwrap().file_name();
5026 let name = name.to_string_lossy();
5027 name == codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
5028 || !codewhale_config::is_valid_xai_oauth_generation(&name)
5029 }),
5030 "failed activation must remove every unreferenced generation but retain legacy"
5031 );
5032 }
5033 assert!(
5034 !fs::read_to_string(config_path)
5035 .unwrap()
5036 .contains("must-be-cleaned")
5037 );
5038 }
5039
5040 #[test]
5041 fn missing_file_message_mentions_oauth_paths() {
5042 let _guard = crate::test_support::lock_test_env();
5043 let msg = missing_auth_message(OAuthProvider::Xai);
5044 assert!(msg.contains("xAI OAuth credentials not found"), "{msg}");
5045 assert!(msg.contains("external-consent"), "{msg}");
5046 assert!(msg.contains("Codewhale-owned OAuth storage"), "{msg}");
5047 assert!(msg.contains("XAI_API_KEY"), "{msg}");
5048 }
5049
5050 #[test]
5051 fn parse_rfc3339_accepts_zulu() {
5052 let ts = parse_rfc3339_secs("2026-07-09T12:00:00.000Z").expect("parse");
5053 assert!(ts > 0);
5054 }
5055
5056 #[test]
5057 fn device_code_constants_match_discovery_shape() {
5058 assert_eq!(
5059 DEFAULT_SCOPES.split_whitespace().collect::<Vec<_>>(),
5060 [
5061 "openid",
5062 "profile",
5063 "email",
5064 "offline_access",
5065 "api:access",
5066 "grok-cli:access",
5067 ]
5068 );
5069 assert_eq!(XAI_OIDC_ISSUER, "https://auth.x.ai");
5070 assert_eq!(GROK_OIDC_CLIENT_ID.len(), 36);
5071 }
5072
5073 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5074 async fn discovery_binds_to_advertised_endpoints() {
5075 let server = MockServer::start().await;
5076 Mock::given(method("GET"))
5077 .and(path("/.well-known/openid-configuration"))
5078 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5079 "issuer": server.uri(),
5080 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
5081 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
5082 })))
5083 .expect(1)
5084 .mount(&server)
5085 .await;
5086
5087 let endpoints = tokio::task::block_in_place(|| {
5088 discover_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri()).expect("discover endpoints")
5089 });
5090
5091 assert_eq!(
5092 endpoints,
5093 OAuthEndpoints {
5094 device_authorization_endpoint: Some(format!(
5095 "{}/oauth2/device-advertised",
5096 server.uri()
5097 )),
5098 token_endpoint: format!("{}/oauth2/token-advertised", server.uri()),
5099 }
5100 );
5101 }
5102
5103 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5104 async fn refresh_uses_discovered_token_endpoint() {
5105 let server = MockServer::start().await;
5106 Mock::given(method("GET"))
5107 .and(path("/.well-known/openid-configuration"))
5108 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5109 "issuer": server.uri(),
5110 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
5111 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
5112 })))
5113 .expect(1)
5114 .mount(&server)
5115 .await;
5116 Mock::given(method("POST"))
5117 .and(path("/oauth2/token-advertised"))
5118 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5119 "access_token": "refreshed-access",
5120 "refresh_token": "rotated-refresh",
5121 "expires_in": 3600
5122 })))
5123 .expect(1)
5124 .mount(&server)
5125 .await;
5126
5127 let token = tokio::task::block_in_place(|| {
5128 refresh_for_provider(
5129 OAuthProvider::Xai,
5130 &ReqwestOAuthFormClient,
5131 &server.uri(),
5132 GROK_OIDC_CLIENT_ID,
5133 "refresh-secret",
5134 )
5135 .expect("refresh token")
5136 });
5137
5138 assert_eq!(token.access_token.as_deref(), Some("refreshed-access"));
5139 assert_eq!(token.refresh_token.as_deref(), Some("rotated-refresh"));
5140 }
5141
5142 #[test]
5143 fn https_discovery_rejects_plaintext_endpoint_downgrade() {
5144 let error = validate_discovered_oauth_endpoint(
5145 Some("http://auth.x.ai/oauth2/device/code".to_string()),
5146 "device_authorization_endpoint",
5147 XAI_OIDC_ISSUER,
5148 )
5149 .expect_err("HTTPS issuer must reject an HTTP endpoint");
5150
5151 assert!(error.to_string().contains("downgrade"), "{error}");
5152 }
5153
5154 #[test]
5155 fn https_discovery_accepts_same_origin_with_explicit_default_port() {
5156 let endpoint = "https://auth.x.ai:443/oauth2/token";
5157 let validated = validate_discovered_oauth_endpoint(
5158 Some(endpoint.to_string()),
5159 "token_endpoint",
5160 XAI_OIDC_ISSUER,
5161 )
5162 .expect("URL origins normalize the explicit default HTTPS port");
5163
5164 assert_eq!(validated, endpoint);
5165 }
5166
5167 #[test]
5168 fn https_discovery_rejects_cross_origin_endpoint() {
5169 let error = validate_discovered_oauth_endpoint(
5170 Some("https://oauth.attacker.example/oauth2/token".to_string()),
5171 "token_endpoint",
5172 XAI_OIDC_ISSUER,
5173 )
5174 .expect_err("discovered OAuth endpoints must stay on the issuer origin");
5175
5176 assert!(error.to_string().contains("different origin"), "{error}");
5177 }
5178
5179 #[test]
5180 fn discovery_rejects_mismatched_issuer() {
5181 let error = validate_discovered_issuer(
5182 Some("https://attacker.example".to_string()),
5183 XAI_OIDC_ISSUER,
5184 )
5185 .expect_err("discovery issuer must bind to the request issuer");
5186
5187 assert!(error.to_string().contains("does not match"), "{error}");
5188 }
5189
5190 #[test]
5191 fn oauth_error_details_collapse_control_whitespace() {
5192 let detail = oauth_failure_detail(
5193 Some("invalid_scope\nforged"),
5194 Some("bad\t scope\r\nnext line"),
5195 reqwest::StatusCode::BAD_REQUEST,
5196 );
5197
5198 assert!(
5199 !detail
5200 .chars()
5201 .any(|character| matches!(character, '\n' | '\r' | '\t')),
5202 "{detail}"
5203 );
5204 assert!(detail.contains("invalid_scope forged"), "{detail}");
5205 assert!(detail.contains("bad scope next line"), "{detail}");
5206 }
5207
5208 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5209 async fn discovery_failure_uses_documented_endpoint_fallback() {
5210 let server = MockServer::start().await;
5211 Mock::given(method("GET"))
5212 .and(path("/.well-known/openid-configuration"))
5213 .respond_with(
5214 ResponseTemplate::new(503)
5215 .set_body_raw("<html>temporarily unavailable</html>", "text/html"),
5216 )
5217 .expect(1)
5218 .mount(&server)
5219 .await;
5220
5221 let endpoints = tokio::task::block_in_place(|| {
5222 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
5223 });
5224
5225 assert_eq!(
5226 endpoints,
5227 OAuthEndpoints {
5228 device_authorization_endpoint: Some(format!("{}/oauth2/device/code", server.uri())),
5229 token_endpoint: format!("{}/oauth2/token", server.uri()),
5230 }
5231 );
5232 }
5233
5234 /// End-to-end regression for the v0.9.4 dogfood failure (#5032): starting
5235 /// from the exact state the dogfood machine was bricked in — a
5236 /// `providers.xai.oauth_credential_generation` pointer whose credential
5237 /// file no longer exists — the full device flow (discovery, device-code
5238 /// request, token poll, activation) must succeed and replace the stale
5239 /// pointer instead of dying with "xAI login was not activated; provider
5240 /// configuration is unchanged".
5241 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5242 async fn device_login_end_to_end_recovers_from_dangling_generation_pointer() {
5243 let _guard = crate::test_support::lock_test_env();
5244 let server = MockServer::start().await;
5245 Mock::given(method("GET"))
5246 .and(path("/.well-known/openid-configuration"))
5247 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5248 "issuer": server.uri(),
5249 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
5250 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
5251 })))
5252 .expect(1)
5253 .mount(&server)
5254 .await;
5255 Mock::given(method("POST"))
5256 .and(path("/oauth2/device-advertised"))
5257 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5258 "device_code": "device-token",
5259 "user_code": "CW-TEST",
5260 "verification_uri": format!("{}/verify", server.uri()),
5261 "expires_in": 60,
5262 "interval": 1
5263 })))
5264 .expect(1)
5265 .mount(&server)
5266 .await;
5267 Mock::given(method("POST"))
5268 .and(path("/oauth2/token-advertised"))
5269 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
5270 "access_token": "e2e-xai-access",
5271 "refresh_token": "e2e-xai-refresh",
5272 "expires_in": 3600,
5273 "token_type": "Bearer"
5274 })))
5275 .expect(1)
5276 .mount(&server)
5277 .await;
5278
5279 let dir = TempDir::new().unwrap();
5280 let home = dir
5281 .path()
5282 .canonicalize()
5283 .expect("canonical temp root")
5284 .join("owned-home");
5285 let config_path = dir.path().join("config.toml");
5286 // The exact dogfood-machine state: valid-looking generation pointer,
5287 // missing credential file.
5288 let stale = "xai-auth-39a2f3e766ab47f89490002cd04fe187.json";
5289 fs::write(
5290 &config_path,
5291 format!(
5292 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
5293 ),
5294 )
5295 .unwrap();
5296 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
5297
5298 // The login half runs through the unified flow; only activation is
5299 // still legacy here (3b-ii unifies it).
5300 let inputs = crate::oauth::ResolvedOAuthInputs {
5301 issuer: server.uri(),
5302 client_id: GROK_OIDC_CLIENT_ID.to_string(),
5303 scopes: DEFAULT_SCOPES.to_string(),
5304 open_browser: false,
5305 };
5306 let unified = tokio::task::block_in_place(|| {
5307 crate::oauth::device_code_login_with(crate::oauth::OAuthProvider::Xai, &inputs)
5308 })
5309 .expect("device login against mock xAI");
5310 let pending = unified;
5311 let mut live = Config::default();
5312 let activation = activate_login(pending, Some(&config_path), Some(&mut live))
5313 .expect("dangling pointer must not brick activation");
5314
5315 assert!(activation.auth_path.exists());
5316 let owned = fs::read_to_string(&activation.auth_path).unwrap();
5317 assert!(owned.contains("e2e-xai-access"));
5318 assert!(owned.contains("e2e-xai-refresh"));
5319 let persisted = fs::read_to_string(&config_path).unwrap();
5320 assert!(
5321 !persisted.contains(stale),
5322 "stale pointer must be replaced: {persisted}"
5323 );
5324 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
5325 assert!(persisted.contains("auth_mode = \"oauth\""));
5326 assert!(
5327 credentials_valid(OAuthProvider::Xai, &live),
5328 "activated login must be usable"
5329 );
5330 }
5331
5332 #[test]
5333 fn apply_token_response_sets_expiry_from_expires_in() {
5334 let mut entry = OwnedAuthEntry {
5335 access_token: None,
5336 refresh_token: None,
5337 expires_at: None,
5338 id_token: None,
5339 account_id: None,
5340 oidc_issuer: None,
5341 oidc_client_id: None,
5342 originator: None,
5343 auth_mode: None,
5344 extra: BTreeMap::new(),
5345 };
5346 let token = OAuthTokenMaterial {
5347 id_token: None,
5348 access_token: Some("fresh-access".to_string()),
5349 refresh_token: Some("fresh-refresh".to_string()),
5350 expires_in: Some(3600),
5351 error: None,
5352 error_description: None,
5353 interval: None,
5354 };
5355 let before = now_unix_secs().expect("clock");
5356
5357 apply_token_response(
5358 OAuthProvider::Xai,
5359 &mut entry,
5360 XAI_OIDC_ISSUER,
5361 GROK_OIDC_CLIENT_ID,
5362 &token,
5363 )
5364 .expect("apply token");
5365
5366 assert_eq!(entry.access_token.as_deref(), Some("fresh-access"));
5367 assert_eq!(entry.refresh_token.as_deref(), Some("fresh-refresh"));
5368 let expires_at = entry
5369 .expires_at
5370 .as_deref()
5371 .and_then(parse_rfc3339_secs)
5372 .expect("expires_at set from expires_in");
5373 let after = now_unix_secs().expect("clock");
5374 assert!(
5375 expires_at >= before + 3600,
5376 "{expires_at} < {before} + 3600"
5377 );
5378 assert!(expires_at <= after + 3600, "{expires_at} > {after} + 3600");
5379 }
5380
5381 #[test]
5382 fn apply_token_response_rejects_missing_access_token() {
5383 let mut entry = OwnedAuthEntry {
5384 access_token: None,
5385 refresh_token: None,
5386 expires_at: None,
5387 id_token: None,
5388 account_id: None,
5389 oidc_issuer: None,
5390 oidc_client_id: None,
5391 originator: None,
5392 auth_mode: None,
5393 extra: BTreeMap::new(),
5394 };
5395 let token = OAuthTokenMaterial {
5396 id_token: None,
5397 access_token: None,
5398 refresh_token: None,
5399 expires_in: None,
5400 error: None,
5401 error_description: None,
5402 interval: None,
5403 };
5404
5405 let error = apply_token_response(
5406 OAuthProvider::Xai,
5407 &mut entry,
5408 XAI_OIDC_ISSUER,
5409 GROK_OIDC_CLIENT_ID,
5410 &token,
5411 )
5412 .expect_err("missing access_token must fail");
5413
5414 assert!(
5415 error.to_string().contains("missing access_token"),
5416 "{error}"
5417 );
5418 }
5419
5420 struct MockTokenClient {
5421 responses: Mutex<Vec<(u16, String)>>,
5422 posts: Mutex<Vec<MockPost>>,
5423 }
5424
5425 impl MockTokenClient {
5426 fn new(responses: Vec<(u16, String)>) -> Self {
5427 Self {
5428 responses: Mutex::new(responses),
5429 posts: Mutex::new(Vec::new()),
5430 }
5431 }
5432 }
5433
5434 impl crate::oauth::OAuthFormClient for MockTokenClient {
5435 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
5436 self.posts.lock().expect("posts").push((
5437 url.to_string(),
5438 form.iter()
5439 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
5440 .collect(),
5441 ));
5442 let mut responses = self.responses.lock().expect("responses");
5443 anyhow::ensure!(
5444 !responses.is_empty(),
5445 "mock issuer has no remaining responses"
5446 );
5447 Ok(responses.remove(0))
5448 }
5449 }
5450
5451 fn jwt_with_exp(exp: u64) -> String {
5452 let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
5453 format!("header.{payload}.sig")
5454 }
5455
5456 #[test]
5457 fn store_persist_refresh_and_revoke_use_mock_issuer() {
5458 let _lock = crate::test_support::lock_test_env();
5459 let home = tempfile::tempdir().expect("temp home");
5460 let root = home.path().canonicalize().expect("canonical home");
5461 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5462 let config_path = root.join("config.toml");
5463 std::fs::write(&config_path, "").expect("empty config");
5464
5465 let pending = pending_login_with_id_token_for_test(
5466 OAuthProvider::Chatgpt,
5467 "access-1",
5468 "refresh-1",
5469 Some(&jwt_with_account("acct-7")),
5470 );
5471 let activation = activate_login(pending, Some(&config_path), None).expect("activate");
5472 assert!(activation.auth_path.exists());
5473 let persisted = std::fs::read_to_string(root.join("config.toml")).expect("config");
5474 assert!(persisted.contains("chatgpt-auth-"));
5475 assert!(persisted.contains("auth_mode = \"oauth\""));
5476 assert!(!persisted.contains("access-1"), "{persisted}");
5477
5478 let generation = toml::from_str::<toml::Value>(&persisted)
5479 .unwrap()["providers"]["openai_codex"]["oauth_credential_generation"]
5480 .as_str()
5481 .unwrap()
5482 .to_string();
5483 let mut config = Config {
5484 provider: Some(ApiProvider::OpenaiCodex.as_str().to_string()),
5485 ..Config::default()
5486 };
5487 config.mark_codewhale_owned_chatgpt_oauth(generation.clone());
5488 assert!(credentials_valid(OAuthProvider::Chatgpt, &config));
5489
5490 let stale = jwt_with_exp(1_000_000_000);
5491 let scope = format!("{CHATGPT_OAUTH_ISSUER}::{CHATGPT_OAUTH_CLIENT_ID}");
5492 let raw = serde_json::json!({
5493 &scope: {
5494 "access_token": stale,
5495 "refresh_token": "refresh-old",
5496 // Conflicting metadata must not suppress the existing refresh.
5497 "expires_at": rfc3339_from_now(3600),
5498 "oidc_issuer": CHATGPT_OAUTH_ISSUER,
5499 "oidc_client_id": CHATGPT_OAUTH_CLIENT_ID,
5500 "originator": CHATGPT_OAUTH_ORIGINATOR
5501 }
5502 });
5503 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
5504 store.write(
5505 &generation,
5506 serde_json::to_vec_pretty(&raw).unwrap().as_slice(),
5507 true,
5508 )
5509 })
5510 .unwrap();
5511
5512 let mock = MockTokenClient::new(vec![(
5513 200,
5514 serde_json::json!({
5515 "access_token": "access-2",
5516 "refresh_token": "refresh-2",
5517 "expires_in": 3600
5518 })
5519 .to_string(),
5520 )]);
5521 let refreshed =
5522 get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &mock).expect("refresh");
5523 assert_eq!(refreshed.access_token, "access-2");
5524 let stored = codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
5525 store.read_to_string(&generation)
5526 })
5527 .unwrap()
5528 .unwrap();
5529 assert!(stored.contains("refresh-2"), "{stored}");
5530 assert!(!stored.contains("refresh-old"), "{stored}");
5531
5532 let revoke_mock = MockTokenClient::new(vec![(200, String::new())]);
5533 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
5534 revoke_owned_login_locked_with(
5535 OAuthProvider::Chatgpt,
5536 Some(&config_path),
5537 None,
5538 store,
5539 &revoke_mock,
5540 )
5541 })
5542 .expect("revoke");
5543 let after = std::fs::read_to_string(&config_path).expect("config after revoke");
5544 assert!(!after.contains("chatgpt-auth-"), "{after}");
5545 let posts = revoke_mock.posts.lock().unwrap();
5546 assert!(
5547 posts.iter().any(|(url, _)| url.contains("/oauth/revoke")),
5548 "{posts:?}"
5549 );
5550 }
5551
5552 #[test]
5553 fn debug_impls_redact_secrets() {
5554 let entry = OwnedAuthEntry {
5555 access_token: Some("secret-access".into()),
5556 refresh_token: Some("secret-refresh".into()),
5557 expires_at: None,
5558 id_token: Some("secret-id".into()),
5559 account_id: None,
5560 oidc_issuer: None,
5561 oidc_client_id: None,
5562 originator: None,
5563 auth_mode: None,
5564 extra: BTreeMap::new(),
5565 };
5566 let rendered = format!("{entry:?}");
5567 assert!(rendered.contains("<redacted>"));
5568 assert!(!rendered.contains("secret-access"));
5569 assert!(!rendered.contains("secret-refresh"));
5570 assert!(!rendered.contains("secret-id"));
5571
5572 let activation = OAuthActivation {
5573 credentials: OwnedOAuthCredentials {
5574 access_token: "secret-access".into(),
5575 account_id: None,
5576 refresh_token: Some("secret-refresh".into()),
5577 expires_at: None,
5578 issuer: "issuer".into(),
5579 client_id: "client".into(),
5580 },
5581 config_path: PathBuf::from("/tmp/config.toml"),
5582 auth_path: PathBuf::from("/tmp/auth.json"),
5583 };
5584 let rendered = format!("{activation:?}");
5585 assert!(rendered.contains("<redacted>"));
5586 assert!(!rendered.contains("secret-access"));
5587 }
5588 }
5589
5589 lines RUST