返回 CodeWhale
oauth.rs
根目录 / crates / tui / src / oauth.rs
1 //! Codewhale-owned OAuth login and protected credential lifecycle.
2 //! ChatGPT uses its official dynamic registration and direct inference grant.
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::{IsTerminal, 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 jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
24 use serde::{Deserialize, Serialize};
25 use serde_json::Value;
26 use sha2::{Digest, Sha256};
27
28 use crate::config::Config;
29
30 /// OAuth token payload stored in `auth.json`.
31 #[derive(Debug, Clone, Deserialize)]
32 #[serde(rename_all = "snake_case")]
33 struct AuthTokens {
34 access_token: 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 }
49
50 /// JWT claims subset for expiry extraction.
51 #[derive(Debug, Deserialize)]
52 struct JwtClaims {
53 exp: Option<u64>,
54 }
55
56 /// Resolve the path to the Codex auth file.
57 ///
58 /// Priority:
59 /// 1. `OPENAI_CODEX_AUTH_FILE` env var
60 /// 2. `$CODEX_HOME/auth.json`
61 /// 3. `~/.codex/auth.json`
62 pub fn auth_file_path() -> PathBuf {
63 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
64 let p = PathBuf::from(&path);
65 if !p.as_os_str().is_empty() {
66 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
67 }
68 }
69 let codex_home = std::env::var("CODEX_HOME")
70 .map(PathBuf::from)
71 .unwrap_or_else(|_| {
72 crate::config::effective_home_dir()
73 .unwrap_or_else(|| PathBuf::from("."))
74 .join(".codex")
75 });
76 let path = codex_home.join("auth.json");
77 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
78 }
79
80 /// Try to extract `exp` (epoch seconds) from a JWT without verifying
81 /// the signature. Returns `None` on any parse failure.
82 fn jwt_expiry_seconds(token: &str) -> Option<u64> {
83 let parts: Vec<&str> = token.split('.').collect();
84 if parts.len() < 2 {
85 return None;
86 }
87 let payload = parts[1];
88 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
89 let claims: JwtClaims = serde_json::from_slice(&decoded).ok()?;
90 claims.exp
91 }
92
93 /// Check whether an access token is expired, with a 60-second safety margin.
94 fn token_is_expired(access_token: &str) -> bool {
95 match jwt_expiry_seconds(access_token) {
96 Some(exp) => {
97 let now = SystemTime::now()
98 .duration_since(UNIX_EPOCH)
99 .unwrap_or(Duration::ZERO)
100 .as_secs();
101 // 60-second safety margin
102 now + 60 >= exp
103 }
104 // If we can't prove freshness, fail closed. External credentials are
105 // never refreshed by Codewhale.
106 None => true,
107 }
108 }
109
110 /// Load Codex credentials from the auth file.
111 ///
112 /// Returns `Ok(None)` if the file doesn't exist or has no usable tokens.
113 /// Returns `Err` only on parse/IO errors that aren't "file not found".
114 fn load_credentials(grant: &ExternalCredentialReadGrant) -> Result<Option<CodexCredentials>> {
115 let Some(contents) = crate::external_credentials::read_to_string(grant)? else {
116 return Ok(None);
117 };
118 let auth: CodexAuthFile = serde_json::from_str(&contents).map_err(|_| {
119 anyhow::anyhow!(
120 "Codex credential file {} is not valid credential JSON",
121 codewhale_config::quote_os_path(grant.path())
122 )
123 })?;
124 let tokens = match auth.tokens {
125 Some(t) => t,
126 None => return Ok(None),
127 };
128 let access_token = match tokens.access_token {
129 Some(t) if !t.trim().is_empty() => t,
130 _ => return Ok(None),
131 };
132 Ok(Some(CodexCredentials { access_token }))
133 }
134
135 /// Validate only the stored OAuth file, excluding token environment
136 /// overrides so config-vs-env provenance remains truthful.
137 ///
138 /// This consumes a grant, so it can only run for a path the user explicitly
139 /// consented to. Consent is not a credential (#5772): status surfaces call
140 /// this to find out whether the consented file *still* holds a usable token,
141 /// because a record that outlives its token would otherwise read as stored.
142 #[must_use]
143 #[cfg(test)]
144 pub fn stored_credentials_present(grant: &ExternalCredentialReadGrant) -> bool {
145 load_credentials(grant)
146 .ok()
147 .flatten()
148 .is_some_and(|credentials| !token_is_expired(&credentials.access_token))
149 }
150
151 /// Load read-only credentials from the exact external path authorized by
152 /// `grant`. Expired tokens fail with guidance; they are never refreshed.
153 pub fn get_credentials(grant: &ExternalCredentialReadGrant) -> Result<CodexCredentials> {
154 let creds =
155 load_credentials(grant)?.with_context(|| missing_auth_message(OAuthProvider::Chatgpt))?;
156
157 // Check if the access token is still valid.
158 if !token_is_expired(&creds.access_token) {
159 return Ok(creds);
160 }
161
162 bail!(
163 "Codex access token in {} is expired. Read-only consent never refreshes or rewrites another CLI's credentials. Use `codewhale auth chatgpt` for the official ChatGPT plan route. To inspect this legacy credential file again, renew its login with `codex login`.",
164 codewhale_config::quote_os_path(grant.path())
165 )
166 }
167
168 // ── ONE access-route flow ─────────────────────────────────────────────
169 // Providers are DATA, not files. Every subscription login — xAI device
170 // code today, ChatGPT PKCE next — runs through the parameter table below
171 // and the shared device-code core; per-provider OAuth modules are deleted,
172 // not repaired.
173
174 /// How this run reaches a provider: an OAuth login Codewhale owns, a
175 /// read-only import from another CLI, a pasted key, or (reserved) an ACP
176 /// subscription bridge. The bridge arm lands with the ACP work; until then
177 /// it resolves to a clear error, never a silent fallback.
178 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
179 #[allow(
180 dead_code,
181 reason = "3b-ii wires the access-route dispatch that consumes this"
182 )]
183 pub enum AccessMethod {
184 /// Browser/device OAuth login whose tokens Codewhale stores and refreshes.
185 OwnedOAuth(OAuthProvider),
186 /// Read-only credentials owned by another CLI, behind a consent grant.
187 ExternalImport(ExternalImportSource),
188 /// A pasted API key. No login, no refresh, no storage beyond config.
189 ApiKey,
190 /// Subscription access through an ACP bridge (Antigravity, Copilot).
191 /// Reserved: no producer yet.
192 AcpBridge,
193 }
194
195 /// Providers with an OAuth login Codewhale can own.
196 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
197 pub enum OAuthProvider {
198 Xai,
199 /// No device-code producer yet; the PKCE path (3b-i(b)) constructs this.
200 #[allow(
201 dead_code,
202 reason = "3b-i(b) wires the PKCE login that constructs this"
203 )]
204 Chatgpt,
205 }
206
207 impl AccessMethod {
208 /// Short user-facing name for picker and status surfaces.
209 #[must_use]
210 #[allow(
211 dead_code,
212 reason = "3b-ii wires the access-route dispatch that consumes this"
213 )]
214 pub fn label(&self) -> &'static str {
215 match self {
216 AccessMethod::OwnedOAuth(OAuthProvider::Xai) => "xAI subscription",
217 AccessMethod::OwnedOAuth(OAuthProvider::Chatgpt) => "ChatGPT subscription",
218 AccessMethod::ExternalImport(ExternalImportSource::GrokCli) => "Grok CLI import",
219 AccessMethod::ExternalImport(ExternalImportSource::CodexCli) => "Codex CLI import",
220 AccessMethod::ExternalImport(ExternalImportSource::Antigravity) => "Antigravity import",
221 AccessMethod::ExternalImport(ExternalImportSource::Dsh) => "DSH import",
222 AccessMethod::ApiKey => "API key",
223 AccessMethod::AcpBridge => "ACP bridge",
224 }
225 }
226 }
227
228 /// Another CLI whose credentials can be imported read-only.
229 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
230 #[allow(dead_code, reason = "3b-ii wires the import table that consumes this")]
231 pub enum ExternalImportSource {
232 /// `~/.grok/auth.json` / `XAI_AUTH_PATH`, keyed by issuer::client-id.
233 GrokCli,
234 /// `~/.codex/auth.json`, `{tokens:{access_token, account_id}}`.
235 CodexCli,
236 /// Antigravity `state.vscdb` SQLite row, opened read-only.
237 Antigravity,
238 /// `$DSH_HOME/.credentials.yaml` flat mapping, `DEEPSEEK_API_KEY`.
239 Dsh,
240 }
241
242 /// Test/dev knobs a provider reads from the environment. Data, so a new
243 /// provider adds rows here instead of a new module.
244 pub struct OAuthEnvOverrides {
245 pub issuer_vars: &'static [&'static str],
246 pub client_id_vars: &'static [&'static str],
247 pub scope_vars: &'static [&'static str],
248 pub no_browser_var: &'static str,
249 /// Set (to anything) to drop [`OAuthProviderParams::account_choice_extras`]
250 /// from the authorize URL, in case the issuer rejects them.
251 pub no_account_prompt_var: Option<&'static str>,
252 }
253
254 /// Everything about one provider's OAuth login that is not logic.
255 pub struct OAuthProviderParams {
256 /// Human name for prompts and errors: "xAI", "ChatGPT".
257 pub display_name: &'static str,
258 pub default_issuer: &'static str,
259 pub default_client_id: &'static str,
260 pub default_scopes: &'static str,
261 pub env: OAuthEnvOverrides,
262 /// `Some` device-authorization path under the issuer (xAI); `None`
263 /// means the issuer offers no device flow and device login must fail
264 /// loudly instead of guessing (ChatGPT).
265 pub device_code_path: Option<&'static str>,
266 /// `Some` browser authorization path under the issuer (ChatGPT PKCE);
267 /// `None` means the issuer offers no browser flow and browser login
268 /// fails the same loud way (xAI is device-code only).
269 pub authorize_path: Option<&'static str>,
270 /// Token path under the issuer.
271 pub token_path: &'static str,
272 /// Whether the issuer was discovered (xAI) or pinned (ChatGPT paths).
273 pub discover_endpoints: bool,
274 /// Seconds the device-code poll runs past the server's `expires_in`.
275 pub device_poll_floor_secs: u64,
276 /// Extra authorize-endpoint parameters beyond the standard OAuth set,
277 /// sent verbatim so the issuer sees exactly who is calling.
278 pub authorize_extras: &'static [(&'static str, &'static str)],
279 /// Authorize parameters that ask the issuer to let the user choose the
280 /// account instead of reusing the browser's session. Sent unless
281 /// [`OAuthEnvOverrides::no_account_prompt_var`] is set.
282 pub account_choice_extras: &'static [(&'static str, &'static str)],
283 /// Honest client identity for issuers that require one (ChatGPT's
284 /// `originator`). Never impersonate another CLI.
285 pub originator: Option<&'static str>,
286 /// Remote revoke path under the issuer, pinned rather than discovered:
287 /// revoke must still clear local credentials when the issuer is
288 /// unreachable, so a discovery fetch would only add a failure mode to a
289 /// path whose contract is to clean up regardless. `None` when
290 /// revocation is purely local (xAI).
291 pub revoke_path: Option<&'static str>,
292 /// Registered loopback redirect for browser flows.
293 pub callback_path: &'static str,
294 /// Loopback ports the public client registered, in preference order.
295 pub loopback_ports: &'static [u16],
296 /// The command that re-runs this provider's login, for error guidance.
297 pub relogin_hint: &'static str,
298 /// The slash command that re-runs this login inside a running session
299 /// and switches that session's live client.
300 pub session_login_hint: &'static str,
301 /// What to tell the user when every callback port is taken.
302 pub callback_conflict_hint: &'static str,
303 }
304
305 pub const XAI_OAUTH_PARAMS: OAuthProviderParams = OAuthProviderParams {
306 display_name: "xAI",
307 // Single source: the legacy module still owns these strings until its
308 // activation path unifies and they move here in 3b-iii.
309 default_issuer: XAI_OIDC_ISSUER,
310 default_client_id: GROK_OIDC_CLIENT_ID,
311 default_scopes: DEFAULT_SCOPES,
312 env: OAuthEnvOverrides {
313 issuer_vars: &["GROK_OIDC_ISSUER", "XAI_OIDC_ISSUER"],
314 client_id_vars: &["GROK_OIDC_CLIENT_ID", "XAI_OIDC_CLIENT_ID"],
315 scope_vars: &["GROK_OIDC_SCOPES", "XAI_OIDC_SCOPES"],
316 no_browser_var: "CODEWHALE_XAI_OAUTH_NO_BROWSER",
317 no_account_prompt_var: None,
318 },
319 device_code_path: Some("oauth2/device/code"),
320 authorize_path: None,
321 token_path: "oauth2/token",
322 discover_endpoints: true,
323 device_poll_floor_secs: 30,
324 authorize_extras: &[],
325 account_choice_extras: &[],
326 originator: None,
327 revoke_path: None,
328 callback_path: "",
329 loopback_ports: &[],
330 relogin_hint: "codewhale auth xai-device",
331 session_login_hint: "/auth xai-device",
332 callback_conflict_hint: "",
333 };
334
335 pub const CHATGPT_OAUTH_PARAMS: OAuthProviderParams = OAuthProviderParams {
336 display_name: "ChatGPT",
337 // Single source: same arrangement as the xAI row above.
338 default_issuer: CHATGPT_OAUTH_ISSUER,
339 default_client_id: CHATGPT_OAUTH_CLIENT_ID,
340 default_scopes: CHATGPT_OAUTH_SCOPE,
341 originator: Some(CHATGPT_OAUTH_ORIGINATOR),
342 env: OAuthEnvOverrides {
343 issuer_vars: &["CODEWHALE_CHATGPT_OAUTH_ISSUER"],
344 client_id_vars: &["CODEWHALE_CHATGPT_OAUTH_CLIENT_ID"],
345 scope_vars: &[],
346 no_browser_var: "CODEWHALE_CHATGPT_OAUTH_NO_BROWSER",
347 no_account_prompt_var: Some("CODEWHALE_CHATGPT_OAUTH_NO_PROMPT"),
348 },
349 device_code_path: None,
350 authorize_path: Some("api/accounts/authorize"),
351 token_path: "api/accounts/oauth/token",
352 discover_endpoints: false,
353 device_poll_floor_secs: 30,
354 authorize_extras: &[("resource", CHATGPT_OAUTH_RESOURCE)],
355 // `prompt=login` (OIDC Core 1.0 §3.1.2.1) asks the issuer to re-prompt
356 // instead of silently reusing whichever ChatGPT account the browser is
357 // already signed into. Returning registrations still require the same
358 // verified account; a new account explicitly starts dynamic registration.
359 // Every browser login here is user-initiated; refresh
360 // never visits the authorize endpoint. CODEWHALE_CHATGPT_OAUTH_NO_PROMPT
361 // drops it should the issuer ever refuse it.
362 account_choice_extras: &[("prompt", "login")],
363 revoke_path: Some("api/accounts/oauth/revoke"),
364 callback_path: "/auth/callback",
365 loopback_ports: &[1455, 1457, 0],
366 relogin_hint: "codewhale auth chatgpt",
367 session_login_hint: "/auth chatgpt",
368 callback_conflict_hint: "Close the process holding the callback port and retry `codewhale auth chatgpt`.",
369 };
370
371 /// The parameter table. A provider login looks its row up here; adding a
372 /// provider means adding a row, never a module.
373 #[must_use]
374 pub fn oauth_provider_params(provider: OAuthProvider) -> &'static OAuthProviderParams {
375 match provider {
376 OAuthProvider::Xai => &XAI_OAUTH_PARAMS,
377 OAuthProvider::Chatgpt => &CHATGPT_OAUTH_PARAMS,
378 }
379 }
380
381 /// Resolved login inputs: schema defaults, environment-tested in order.
382 #[derive(Clone)]
383 pub struct ResolvedOAuthInputs {
384 pub issuer: String,
385 pub client_id: String,
386 pub scopes: String,
387 pub open_browser: bool,
388 }
389
390 impl OAuthProviderParams {
391 /// Resolve issuer/client/scopes from the environment, first var wins.
392 #[must_use]
393 pub fn resolve_inputs(&self) -> ResolvedOAuthInputs {
394 let first_set = |vars: &[&str], fallback: &str| {
395 vars.iter()
396 .filter_map(|var| std::env::var(var).ok())
397 .find(|value| !value.trim().is_empty())
398 .unwrap_or_else(|| fallback.to_string())
399 };
400 ResolvedOAuthInputs {
401 issuer: first_set(self.env.issuer_vars, self.default_issuer),
402 client_id: first_set(self.env.client_id_vars, self.default_client_id),
403 scopes: first_set(self.env.scope_vars, self.default_scopes),
404 open_browser: std::env::var_os(self.env.no_browser_var).is_none(),
405 }
406 }
407 }
408
409 /// Token material from a completed grant. No Debug: the tokens never print.
410 /// `interval` rides along because a `slow_down` error response may carry
411 /// the server's new minimum, which the loop prefers over its own tracked
412 /// value (RFC 8628 §3.5; WSL/VM clock drift).
413 #[derive(Clone, Deserialize)]
414 pub struct OAuthTokenMaterial {
415 pub access_token: Option<String>,
416 pub refresh_token: Option<String>,
417 pub expires_in: Option<u64>,
418 #[serde(default)]
419 pub earliest_refresh_at: Option<Value>,
420 /// OpenID Connect id token; carries the account claim when issued.
421 #[serde(default)]
422 pub id_token: Option<String>,
423 #[serde(default)]
424 pub scope: Option<String>,
425 #[serde(default)]
426 pub token_type: Option<String>,
427 /// Set only after cryptographic validation, never deserialized from a server.
428 #[serde(skip)]
429 pub(crate) verified_chatgpt: Option<ChatgptRegistration>,
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 #[derive(Debug, PartialEq, Eq)]
801 enum OAuthChallengeStream {
802 Stderr,
803 Stdout,
804 }
805
806 fn oauth_challenge_stream(
807 stderr_terminal: bool,
808 stdout_terminal: bool,
809 ) -> Result<OAuthChallengeStream> {
810 if stderr_terminal {
811 Ok(OAuthChallengeStream::Stderr)
812 } else if stdout_terminal {
813 Ok(OAuthChallengeStream::Stdout)
814 } else {
815 bail!(
816 "Sign-in requires a terminal for the private login challenge; run codewhale auth in an interactive terminal without redirecting both output streams"
817 );
818 }
819 }
820
821 fn oauth_challenge_writer() -> Result<Box<dyn std::io::Write + Send>> {
822 match oauth_challenge_stream(
823 std::io::stderr().is_terminal(),
824 std::io::stdout().is_terminal(),
825 )? {
826 OAuthChallengeStream::Stderr => Ok(Box::new(std::io::stderr())),
827 OAuthChallengeStream::Stdout => Ok(Box::new(std::io::stdout())),
828 }
829 }
830
831 /// Interactive device-code login for any provider whose row offers it.
832 /// Shows the verification URL + user code only on a terminal and polls until
833 /// approved. A provider with no device flow (ChatGPT) fails here with the
834 /// reason, instead of deep in transport code.
835 pub async fn device_code_login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
836 // Endpoint resolution does blocking HTTP (discovery): it must run on the
837 // blocking worker, never on the async executor. Providers with no device
838 // flow fail here, before any thread spawns and before any network.
839 let params = oauth_provider_params(provider);
840 if params.device_code_path.is_none() {
841 bail!(
842 "{} offers no device-code flow; sign in through the browser login instead",
843 params.display_name
844 );
845 }
846 let inputs = params.resolve_inputs();
847 let display_name = params.display_name;
848 let challenge = oauth_challenge_writer()?;
849 device_code_login_on_worker(provider, inputs, challenge)
850 .await
851 .with_context(|| format!("{display_name} device-code login worker failed"))
852 }
853
854 async fn device_code_login_on_worker(
855 provider: OAuthProvider,
856 inputs: ResolvedOAuthInputs,
857 mut challenge: Box<dyn std::io::Write + Send>,
858 ) -> Result<PendingOAuthLogin> {
859 tokio::task::spawn_blocking(move || {
860 device_code_login_with(provider, &inputs, challenge.as_mut())
861 })
862 .await
863 .context("device-code protocol worker failed")?
864 }
865
866 /// Blocking protocol worker. Production callers capture a terminal first;
867 /// protocol tests inject a private output buffer or sink.
868 fn device_code_login_with(
869 provider: OAuthProvider,
870 inputs: &ResolvedOAuthInputs,
871 challenge: &mut dyn std::io::Write,
872 ) -> Result<PendingOAuthLogin> {
873 let params = oauth_provider_params(provider);
874 let display_name = params.display_name;
875 let endpoints = resolve_oauth_endpoints(params, &inputs.issuer);
876 let Some(device_endpoint) = endpoints.device_authorization_endpoint else {
877 bail!(
878 "{display_name} offers no device-code flow; sign in through the browser login instead"
879 );
880 };
881 let token_endpoint = endpoints.token_endpoint;
882 let poll_floor_secs = params.device_poll_floor_secs;
883 let grant = request_device_grant(&device_endpoint, &inputs.client_id, &inputs.scopes)?;
884 let verify = grant
885 .verification_uri_complete
886 .clone()
887 .or(grant.verification_uri.clone())
888 .unwrap_or_else(|| format!("{}/device", inputs.issuer.trim_end_matches('/')));
889 // Off the wire, headed for `webbrowser::open`: must be a bare
890 // navigation, never a scheme or credential smuggle.
891 let verify = codewhale_config::device_code::validate_browser_verification_uri(
892 &verify,
893 &format!("{display_name} device-code request"),
894 )?;
895 let user_code = grant.user_code.unwrap_or_default();
896 anyhow::ensure!(
897 user_code.len() <= 1024 && !user_code.chars().any(char::is_control),
898 "device-code login returned invalid user-code display data"
899 );
900
901 writeln!(challenge, "{display_name} device-code login")?;
902 writeln!(challenge, " Open: {verify}")?;
903 writeln!(challenge, " Code: {user_code}")?;
904 writeln!(challenge, "{}", account_choice_hint(display_name))?;
905 writeln!(
906 challenge,
907 "Waiting for approval in the browser… (Ctrl+C to abort)"
908 )?;
909 challenge.flush()?;
910 if inputs.open_browser && webbrowser::open(&verify).is_err() {
911 writeln!(
912 challenge,
913 "Could not open the browser automatically; open the displayed URL manually"
914 )?;
915 }
916
917 let lifetime = Duration::from_secs(
918 grant
919 .expires_in
920 .unwrap_or(DEVICE_POLL_MAX_SECS)
921 .max(poll_floor_secs),
922 );
923 let token = codewhale_config::device_code::DeviceCodePoll::new(
924 lifetime,
925 format!(
926 "{display_name} device-code authorization timed out. Re-run device login \
927 and approve the code before it expires."
928 ),
929 )
930 .interval_seconds(grant.interval)
931 .wait_before_first_poll(true)
932 .slow_down_timeout_message(format!(
933 "{display_name} device-code authorization timed out after one or more slow_down \
934 responses. That is usually clock drift in a WSL or VM environment; \
935 sync the clock, then re-run device login and approve the code before \
936 it expires."
937 ))
938 .run(std::thread::sleep, || {
939 poll_device_grant(
940 &token_endpoint,
941 &inputs.client_id,
942 grant.device_code.as_deref().unwrap_or(""),
943 )
944 })?;
945
946 Ok(PendingOAuthLogin {
947 provider,
948 issuer: inputs.issuer.clone(),
949 client_id: inputs.client_id.clone(),
950 token,
951 })
952 }
953
954 /// One login entry point for every provider: the params row decides whether
955 /// the grant is device-code or browser PKCE. A provider with neither fails
956 /// here with the reason.
957 pub async fn login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
958 if provider == OAuthProvider::Chatgpt {
959 let config = Config::load(None, None)?;
960 return login_with_config(provider, &config).await;
961 }
962 let params = oauth_provider_params(provider);
963 if params.device_code_path.is_some() {
964 device_code_login(provider).await
965 } else if params.authorize_path.is_some() {
966 pkce_login(provider).await
967 } else {
968 bail!("{} offers no sign-in flow", params.display_name);
969 }
970 }
971
972 /// Reauthorize the registration selected by the caller's actual config.
973 pub async fn login_with_config(
974 provider: OAuthProvider,
975 config: &Config,
976 ) -> Result<PendingOAuthLogin> {
977 if provider != OAuthProvider::Chatgpt {
978 let params = oauth_provider_params(provider);
979 return if params.device_code_path.is_some() {
980 device_code_login(provider).await
981 } else {
982 pkce_login(provider).await
983 };
984 }
985 let selected = official_chatgpt_registration(config).ok();
986 let inputs = oauth_provider_params(provider).resolve_inputs();
987 anyhow::ensure!(
988 inputs.issuer == CHATGPT_OAUTH_ISSUER,
989 "Official ChatGPT sign-in requires https://auth.openai.com; remove the issuer override"
990 );
991 let mut challenge = oauth_challenge_writer()?;
992 tokio::task::spawn_blocking(move || {
993 pkce_login_with_selected(provider, &inputs, selected, challenge.as_mut())
994 })
995 .await
996 .context("ChatGPT PKCE login worker failed")?
997 }
998
999 // ── form-post transport seam ──────────────────────────────────────────
1000 //
1001 // One seam for every OAuth form post (PKCE exchange, refresh, revoke): the
1002 // production client is reqwest with the shared bounds; tests substitute a
1003 // mock issuer. The seam records network/refresh in test builds so the
1004 // side-effect trap can still prove "zero external I/O" assertions.
1005
1006 pub(crate) trait OAuthFormClient {
1007 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)>;
1008 fn chatgpt_jwks(&self, issuer: &str) -> Result<JwkSet> {
1009 fetch_chatgpt_jwks(issuer)
1010 }
1011 fn revocation_endpoint(&self, params: &OAuthProviderParams, issuer: &str) -> Result<String> {
1012 remote_revoke_url(params, issuer).context("OAuth has no remote revoke endpoint")
1013 }
1014 }
1015
1016 pub(crate) struct ReqwestOAuthFormClient;
1017
1018 impl OAuthFormClient for ReqwestOAuthFormClient {
1019 fn revocation_endpoint(&self, params: &OAuthProviderParams, issuer: &str) -> Result<String> {
1020 if params.display_name != "ChatGPT" {
1021 return remote_revoke_url(params, issuer)
1022 .context("OAuth has no remote revoke endpoint");
1023 }
1024 anyhow::ensure!(
1025 issuer == CHATGPT_OAUTH_ISSUER,
1026 "ChatGPT revocation issuer is invalid"
1027 );
1028 let response = oauth_http_client("revocation discovery")?
1029 .get(format!("{issuer}/.well-known/openid-configuration"))
1030 .send()
1031 .context("ChatGPT revocation discovery failed")?;
1032 let (status, document): (_, Value) =
1033 parse_oauth_json(response, "ChatGPT revocation discovery")?;
1034 anyhow::ensure!(
1035 status.is_success() && document.get("issuer").and_then(Value::as_str) == Some(issuer),
1036 "ChatGPT revocation discovery issuer is invalid"
1037 );
1038 let endpoint = document
1039 .get("revocation_endpoint")
1040 .and_then(Value::as_str)
1041 .context("ChatGPT did not publish a revocation endpoint")?;
1042 let url = oauth_endpoint_url(endpoint)?;
1043 anyhow::ensure!(
1044 url.origin() == oauth_endpoint_url(issuer)?.origin()
1045 && url.query().is_none()
1046 && url.fragment().is_none(),
1047 "ChatGPT revocation endpoint is invalid"
1048 );
1049 Ok(url.to_string())
1050 }
1051 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
1052 let url = oauth_endpoint_url(url)?;
1053 #[cfg(test)]
1054 crate::external_credentials::record_oauth_network();
1055 let client = oauth_http_client("form")?;
1056 let response = client
1057 .post(url)
1058 .form(form)
1059 .send()
1060 .context("OAuth form request failed")?;
1061 let status = response.status().as_u16();
1062 let mut reader = response.take(OAUTH_RESPONSE_BODY_LIMIT + 1);
1063 let mut body = Vec::new();
1064 reader
1065 .read_to_end(&mut body)
1066 .context("reading OAuth form response")?;
1067 if body.len() as u64 > OAUTH_RESPONSE_BODY_LIMIT {
1068 body.truncate(OAUTH_RESPONSE_BODY_LIMIT as usize);
1069 }
1070 Ok((status, String::from_utf8(body).unwrap_or_default()))
1071 }
1072 }
1073
1074 /// Token/authorize endpoint URLs for one provider row.
1075 pub(crate) fn form_token_url(params: &OAuthProviderParams, issuer: &str) -> String {
1076 format!("{}/{}", issuer.trim_end_matches('/'), params.token_path)
1077 }
1078
1079 /// Pinned remote revoke URL; `None` when the provider revokes locally only.
1080 pub(crate) fn remote_revoke_url(params: &OAuthProviderParams, issuer: &str) -> Option<String> {
1081 params
1082 .revoke_path
1083 .map(|path| format!("{}/{}", issuer.trim_end_matches('/'), path))
1084 }
1085
1086 /// Parse a form-post token response. Error bodies are never echoed: the
1087 /// detail names the error code only, so a hostile issuer cannot smuggle
1088 /// secret-bearing text back through diagnostics.
1089 pub(crate) fn parse_oauth_form_response(
1090 status: u16,
1091 body: &str,
1092 operation: &str,
1093 params: &OAuthProviderParams,
1094 ) -> Result<OAuthTokenMaterial> {
1095 let name = params.display_name;
1096 let parsed: OAuthTokenMaterial = serde_json::from_str(body).map_err(|_| {
1097 anyhow::anyhow!("{name} OAuth {operation} returned HTTP {status} that was not token JSON")
1098 })?;
1099 if !(200..300).contains(&status) || parsed.error.is_some() {
1100 let err = parsed.error.as_deref().unwrap_or("token_error");
1101 if matches!(
1102 err,
1103 "invalid_grant"
1104 | "refresh_token_reused"
1105 | "refresh_token_expired"
1106 | "refresh_token_invalidated"
1107 ) || status == 401
1108 {
1109 bail!(
1110 "{name} OAuth {operation} failed permanently ({err}). Sign in again with `{}`.",
1111 params.relogin_hint
1112 );
1113 }
1114 bail!("{name} OAuth {operation} failed ({err})");
1115 }
1116 anyhow::ensure!(
1117 parsed
1118 .access_token
1119 .as_deref()
1120 .is_some_and(|token| !token.trim().is_empty()),
1121 "{name} OAuth {operation} returned an empty access token"
1122 );
1123 Ok(parsed)
1124 }
1125
1126 fn compact_form_error(body: &str) -> String {
1127 body.chars().filter(|c| !c.is_control()).take(80).collect()
1128 }
1129
1130 /// Refresh an owned token through the seam at an explicit token URL —
1131 /// discovered when the provider row demands it, pinned otherwise. Refresh is
1132 /// a Codewhale-owned credential operation only: external imports never
1133 /// refresh.
1134 pub(crate) fn refresh_access_token_via(
1135 client: &dyn OAuthFormClient,
1136 params: &OAuthProviderParams,
1137 token_url: &str,
1138 client_id: &str,
1139 refresh_token: &str,
1140 ) -> Result<OAuthTokenMaterial> {
1141 #[cfg(test)]
1142 crate::external_credentials::record_oauth_refresh();
1143 let mut fields = vec![
1144 ("grant_type", "refresh_token"),
1145 ("client_id", client_id),
1146 ("refresh_token", refresh_token),
1147 ];
1148 if params.display_name == "ChatGPT" {
1149 fields.push(("resource", CHATGPT_OAUTH_RESOURCE));
1150 }
1151 let (status, body) = client.post_form(token_url, &fields)?;
1152 parse_oauth_form_response(status, &body, "refresh", params)
1153 }
1154
1155 /// Best-effort remote revoke through the seam. Callers clear local
1156 /// credentials regardless of this outcome.
1157 pub(crate) fn revoke_remote_token_via(
1158 client: &dyn OAuthFormClient,
1159 params: &OAuthProviderParams,
1160 issuer: &str,
1161 client_id: &str,
1162 token: &str,
1163 ) -> Result<()> {
1164 let revoke_url = client.revocation_endpoint(params, issuer)?;
1165 let mut fields = vec![("token", token), ("client_id", client_id)];
1166 if params.display_name == "ChatGPT" {
1167 fields.push(("token_type_hint", "refresh_token"));
1168 }
1169 let (status, _) = client.post_form(&revoke_url, &fields)?;
1170 anyhow::ensure!(
1171 (200..300).contains(&status),
1172 "{} OAuth revoke failed with HTTP {status}",
1173 params.display_name
1174 );
1175 Ok(())
1176 }
1177
1178 // ── PKCE browser login ────────────────────────────────────────────────
1179
1180 /// RFC 7636 S256 PKCE pair. Custom Debug: the verifier is exchanged for
1181 /// bearer material and never prints.
1182 #[derive(Clone)]
1183 pub struct PkceChallenge {
1184 pub verifier: String,
1185 pub challenge: String,
1186 }
1187
1188 impl std::fmt::Debug for PkceChallenge {
1189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1190 f.debug_struct("PkceChallenge")
1191 .field("verifier", &"<redacted>")
1192 .field("challenge", &self.challenge)
1193 .finish()
1194 }
1195 }
1196
1197 /// A browser authorization request in flight: state, PKCE pair, and the
1198 /// registered redirect the callback server answers on.
1199 #[derive(Clone)]
1200 pub struct BrowserAuthRequest {
1201 pub state: String,
1202 pub nonce: String,
1203 pub pkce: PkceChallenge,
1204 pub redirect_uri: String,
1205 pub authorize_url: String,
1206 }
1207
1208 impl std::fmt::Debug for BrowserAuthRequest {
1209 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1210 f.debug_struct("BrowserAuthRequest")
1211 .field("state", &self.state)
1212 .field("pkce", &self.pkce)
1213 .field("redirect_uri", &self.redirect_uri)
1214 .field("authorize_url", &"<redacted>")
1215 .finish()
1216 }
1217 }
1218
1219 /// Parsed callback query: a code+state pair, or the issuer's refusal.
1220 #[derive(Clone, PartialEq, Eq)]
1221 pub enum CallbackOutcome {
1222 Success {
1223 code: String,
1224 state: String,
1225 client_id: Option<String>,
1226 },
1227 Error {
1228 error: String,
1229 description: Option<String>,
1230 state: Option<String>,
1231 },
1232 }
1233
1234 impl std::fmt::Debug for CallbackOutcome {
1235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1236 match self {
1237 Self::Success {
1238 state, client_id, ..
1239 } => f
1240 .debug_struct("Success")
1241 .field("code", &"<redacted>")
1242 .field("state", state)
1243 .field("client_id", client_id)
1244 .finish(),
1245 Self::Error { state, .. } => f
1246 .debug_struct("Error")
1247 .field("error", &"<redacted>")
1248 .field("state", state)
1249 .finish(),
1250 }
1251 }
1252 }
1253
1254 /// RFC 7636 S256 PKCE pair.
1255 #[must_use]
1256 pub fn generate_pkce() -> PkceChallenge {
1257 let verifier = random_url_token(32);
1258 let digest = Sha256::digest(verifier.as_bytes());
1259 PkceChallenge {
1260 verifier,
1261 challenge: URL_SAFE_NO_PAD.encode(digest),
1262 }
1263 }
1264
1265 #[must_use]
1266 pub fn generate_state() -> String {
1267 random_url_token(16)
1268 }
1269
1270 fn random_url_token(nbytes: usize) -> String {
1271 let mut bytes = vec![0u8; nbytes.max(16)];
1272 let mut offset = 0;
1273 while offset < bytes.len() {
1274 let chunk = uuid::Uuid::new_v4();
1275 let take = (bytes.len() - offset).min(16);
1276 bytes[offset..offset + take].copy_from_slice(&chunk.as_bytes()[..take]);
1277 offset += take;
1278 }
1279 URL_SAFE_NO_PAD.encode(bytes)
1280 }
1281
1282 pub fn build_authorize_url(
1283 params: &OAuthProviderParams,
1284 issuer: &str,
1285 client_id: &str,
1286 scopes: &str,
1287 redirect_uri: &str,
1288 state: &str,
1289 pkce: &PkceChallenge,
1290 ) -> Result<String> {
1291 let Some(authorize_path) = params.authorize_path else {
1292 bail!("{} offers no browser sign-in flow", params.display_name);
1293 };
1294 // A malformed configured issuer must fail loudly. Silently redirecting
1295 // the browser to the production authorize endpoint would hand the
1296 // issuer a sign-in the user aimed somewhere else.
1297 let issuer_var = params
1298 .env
1299 .issuer_vars
1300 .first()
1301 .copied()
1302 .unwrap_or("the issuer environment variable");
1303 let mut url = oauth_endpoint_url(&format!(
1304 "{}/{}",
1305 issuer.trim_end_matches('/'),
1306 authorize_path
1307 ))
1308 .with_context(|| {
1309 format!(
1310 "{} OAuth issuer is not a valid URL or uses an insecure endpoint — check {issuer_var}",
1311 params.display_name
1312 )
1313 })?;
1314 url.query_pairs_mut()
1315 .append_pair("response_type", "code")
1316 .append_pair("client_id", client_id)
1317 .append_pair("redirect_uri", redirect_uri)
1318 .append_pair("scope", scopes)
1319 .append_pair("code_challenge", &pkce.challenge)
1320 .append_pair("code_challenge_method", "S256")
1321 .append_pair("state", state);
1322 if let Some(originator) = params.originator {
1323 url.query_pairs_mut().append_pair("originator", originator);
1324 }
1325 for (key, value) in params.authorize_extras {
1326 url.query_pairs_mut().append_pair(key, value);
1327 }
1328 let account_prompt_disabled = params
1329 .env
1330 .no_account_prompt_var
1331 .is_some_and(|var| std::env::var_os(var).is_some());
1332 if !account_prompt_disabled {
1333 for (key, value) in params.account_choice_extras {
1334 url.query_pairs_mut().append_pair(key, value);
1335 }
1336 }
1337 Ok(url.to_string())
1338 }
1339
1340 pub fn parse_callback_query(params: &OAuthProviderParams, query: &str) -> Result<CallbackOutcome> {
1341 let parsed = reqwest::Url::parse(&format!("http://127.0.0.1{}?{query}", params.callback_path))
1342 .context("OAuth callback query is not valid")?;
1343 let mut code = None;
1344 let mut state = None;
1345 let mut error = None;
1346 let mut description = None;
1347 let mut client_id = None;
1348 let mut seen = std::collections::HashSet::new();
1349 for (key, value) in parsed.query_pairs() {
1350 if matches!(
1351 key.as_ref(),
1352 "code" | "state" | "error" | "error_description" | "client_id"
1353 ) {
1354 anyhow::ensure!(
1355 seen.insert(key.to_string()),
1356 "OAuth callback contains a duplicate parameter"
1357 );
1358 }
1359 match key.as_ref() {
1360 "code" => code = Some(value.into_owned()),
1361 "state" => state = Some(value.into_owned()),
1362 "error" => error = Some(value.into_owned()),
1363 "error_description" => description = Some(value.into_owned()),
1364 "client_id" => client_id = Some(value.into_owned()),
1365 _ => {}
1366 }
1367 }
1368 anyhow::ensure!(
1369 code.is_none() || error.is_none(),
1370 "OAuth callback contains both success and error"
1371 );
1372 if let Some(error) = error {
1373 return Ok(CallbackOutcome::Error {
1374 error,
1375 description,
1376 state,
1377 });
1378 }
1379 let code = code
1380 .filter(|c| !c.trim().is_empty())
1381 .context("OAuth callback missing authorization code")?;
1382 let state = state
1383 .filter(|s| !s.trim().is_empty())
1384 .context("OAuth callback missing state")?;
1385 Ok(CallbackOutcome::Success {
1386 code,
1387 state,
1388 client_id,
1389 })
1390 }
1391
1392 #[cfg(test)]
1393 pub fn accept_callback(expected_state: &str, outcome: CallbackOutcome) -> Result<String> {
1394 accept_callback_with_client(expected_state, outcome).map(|(code, _)| code)
1395 }
1396
1397 fn accept_callback_with_client(
1398 expected_state: &str,
1399 outcome: CallbackOutcome,
1400 ) -> Result<(String, Option<String>)> {
1401 match outcome {
1402 CallbackOutcome::Success {
1403 code,
1404 state,
1405 client_id,
1406 } => {
1407 anyhow::ensure!(
1408 state == expected_state,
1409 "OAuth callback state did not match the pending login"
1410 );
1411 Ok((code, client_id))
1412 }
1413 CallbackOutcome::Error {
1414 error,
1415 description,
1416 state,
1417 } => {
1418 anyhow::ensure!(
1419 state.as_deref() == Some(expected_state),
1420 "OAuth error callback state did not match the pending login"
1421 );
1422 let _ = description;
1423 let error = compact_form_error(&error);
1424 bail!("sign-in was not completed ({error})")
1425 }
1426 }
1427 }
1428
1429 fn parse_http_request_target(request_line: &str) -> Result<String> {
1430 let mut parts = request_line.split_whitespace();
1431 let method = parts.next().unwrap_or_default();
1432 anyhow::ensure!(
1433 method.eq_ignore_ascii_case("GET"),
1434 "OAuth callback must be GET"
1435 );
1436 let target = parts
1437 .next()
1438 .context("OAuth callback missing request target")?;
1439 Ok(target.to_string())
1440 }
1441
1442 fn query_from_target<'a>(params: &OAuthProviderParams, target: &'a str) -> Result<&'a str> {
1443 let path = target.split('?').next().unwrap_or(target);
1444 anyhow::ensure!(
1445 path == params.callback_path,
1446 "OAuth callback path was not {}",
1447 params.callback_path
1448 );
1449 Ok(target.split_once('?').map(|(_, q)| q).unwrap_or(""))
1450 }
1451
1452 /// Bind ChatGPT's exact 127.0.0.1 callback, with an ephemeral-port fallback.
1453 /// Other providers using localhost bind both IP stacks when available.
1454 pub fn bind_loopback_callback(params: &OAuthProviderParams) -> Result<Vec<TcpListener>> {
1455 let name = params.display_name;
1456 let mut last_error = None;
1457 for port in params.loopback_ports {
1458 let mut bound = Vec::new();
1459 for addr in [
1460 SocketAddr::from((Ipv4Addr::LOCALHOST, *port)),
1461 SocketAddr::from((Ipv6Addr::LOCALHOST, *port)),
1462 ] {
1463 if params.display_name == "ChatGPT" && addr.is_ipv6() {
1464 continue;
1465 }
1466 match TcpListener::bind(addr) {
1467 Ok(listener) => {
1468 listener.set_nonblocking(true).with_context(|| {
1469 format!("{name} OAuth callback listener could not be set non-blocking")
1470 })?;
1471 bound.push(listener);
1472 }
1473 Err(error) => last_error = Some(error),
1474 }
1475 }
1476 if !bound.is_empty() {
1477 return Ok(bound);
1478 }
1479 }
1480 let ports = params
1481 .loopback_ports
1482 .iter()
1483 .map(u16::to_string)
1484 .collect::<Vec<_>>()
1485 .join(" or ");
1486 let hint = if params.callback_conflict_hint.is_empty() {
1487 String::new()
1488 } else {
1489 format!(" {}", params.callback_conflict_hint)
1490 };
1491 Err(last_error
1492 .map(anyhow::Error::from)
1493 .unwrap_or_else(|| anyhow::anyhow!("unable to bind {name} OAuth callback ports")))
1494 .with_context(|| format!("{name} sign-in needs loopback port {ports}.{hint}"))
1495 }
1496
1497 pub(crate) fn start_auth_request_on(
1498 listeners: &[TcpListener],
1499 params: &OAuthProviderParams,
1500 inputs: &ResolvedOAuthInputs,
1501 ) -> Result<BrowserAuthRequest> {
1502 let port = listeners
1503 .first()
1504 .with_context(|| {
1505 format!(
1506 "{} OAuth callback has no bound listener",
1507 params.display_name
1508 )
1509 })?
1510 .local_addr()
1511 .with_context(|| {
1512 format!(
1513 "{} OAuth callback listener has no local address",
1514 params.display_name
1515 )
1516 })?
1517 .port();
1518 let host = if params.display_name == "ChatGPT" {
1519 "127.0.0.1"
1520 } else {
1521 "localhost"
1522 };
1523 let redirect_uri = format!("http://{host}:{port}{}", params.callback_path);
1524 let pkce = generate_pkce();
1525 let state = generate_state();
1526 let nonce = generate_state();
1527 let mut authorize_url = reqwest::Url::parse(&build_authorize_url(
1528 params,
1529 &inputs.issuer,
1530 &inputs.client_id,
1531 &inputs.scopes,
1532 &redirect_uri,
1533 &state,
1534 &pkce,
1535 )?)?;
1536 if params.display_name == "ChatGPT" {
1537 authorize_url.query_pairs_mut().append_pair("nonce", &nonce);
1538 if inputs.client_id == CHATGPT_OAUTH_CLIENT_ID {
1539 authorize_url
1540 .query_pairs_mut()
1541 .append_pair("agent_name_hint", "Codewhale");
1542 }
1543 }
1544 Ok(BrowserAuthRequest {
1545 state,
1546 nonce,
1547 pkce,
1548 redirect_uri,
1549 authorize_url: authorize_url.to_string(),
1550 })
1551 }
1552
1553 const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
1554 const CALLBACK_HTML_OK: &str = "<!doctype html><html><body><p>Signed in to Codewhale. You can close this tab.</p></body></html>";
1555 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>";
1556
1557 fn wait_for_callback(
1558 listeners: &[TcpListener],
1559 params: &OAuthProviderParams,
1560 expected_state: &str,
1561 ) -> Result<(String, Option<String>)> {
1562 let deadline = Instant::now() + CALLBACK_TIMEOUT;
1563 loop {
1564 if Instant::now() >= deadline {
1565 bail!(
1566 "{} sign-in timed out waiting for the browser callback",
1567 params.display_name
1568 );
1569 }
1570 // Whichever stack `localhost` resolved to for the browser is the one
1571 // that gets the connection; poll them all.
1572 for listener in listeners {
1573 match listener.accept() {
1574 Ok((stream, _)) => {
1575 return handle_callback_stream(stream, params, expected_state);
1576 }
1577 Err(error)
1578 if error.kind() == std::io::ErrorKind::WouldBlock
1579 || error.kind() == std::io::ErrorKind::Interrupted => {}
1580 Err(error) => {
1581 return Err(error).context(format!(
1582 "{} OAuth callback accept failed",
1583 params.display_name
1584 ));
1585 }
1586 }
1587 }
1588 std::thread::sleep(Duration::from_millis(50));
1589 }
1590 }
1591
1592 fn handle_callback_stream(
1593 mut stream: TcpStream,
1594 params: &OAuthProviderParams,
1595 expected_state: &str,
1596 ) -> Result<(String, Option<String>)> {
1597 // BSD sockets (macOS) hand the accepted stream the listener's O_NONBLOCK;
1598 // the bounded read below needs a blocking socket with a timeout.
1599 stream.set_nonblocking(false).with_context(|| {
1600 format!(
1601 "{} OAuth callback stream could not be set blocking",
1602 params.display_name
1603 )
1604 })?;
1605 stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
1606 // One read is not one request: TCP may deliver the callback in
1607 // fragments, and a truncated query parses as a missing parameter.
1608 // Read until the blank line that ends the HTTP headers.
1609 let mut buf = [0u8; 4096];
1610 let mut len = 0usize;
1611 loop {
1612 if len == buf.len() {
1613 break;
1614 }
1615 let n = stream
1616 .read(&mut buf[len..])
1617 .with_context(|| format!("reading {} OAuth callback request", params.display_name))?;
1618 if n == 0 {
1619 break;
1620 }
1621 len += n;
1622 if buf[..len].windows(4).any(|window| window == b"\r\n\r\n") {
1623 break;
1624 }
1625 }
1626 let request = String::from_utf8_lossy(&buf[..len]);
1627 let request_line = request.lines().next().unwrap_or_default();
1628 let result = (|| {
1629 let target = parse_http_request_target(request_line)?;
1630 let query = query_from_target(params, &target)?;
1631 let outcome = parse_callback_query(params, query)?;
1632 accept_callback_with_client(expected_state, outcome)
1633 })();
1634 let (status, body) = match &result {
1635 Ok(_) => ("200 OK", CALLBACK_HTML_OK),
1636 Err(_) => ("400 Bad Request", CALLBACK_HTML_ERR),
1637 };
1638 let _ = write!(
1639 stream,
1640 "HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
1641 body.len()
1642 );
1643 result
1644 }
1645
1646 pub(crate) fn exchange_authorization_code(
1647 client: &dyn OAuthFormClient,
1648 params: &OAuthProviderParams,
1649 token_endpoint: &str,
1650 client_id: &str,
1651 redirect_uri: &str,
1652 code: &str,
1653 verifier: &str,
1654 ) -> Result<OAuthTokenMaterial> {
1655 let mut fields = vec![
1656 ("grant_type", "authorization_code"),
1657 ("client_id", client_id),
1658 ("redirect_uri", redirect_uri),
1659 ("code", code),
1660 ("code_verifier", verifier),
1661 ];
1662 if params.display_name == "ChatGPT" {
1663 fields.push(("resource", CHATGPT_OAUTH_RESOURCE));
1664 }
1665 let (status, body) = client.post_form(token_endpoint, &fields)?;
1666 parse_oauth_form_response(status, &body, "authorization code exchange", params)
1667 }
1668
1669 /// Interactive PKCE browser login for any provider whose row offers it.
1670 /// Shows the authorize URL only on a terminal, opens a browser, and waits for the loopback
1671 /// callback. A provider with no browser flow (xAI) fails here with the
1672 /// reason, before any listener binds.
1673 pub async fn pkce_login(provider: OAuthProvider) -> Result<PendingOAuthLogin> {
1674 let params = oauth_provider_params(provider);
1675 if params.authorize_path.is_none() {
1676 bail!(
1677 "{} offers no browser sign-in flow; sign in through the device-code login instead",
1678 params.display_name
1679 );
1680 }
1681 let inputs = params.resolve_inputs();
1682 if provider == OAuthProvider::Chatgpt {
1683 anyhow::ensure!(
1684 inputs.issuer == CHATGPT_OAUTH_ISSUER,
1685 "Official ChatGPT sign-in requires https://auth.openai.com; remove the issuer override"
1686 );
1687 }
1688 let display_name = params.display_name;
1689 let mut challenge = oauth_challenge_writer()?;
1690 tokio::task::spawn_blocking(move || {
1691 pkce_login_with_selected(provider, &inputs, None, challenge.as_mut())
1692 })
1693 .await
1694 .with_context(|| format!("{display_name} PKCE login worker failed"))?
1695 }
1696
1697 /// Blocking browser protocol worker with a previously admitted terminal.
1698 fn pkce_login_with_selected(
1699 provider: OAuthProvider,
1700 inputs: &ResolvedOAuthInputs,
1701 selected: Option<ChatgptRegistration>,
1702 challenge: &mut dyn std::io::Write,
1703 ) -> Result<PendingOAuthLogin> {
1704 let params = oauth_provider_params(provider);
1705 let display_name = params.display_name;
1706 let mut host = if provider == OAuthProvider::Chatgpt {
1707 Some(prepare_chatgpt_host()?)
1708 } else {
1709 None
1710 };
1711 if let Some(host) = &mut host {
1712 if !std::env::var("CODEWHALE_CHATGPT_NEW_ACCOUNT").is_ok_and(|value| value == "1")
1713 && let Some(mut selected) = selected
1714 {
1715 selected.host_id = host.host_id.clone();
1716 host.registration = Some(selected);
1717 }
1718 if let Some(registration) = &host.registration {
1719 anyhow::ensure!(
1720 registration.issuer == inputs.issuer,
1721 "Saved ChatGPT registration belongs to a different issuer; credentials were not replaced"
1722 );
1723 }
1724 }
1725 let mut inputs = inputs.clone();
1726 if let Some(host) = &host {
1727 inputs.client_id = host.registration.as_ref().map_or_else(
1728 || CHATGPT_OAUTH_CLIENT_ID.to_string(),
1729 |registration| registration.client_id.clone(),
1730 );
1731 }
1732 let listeners = bind_loopback_callback(params)?;
1733 let mut request = start_auth_request_on(&listeners, params, &inputs)?;
1734 if let Some(host) = &host {
1735 let mut url = reqwest::Url::parse(&request.authorize_url)?;
1736 url.query_pairs_mut()
1737 .append_pair("ext_agent_host_id", &host.host_id);
1738 if let Some(email) = host
1739 .registration
1740 .as_ref()
1741 .and_then(|registration| registration.email.as_deref())
1742 {
1743 url.query_pairs_mut().append_pair("login_hint", email);
1744 }
1745 request.authorize_url = url.to_string();
1746 }
1747 writeln!(challenge, "{display_name} sign-in (PKCE)")?;
1748 writeln!(challenge, " Open: {}", request.authorize_url)?;
1749 writeln!(challenge, "{}", account_choice_hint(display_name))?;
1750 writeln!(
1751 challenge,
1752 "Waiting for the browser callback… (Ctrl+C to abort)"
1753 )?;
1754 challenge.flush()?;
1755 if inputs.open_browser && webbrowser::open(&request.authorize_url).is_err() {
1756 writeln!(
1757 challenge,
1758 "Could not open the browser automatically; open the displayed URL manually"
1759 )?;
1760 }
1761 let (code, callback_id) = wait_for_callback(&listeners, params, &request.state)?;
1762 let client_id = if provider == OAuthProvider::Chatgpt {
1763 issued_callback_client_id(&inputs.client_id, callback_id.as_deref())?
1764 } else {
1765 inputs.client_id.clone()
1766 };
1767 let mut token = exchange_authorization_code(
1768 &ReqwestOAuthFormClient,
1769 params,
1770 &form_token_url(params, &inputs.issuer),
1771 &client_id,
1772 &request.redirect_uri,
1773 &code,
1774 &request.pkce.verifier,
1775 )?;
1776 if let Some(host) = &host {
1777 require_chatgpt_scopes(token.scope.as_deref())?;
1778 anyhow::ensure!(
1779 token
1780 .token_type
1781 .as_deref()
1782 .is_some_and(|value| value.eq_ignore_ascii_case("bearer")),
1783 "ChatGPT token response did not return Bearer credentials"
1784 );
1785 let keys = fetch_chatgpt_jwks(&inputs.issuer)?;
1786 let registration = verify_chatgpt_id_token(
1787 token
1788 .id_token
1789 .as_deref()
1790 .context("ChatGPT sign-in omitted its ID token")?,
1791 &keys,
1792 &inputs.issuer,
1793 &client_id,
1794 Some(&request.nonce),
1795 host.registration
1796 .as_ref()
1797 .map(|registration| registration.subject.as_str()),
1798 &host.host_id,
1799 )?;
1800 verify_chatgpt_access_token(
1801 token
1802 .access_token
1803 .as_deref()
1804 .context("ChatGPT sign-in omitted its access token")?,
1805 &keys,
1806 &registration,
1807 )?;
1808 token.verified_chatgpt = Some(registration);
1809 }
1810 Ok(PendingOAuthLogin {
1811 provider,
1812 issuer: inputs.issuer.clone(),
1813 client_id,
1814 token,
1815 })
1816 }
1817
1818 // ── owned credential storage (one store, two providers) ───────────────
1819 //
1820 // Codewhale-owned OAuth generations live in the config crate's credential
1821 // store under per-provider generation prefixes. xAI historically stored the
1822 // access token as `key` (Grok CLI shape); ChatGPT as `access_token`. One
1823 // entry type reads both; new generations write the unified shape.
1824
1825 /// xAI issuer and public client (constants the params rows and the route
1826 /// surfaces share).
1827 pub const XAI_OIDC_ISSUER: &str = "https://auth.x.ai";
1828 pub const GROK_OIDC_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
1829 pub const DEFAULT_SCOPES: &str = "openid profile email offline_access api:access grok-cli:access";
1830 /// Hard ceiling past a device grant's own `expires_in`.
1831 pub(crate) const DEVICE_POLL_MAX_SECS: u64 = 900;
1832
1833 /// ChatGPT issuer and public client.
1834 pub const CHATGPT_OAUTH_ISSUER: &str = "https://auth.openai.com";
1835 pub const CHATGPT_OAUTH_CLIENT_ID: &str = "dynamic_agent_client";
1836 /// Honest originator; never impersonate `codex_cli_rs`.
1837 pub const CHATGPT_OAUTH_ORIGINATOR: &str = "codewhale";
1838 pub const CHATGPT_OAUTH_RESOURCE: &str = "https://api.openai.com/v1";
1839 pub const CHATGPT_OAUTH_SCOPE: &str =
1840 "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
1841
1842 /// Verified registration identity. The client ID also binds the selected workspace.
1843 /// One active registration is supported; use CODEWHALE_CHATGPT_NEW_ACCOUNT=1
1844 /// to explicitly replace it after a new, fully validated browser sign-in.
1845 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1846 pub(crate) struct ChatgptRegistration {
1847 pub issuer: String,
1848 pub client_id: String,
1849 pub subject: String,
1850 pub email: Option<String>,
1851 pub host_id: String,
1852 }
1853
1854 #[derive(Serialize, Deserialize)]
1855 struct ChatgptHost {
1856 host_id: String,
1857 #[serde(default)]
1858 registration: Option<ChatgptRegistration>,
1859 }
1860
1861 fn valid_issued_chatgpt_client_id(value: &str) -> bool {
1862 value.starts_with("oaiapp_")
1863 && value.len() > 7
1864 && value.len() <= 256
1865 && value
1866 .bytes()
1867 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
1868 }
1869
1870 fn validate_chatgpt_host(host: &ChatgptHost) -> Result<()> {
1871 let id = host
1872 .host_id
1873 .strip_prefix("urn:uuid:")
1874 .and_then(|value| uuid::Uuid::parse_str(value).ok())
1875 .context("ChatGPT host identifier is invalid")?;
1876 anyhow::ensure!(
1877 id.get_version_num() == 4,
1878 "ChatGPT host identifier must be a UUIDv4"
1879 );
1880 if let Some(registration) = &host.registration {
1881 anyhow::ensure!(
1882 registration.host_id == host.host_id
1883 && valid_issued_chatgpt_client_id(&registration.client_id)
1884 && !registration.subject.trim().is_empty(),
1885 "Saved ChatGPT registration is invalid"
1886 );
1887 }
1888 Ok(())
1889 }
1890
1891 fn prepare_chatgpt_host() -> Result<ChatgptHost> {
1892 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
1893 let name = codewhale_config::CHATGPT_HOST_FILE_NAME;
1894 let mut host: ChatgptHost = match store.read_to_string(name)? {
1895 Some(raw) => {
1896 serde_json::from_str(&raw).context("Saved ChatGPT host metadata is invalid")?
1897 }
1898 None => ChatgptHost {
1899 host_id: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
1900 registration: None,
1901 },
1902 };
1903 validate_chatgpt_host(&host)?;
1904 // Persist before opening a browser, including after a cancelled first login.
1905 store.write(name, &serde_json::to_vec(&host)?, true)?;
1906 if std::env::var("CODEWHALE_CHATGPT_NEW_ACCOUNT").is_ok_and(|value| value == "1") {
1907 host.registration = None;
1908 }
1909 Ok(host)
1910 })
1911 }
1912
1913 fn require_chatgpt_scopes(scope: Option<&str>) -> Result<()> {
1914 let scope = scope.context("ChatGPT token response omitted granted scopes; sign in again")?;
1915 for required in ["chatgpt.tokens.use.direct", "resource.invoke"] {
1916 anyhow::ensure!(
1917 scope
1918 .split_ascii_whitespace()
1919 .any(|value| value == required),
1920 "ChatGPT plan permission was not granted; authorize plan usage in ChatGPT and sign in again"
1921 );
1922 }
1923 Ok(())
1924 }
1925
1926 #[derive(Clone, Deserialize)]
1927 struct ChatgptIdClaims {
1928 sub: String,
1929 #[serde(default)]
1930 nonce: Option<String>,
1931 #[serde(default)]
1932 email: Option<String>,
1933 }
1934
1935 fn fetch_chatgpt_jwks(issuer: &str) -> Result<JwkSet> {
1936 let url = oauth_endpoint_url(&format!(
1937 "{}/.well-known/jwks.json",
1938 issuer.trim_end_matches('/')
1939 ))?;
1940 let response = oauth_http_client("identity verification")?
1941 .get(url)
1942 .send()
1943 .context("ChatGPT identity verification keys could not be retrieved")?;
1944 let (status, keys) = parse_oauth_json(response, "ChatGPT verification keys")?;
1945 anyhow::ensure!(
1946 status.is_success(),
1947 "ChatGPT identity verification keys are unavailable"
1948 );
1949 Ok(keys)
1950 }
1951
1952 fn verify_chatgpt_jwt<T: serde::de::DeserializeOwned + Clone>(
1953 token: &str,
1954 keys: &JwkSet,
1955 issuer: &str,
1956 audience: &str,
1957 ) -> Result<T> {
1958 let header = decode_header(token).context("ChatGPT identity token header is invalid")?;
1959 anyhow::ensure!(
1960 matches!(
1961 header.alg,
1962 Algorithm::RS256
1963 | Algorithm::RS384
1964 | Algorithm::RS512
1965 | Algorithm::ES256
1966 | Algorithm::ES384
1967 | Algorithm::EdDSA
1968 ),
1969 "ChatGPT identity token uses an unsupported signature algorithm"
1970 );
1971 let kid = header
1972 .kid
1973 .as_deref()
1974 .context("ChatGPT identity token omitted its verification key")?;
1975 let jwk = keys
1976 .find(kid)
1977 .context("ChatGPT identity token verification key is unknown")?;
1978 let key = DecodingKey::from_jwk(jwk).context("ChatGPT identity verification key is invalid")?;
1979 let mut validation = Validation::new(header.alg);
1980 validation.set_issuer(&[issuer]);
1981 validation.set_audience(&[audience]);
1982 validation.set_required_spec_claims(&["exp", "iss", "aud", "sub"]);
1983 validation.validate_nbf = true;
1984 validation.leeway = 0;
1985 let claims = decode::<Value>(token, &key, &validation)
1986 .map_err(|_| {
1987 anyhow::anyhow!("ChatGPT token signature or identity claims failed verification")
1988 })?
1989 .claims;
1990 let exact_audience = claims.get("aud").is_some_and(|aud| {
1991 aud.as_str() == Some(audience)
1992 || aud
1993 .as_array()
1994 .is_some_and(|values| values.len() == 1 && values[0].as_str() == Some(audience))
1995 });
1996 anyhow::ensure!(
1997 exact_audience,
1998 "ChatGPT token audience does not match this registration"
1999 );
2000 serde_json::from_value(claims).context("ChatGPT token identity claims are invalid")
2001 }
2002
2003 fn verify_chatgpt_id_token(
2004 token: &str,
2005 keys: &JwkSet,
2006 issuer: &str,
2007 client_id: &str,
2008 nonce: Option<&str>,
2009 expected_subject: Option<&str>,
2010 host_id: &str,
2011 ) -> Result<ChatgptRegistration> {
2012 let claims: ChatgptIdClaims = verify_chatgpt_jwt(token, keys, issuer, client_id)?;
2013 anyhow::ensure!(
2014 !claims.sub.trim().is_empty(),
2015 "ChatGPT identity token has no subject"
2016 );
2017 if let Some(nonce) = nonce {
2018 anyhow::ensure!(
2019 claims.nonce.as_deref() == Some(nonce),
2020 "ChatGPT identity token nonce did not match the pending login"
2021 );
2022 }
2023 if let Some(subject) = expected_subject {
2024 anyhow::ensure!(
2025 claims.sub == subject,
2026 "ChatGPT sign-in returned a different account; the selected registration was not replaced"
2027 );
2028 }
2029 Ok(ChatgptRegistration {
2030 issuer: issuer.to_string(),
2031 client_id: client_id.to_string(),
2032 subject: claims.sub,
2033 email: claims.email,
2034 host_id: host_id.to_string(),
2035 })
2036 }
2037
2038 #[derive(Clone, Deserialize)]
2039 struct ChatgptAccessClaims {
2040 sub: String,
2041 client_id: String,
2042 scope: String,
2043 }
2044
2045 fn verify_chatgpt_access_token(
2046 token: &str,
2047 keys: &JwkSet,
2048 registration: &ChatgptRegistration,
2049 ) -> Result<()> {
2050 let claims: ChatgptAccessClaims =
2051 verify_chatgpt_jwt(token, keys, &registration.issuer, CHATGPT_OAUTH_RESOURCE)?;
2052 anyhow::ensure!(
2053 claims.sub == registration.subject && claims.client_id == registration.client_id,
2054 "ChatGPT access token does not match the selected account registration"
2055 );
2056 require_chatgpt_scopes(Some(&claims.scope))
2057 }
2058
2059 fn issued_callback_client_id(pending_id: &str, callback_id: Option<&str>) -> Result<String> {
2060 if pending_id == CHATGPT_OAUTH_CLIENT_ID {
2061 let issued =
2062 callback_id.context("ChatGPT registration did not return an issued client ID")?;
2063 anyhow::ensure!(
2064 valid_issued_chatgpt_client_id(issued),
2065 "ChatGPT registration returned an invalid client ID"
2066 );
2067 Ok(issued.to_string())
2068 } else {
2069 anyhow::ensure!(
2070 valid_issued_chatgpt_client_id(pending_id),
2071 "Saved ChatGPT client ID is invalid; register again"
2072 );
2073 anyhow::ensure!(
2074 callback_id.is_none_or(|value| value == pending_id),
2075 "ChatGPT callback changed the selected registration; credentials were not replaced"
2076 );
2077 Ok(pending_id.to_string())
2078 }
2079 }
2080
2081 fn registration_from_entry(entry: &OwnedAuthEntry) -> Result<ChatgptRegistration> {
2082 let registration: ChatgptRegistration =
2083 serde_json::from_value(entry.extra.get("siwc_registration").cloned().context(
2084 "Existing ChatGPT credentials need reauthorization; run `codewhale auth chatgpt`",
2085 )?)
2086 .context("Saved official ChatGPT registration is invalid; sign in again")?;
2087 anyhow::ensure!(
2088 registration.issuer == CHATGPT_OAUTH_ISSUER
2089 && entry.oidc_issuer.as_deref() == Some(registration.issuer.as_str())
2090 && entry.oidc_client_id.as_deref() == Some(registration.client_id.as_str())
2091 && entry.account_id.as_deref() == Some(registration.subject.as_str())
2092 && valid_issued_chatgpt_client_id(&registration.client_id)
2093 && !registration.subject.trim().is_empty(),
2094 "Saved ChatGPT grant does not match its verified registration; sign in again"
2095 );
2096 require_chatgpt_scopes(entry.extra.get("siwc_scope").and_then(Value::as_str))?;
2097 let access = entry
2098 .access_token
2099 .as_deref()
2100 .context("Saved ChatGPT access token is missing")?;
2101 let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(access.as_bytes()));
2102 anyhow::ensure!(
2103 entry.extra.get("siwc_token_sha256").and_then(Value::as_str) == Some(fingerprint.as_str()),
2104 "Saved ChatGPT token does not match its validated grant; sign in again"
2105 );
2106 Ok(registration)
2107 }
2108
2109 /// Metadata only: no network, refresh, token return, or external credential import.
2110 pub(crate) fn official_chatgpt_registration(config: &Config) -> Result<ChatgptRegistration> {
2111 let identity = config
2112 .builtin_provider_identity(OAuthProvider::Chatgpt.api())
2113 .map_err(anyhow::Error::msg)?;
2114 anyhow::ensure!(
2115 config
2116 .provider_config_for(&identity)
2117 .and_then(|entry| entry.auth_mode.as_deref())
2118 == Some("oauth"),
2119 "Sign in with ChatGPT using `codewhale auth chatgpt`; the selected route has no official OAuth grant"
2120 );
2121 let path = configured_owned_auth_file_path(OAuthProvider::Chatgpt, config)?
2122 .context("Sign in with ChatGPT using `codewhale auth chatgpt`")?;
2123 let mut file = load_owned_auth_file(&path)?.context("Saved ChatGPT credentials are missing")?;
2124 let (_, entry) = select_entry(OAuthProvider::Chatgpt, &mut file)
2125 .context("Saved ChatGPT credentials are missing")?;
2126 registration_from_entry(&entry)
2127 }
2128
2129 impl OAuthProvider {
2130 /// The engine provider this OAuth row belongs to.
2131 #[must_use]
2132 pub fn api(self) -> crate::config::ProviderKind {
2133 match self {
2134 OAuthProvider::Xai => crate::config::ProviderKind::Xai,
2135 OAuthProvider::Chatgpt => crate::config::ProviderKind::OpenaiCodex,
2136 }
2137 }
2138
2139 /// `[providers.<key>]` table this provider's auth state lives under.
2140 fn config_key(self) -> &'static str {
2141 codewhale_config::descriptors::compatibility_for_kind(self.api()).config_key
2142 }
2143
2144 fn legacy_file_name(self) -> &'static str {
2145 match self {
2146 OAuthProvider::Xai => codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
2147 OAuthProvider::Chatgpt => codewhale_config::LEGACY_CHATGPT_OAUTH_FILE_NAME,
2148 }
2149 }
2150
2151 #[must_use]
2152 pub fn is_valid_generation(self, name: &str) -> bool {
2153 match self {
2154 OAuthProvider::Xai => codewhale_config::is_valid_xai_oauth_generation(name),
2155 OAuthProvider::Chatgpt => codewhale_config::is_valid_chatgpt_oauth_generation(name),
2156 }
2157 }
2158
2159 fn validate_generation(self, name: &str) -> Result<()> {
2160 match self {
2161 OAuthProvider::Xai => codewhale_config::validate_xai_oauth_generation(name),
2162 OAuthProvider::Chatgpt => codewhale_config::validate_chatgpt_oauth_generation(name),
2163 }
2164 .map(|_| ())
2165 }
2166
2167 fn generation_path(self, name: &str) -> Result<PathBuf> {
2168 match self {
2169 OAuthProvider::Xai => codewhale_config::xai_oauth_generation_path(name),
2170 OAuthProvider::Chatgpt => codewhale_config::chatgpt_oauth_generation_path(name),
2171 }
2172 }
2173
2174 /// A fresh, uniquely named generation file name for this provider.
2175 fn new_generation(self) -> String {
2176 let (prefix, suffix) = match self {
2177 OAuthProvider::Xai => (
2178 codewhale_config::XAI_OAUTH_GENERATION_PREFIX,
2179 codewhale_config::XAI_OAUTH_GENERATION_SUFFIX,
2180 ),
2181 OAuthProvider::Chatgpt => (
2182 codewhale_config::CHATGPT_OAUTH_GENERATION_PREFIX,
2183 codewhale_config::CHATGPT_OAUTH_GENERATION_SUFFIX,
2184 ),
2185 };
2186 format!("{prefix}{}{suffix}", uuid::Uuid::new_v4().simple())
2187 }
2188 }
2189
2190 /// One entry in a Codewhale-owned auth generation. Reads both historical
2191 /// on-disk shapes; `key` was the xAI/Grok field name for the access token.
2192 #[derive(Clone, Serialize, Deserialize)]
2193 pub struct OwnedAuthEntry {
2194 #[serde(default, alias = "key", skip_serializing_if = "Option::is_none")]
2195 pub access_token: Option<String>,
2196 #[serde(default, skip_serializing_if = "Option::is_none")]
2197 pub refresh_token: Option<String>,
2198 #[serde(default, skip_serializing_if = "Option::is_none")]
2199 pub expires_at: Option<String>,
2200 #[serde(default, skip_serializing_if = "Option::is_none")]
2201 pub id_token: Option<String>,
2202 #[serde(default, skip_serializing_if = "Option::is_none")]
2203 pub account_id: Option<String>,
2204 #[serde(default, skip_serializing_if = "Option::is_none")]
2205 pub oidc_issuer: Option<String>,
2206 #[serde(default, skip_serializing_if = "Option::is_none")]
2207 pub oidc_client_id: Option<String>,
2208 #[serde(default, skip_serializing_if = "Option::is_none")]
2209 pub originator: Option<String>,
2210 #[serde(default, skip_serializing_if = "Option::is_none")]
2211 pub auth_mode: Option<String>,
2212 #[serde(flatten)]
2213 pub extra: BTreeMap<String, Value>,
2214 }
2215
2216 impl std::fmt::Debug for OwnedAuthEntry {
2217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2218 f.debug_struct("OwnedAuthEntry")
2219 .field("access_token", &redacted(self.access_token.is_some()))
2220 .field("refresh_token", &redacted(self.refresh_token.is_some()))
2221 .field("expires_at", &self.expires_at)
2222 .field("id_token", &redacted(self.id_token.is_some()))
2223 .field("account_id", &self.account_id)
2224 .field("oidc_issuer", &self.oidc_issuer)
2225 .field("oidc_client_id", &self.oidc_client_id)
2226 .field("originator", &self.originator)
2227 .field("auth_mode", &self.auth_mode)
2228 .field("extra_keys", &self.extra.keys().collect::<Vec<_>>())
2229 .finish()
2230 }
2231 }
2232
2233 fn redacted(present: bool) -> &'static str {
2234 if present { "<redacted>" } else { "<none>" }
2235 }
2236
2237 /// Resolved owned credentials ready for API use. No Debug: bearer material
2238 /// never prints; consumers redact explicitly.
2239 #[derive(Clone)]
2240 pub struct OwnedOAuthCredentials {
2241 pub access_token: String,
2242 pub account_id: Option<String>,
2243 /// Display label (email, plan) of the entry these credentials came from.
2244 pub account_label: Option<String>,
2245 #[allow(dead_code, reason = "read by provider routes and tests as needed")]
2246 pub refresh_token: Option<String>,
2247 #[allow(dead_code, reason = "diagnostic surface only")]
2248 pub expires_at: Option<String>,
2249 #[allow(dead_code, reason = "route provenance only")]
2250 pub issuer: String,
2251 #[allow(dead_code, reason = "route provenance only")]
2252 pub client_id: String,
2253 }
2254
2255 /// Receipt for a committed Codewhale-owned OAuth generation.
2256 pub struct OAuthActivation {
2257 #[expect(dead_code)]
2258 pub credentials: OwnedOAuthCredentials,
2259 pub config_path: PathBuf,
2260 pub auth_path: PathBuf,
2261 pub provider: OAuthProvider,
2262 /// Non-secret label (email, plan) of the account just signed in.
2263 pub account_label: Option<String>,
2264 /// `Some` when this login replaced an existing Codewhale-owned sign-in;
2265 /// the inner value is that account's label when it had one.
2266 pub replaced: Option<Option<String>>,
2267 }
2268
2269 impl OAuthActivation {
2270 /// "Signed in to ChatGPT as you@example.com (plus)." plus, on its own
2271 /// line, which sign-in this login replaced. Never token material. Shell
2272 /// commands pass [`Locale::En`](codewhale_localization::Locale::En); the
2273 /// TUI passes its UI locale.
2274 #[must_use]
2275 pub fn summary(&self, locale: codewhale_localization::Locale) -> String {
2276 use codewhale_localization::{MessageId, tr};
2277 let name = oauth_provider_params(self.provider).display_name;
2278 let signed_in = match self.account_label.as_deref() {
2279 Some(label) => tr(locale, MessageId::AuthSignedInAs)
2280 .replace("{provider}", name)
2281 .replace("{account}", label),
2282 None => tr(locale, MessageId::AuthSignedInWithoutEmail).replace("{provider}", name),
2283 };
2284 let replaced = match &self.replaced {
2285 Some(Some(previous)) if Some(previous) != self.account_label.as_ref() => Some(
2286 tr(locale, MessageId::AuthReplacedPreviousSignInAs)
2287 .replace("{provider}", name)
2288 .replace("{account}", previous),
2289 ),
2290 Some(Some(_)) => {
2291 Some(tr(locale, MessageId::AuthSameAccountAsBefore).replace("{provider}", name))
2292 }
2293 Some(None) => {
2294 Some(tr(locale, MessageId::AuthReplacedPreviousSignIn).replace("{provider}", name))
2295 }
2296 None => None,
2297 };
2298 match replaced {
2299 Some(replaced) => format!("{signed_in}\n{replaced}"),
2300 None => signed_in,
2301 }
2302 }
2303
2304 /// Official ChatGPT sign-in never uses an ambient process token.
2305 #[must_use]
2306 pub fn env_override_warning(&self, _locale: codewhale_localization::Locale) -> Option<String> {
2307 None
2308 }
2309 }
2310
2311 impl std::fmt::Debug for OAuthActivation {
2312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2313 f.debug_struct("OAuthActivation")
2314 .field("credentials", &redacted(true))
2315 .field("config_path", &self.config_path)
2316 .field("auth_path", &self.auth_path)
2317 .field("provider", &self.provider)
2318 .field("account_label", &self.account_label)
2319 .field("replaced", &self.replaced)
2320 .finish()
2321 }
2322 }
2323
2324 type AuthFile = BTreeMap<String, OwnedAuthEntry>;
2325
2326 fn load_owned_auth_file(path: &Path) -> Result<Option<AuthFile>> {
2327 let Some(raw) = crate::external_credentials::read_codewhale_owned_to_string(path)? else {
2328 return Ok(None);
2329 };
2330 parse_auth_file(&raw, path).map(Some)
2331 }
2332
2333 fn load_owned_auth_file_from_store(
2334 store: &codewhale_config::XaiOAuthCredentialStore,
2335 name: &str,
2336 ) -> Result<Option<AuthFile>> {
2337 let Some(raw) = store.read_to_string(name)? else {
2338 return Ok(None);
2339 };
2340 parse_auth_file(&raw, &store.path_for(name)?).map(Some)
2341 }
2342
2343 fn parse_auth_file(raw: &str, path: &Path) -> Result<AuthFile> {
2344 let value: Value = serde_json::from_str(raw).map_err(|_| {
2345 anyhow::anyhow!(
2346 "credential file {} is not valid credential JSON",
2347 codewhale_config::quote_os_path(path)
2348 )
2349 })?;
2350 let obj = value.as_object().ok_or_else(|| {
2351 anyhow::anyhow!(
2352 "credential file {} must be a JSON object of entries",
2353 codewhale_config::quote_os_path(path)
2354 )
2355 })?;
2356 let mut out = BTreeMap::new();
2357 for (k, v) in obj {
2358 match serde_json::from_value::<OwnedAuthEntry>(v.clone()) {
2359 Ok(entry) => {
2360 out.insert(k.clone(), entry);
2361 }
2362 Err(_) => {
2363 tracing::warn!(
2364 target: "codewhale::oauth",
2365 "skipping unreadable owned auth entry"
2366 );
2367 }
2368 }
2369 }
2370 Ok(out)
2371 }
2372
2373 fn write_auth_file_to_store(
2374 store: &codewhale_config::XaiOAuthCredentialStore,
2375 name: &str,
2376 file: &AuthFile,
2377 allow_replace: bool,
2378 ) -> Result<()> {
2379 let serialized =
2380 serde_json::to_vec_pretty(file).context("serializing owned OAuth credentials")?;
2381 store
2382 .write(name, &serialized, allow_replace)
2383 .with_context(|| {
2384 format!(
2385 "writing owned OAuth credentials to {}",
2386 codewhale_config::quote_os_path(&store.directory().join(name))
2387 )
2388 })?;
2389 #[cfg(test)]
2390 crate::external_credentials::record_owned_credential_write();
2391 Ok(())
2392 }
2393
2394 /// Read-only parse of another CLI's granted credential file.
2395 fn load_external_auth_file(
2396 grant: &codewhale_config::ExternalCredentialReadGrant,
2397 ) -> Result<AuthFile> {
2398 let Some(raw) = crate::external_credentials::read_to_string(grant)? else {
2399 bail!(
2400 "external credential file not found at {}",
2401 codewhale_config::quote_os_path(grant.path())
2402 );
2403 };
2404 parse_auth_file(&raw, grant.path())
2405 }
2406
2407 fn select_entry(provider: OAuthProvider, file: &mut AuthFile) -> Option<(String, OwnedAuthEntry)> {
2408 if provider == OAuthProvider::Chatgpt {
2409 let mut official = file
2410 .iter()
2411 .filter(|(_, entry)| registration_from_entry(entry).is_ok());
2412 if let Some((scope, entry)) = official.next() {
2413 // Ambiguous profile selection fails closed. Login writes one account.
2414 return official
2415 .next()
2416 .is_none()
2417 .then(|| (scope.clone(), entry.clone()));
2418 }
2419 }
2420 // Prefer this provider's registered client-id scope when present.
2421 let preferred_suffix = format!("::{}", oauth_provider_params(provider).default_client_id);
2422 if let Some((k, v)) = file
2423 .iter()
2424 .find(|(k, e)| k.ends_with(&preferred_suffix) && entry_has_usable_secret(e))
2425 {
2426 return Some((k.clone(), v.clone()));
2427 }
2428 file.iter()
2429 .find(|(_, e)| entry_has_usable_secret(e))
2430 .map(|(k, v)| (k.clone(), v.clone()))
2431 }
2432
2433 fn entry_has_usable_secret(entry: &OwnedAuthEntry) -> bool {
2434 entry
2435 .access_token
2436 .as_deref()
2437 .is_some_and(|t| !t.trim().is_empty())
2438 || entry
2439 .refresh_token
2440 .as_deref()
2441 .is_some_and(|t| !t.trim().is_empty())
2442 }
2443
2444 fn chatgpt_refresh_not_before(value: &Value) -> Result<i64> {
2445 value
2446 .as_i64()
2447 .or_else(|| {
2448 value.as_str().and_then(|value| {
2449 value
2450 .parse::<i64>()
2451 .ok()
2452 .or_else(|| parse_rfc3339_secs(value))
2453 })
2454 })
2455 .filter(|value| *value >= 0)
2456 .context("ChatGPT earliest refresh time is invalid; sign in again")
2457 }
2458
2459 const REFRESH_SKEW_SECS: i64 = 60;
2460
2461 fn entry_access_token_is_fresh(entry: &OwnedAuthEntry) -> bool {
2462 let Some(token) = entry
2463 .access_token
2464 .as_deref()
2465 .filter(|t| !t.trim().is_empty())
2466 else {
2467 return false;
2468 };
2469 let stored_expiry = entry.expires_at.as_deref().and_then(parse_rfc3339_secs);
2470 let token_expiry = jwt_expiry_seconds(token).and_then(|exp| i64::try_from(exp).ok());
2471 // A later stored expiry must not hide an already-expired access token.
2472 // Opaque tokens still use stored expiry; no known expiry remains stale.
2473 let now = now_unix_secs().unwrap_or(0);
2474 let refresh_blocked = entry
2475 .extra
2476 .get("siwc_earliest_refresh_at")
2477 .and_then(|value| chatgpt_refresh_not_before(value).ok())
2478 .is_some_and(|earliest| earliest > now);
2479 stored_expiry
2480 .into_iter()
2481 .chain(token_expiry)
2482 .min()
2483 .is_some_and(|exp| {
2484 exp.saturating_sub(now) > REFRESH_SKEW_SECS || (refresh_blocked && exp > now)
2485 })
2486 }
2487
2488 fn credentials_from_entry(
2489 provider: OAuthProvider,
2490 scope: &str,
2491 entry: &OwnedAuthEntry,
2492 access_token: String,
2493 ) -> OwnedOAuthCredentials {
2494 OwnedOAuthCredentials {
2495 access_token,
2496 account_id: entry.account_id.clone(),
2497 account_label: entry.account_label(),
2498 refresh_token: entry.refresh_token.clone(),
2499 expires_at: entry.expires_at.clone(),
2500 issuer: entry
2501 .oidc_issuer
2502 .clone()
2503 .filter(|s| !s.trim().is_empty())
2504 .unwrap_or_else(|| issuer_from_scope(provider, scope)),
2505 client_id: entry
2506 .oidc_client_id
2507 .clone()
2508 .filter(|s| !s.trim().is_empty())
2509 .unwrap_or_else(|| client_id_from_scope(provider, scope)),
2510 }
2511 }
2512
2513 fn issuer_from_scope(provider: OAuthProvider, scope: &str) -> String {
2514 scope
2515 .split_once("::")
2516 .map(|(issuer, _)| issuer.to_string())
2517 .unwrap_or_else(|| oauth_provider_params(provider).default_issuer.to_string())
2518 }
2519
2520 fn client_id_from_scope(provider: OAuthProvider, scope: &str) -> String {
2521 scope
2522 .split_once("::")
2523 .map(|(_, id)| id.to_string())
2524 .unwrap_or_else(|| {
2525 oauth_provider_params(provider)
2526 .default_client_id
2527 .to_string()
2528 })
2529 }
2530
2531 fn apply_token_response(
2532 provider: OAuthProvider,
2533 entry: &mut OwnedAuthEntry,
2534 issuer: &str,
2535 client_id: &str,
2536 token: &OAuthTokenMaterial,
2537 ) -> Result<()> {
2538 if provider == OAuthProvider::Chatgpt {
2539 let registration = token
2540 .verified_chatgpt
2541 .as_ref()
2542 .context("ChatGPT credentials were not cryptographically verified; sign in again")?;
2543 anyhow::ensure!(
2544 registration.issuer == issuer && registration.client_id == client_id,
2545 "Verified ChatGPT registration does not match the token exchange"
2546 );
2547 require_chatgpt_scopes(token.scope.as_deref())?;
2548 entry.extra.insert(
2549 "siwc_registration".to_string(),
2550 serde_json::to_value(registration)?,
2551 );
2552 entry.extra.insert(
2553 "siwc_scope".to_string(),
2554 Value::String(token.scope.clone().unwrap_or_default()),
2555 );
2556 let access = token
2557 .access_token
2558 .as_deref()
2559 .context("token response missing access_token")?;
2560 entry.extra.insert(
2561 "siwc_token_sha256".to_string(),
2562 Value::String(URL_SAFE_NO_PAD.encode(Sha256::digest(access.as_bytes()))),
2563 );
2564 if let Some(value) = &token.earliest_refresh_at {
2565 let earliest = chatgpt_refresh_not_before(value)?;
2566 entry.extra.insert(
2567 "siwc_earliest_refresh_at".to_string(),
2568 Value::from(earliest),
2569 );
2570 } else {
2571 entry.extra.remove("siwc_earliest_refresh_at");
2572 }
2573 entry.account_id = Some(registration.subject.clone());
2574 }
2575 let access = token
2576 .access_token
2577 .as_deref()
2578 .filter(|t| !t.trim().is_empty())
2579 .context("token response missing access_token")?;
2580 entry.access_token = Some(access.to_string());
2581 if let Some(rt) = token
2582 .refresh_token
2583 .as_deref()
2584 .filter(|t| !t.trim().is_empty())
2585 {
2586 entry.refresh_token = Some(rt.to_string());
2587 }
2588 entry.oidc_issuer = Some(issuer.to_string());
2589 entry.oidc_client_id = Some(client_id.to_string());
2590 entry.auth_mode = Some("oidc".to_string());
2591 entry.originator = oauth_provider_params(provider)
2592 .originator
2593 .map(ToOwned::to_owned);
2594 if let Some(id_token) = token.id_token.clone() {
2595 if provider != OAuthProvider::Chatgpt
2596 && let Some(account_id) = account_id_from_id_token(&id_token)
2597 {
2598 entry.account_id = Some(account_id);
2599 }
2600 entry.id_token = Some(id_token);
2601 }
2602 if let Some(expires_in) = token.expires_in {
2603 entry.expires_at = Some(rfc3339_from_now(expires_in));
2604 } else if let Some(exp) = jwt_expiry_seconds(access) {
2605 entry.expires_at = Some(rfc3339_from_unix(exp as i64));
2606 }
2607 Ok(())
2608 }
2609
2610 fn account_id_from_id_token(token: &str) -> Option<String> {
2611 let payload = jwt_payload(token)?;
2612 if let Some(id) = payload.get("chatgpt_account_id").and_then(Value::as_str) {
2613 let trimmed = id.trim();
2614 if !trimmed.is_empty() {
2615 return Some(trimmed.to_string());
2616 }
2617 }
2618 payload
2619 .get("https://api.openai.com/auth")
2620 .and_then(|auth| auth.get("chatgpt_account_id"))
2621 .and_then(Value::as_str)
2622 .map(str::trim)
2623 .filter(|id| !id.is_empty())
2624 .map(ToOwned::to_owned)
2625 }
2626
2627 /// Longest email the label keeps (RFC 5321 path limit); plan names are short.
2628 const ACCOUNT_EMAIL_MAX_CHARS: usize = 254;
2629 const ACCOUNT_PLAN_MAX_CHARS: usize = 32;
2630 /// Leading characters of a ChatGPT account id shown to tell two workspaces
2631 /// on one email apart.
2632 const ACCOUNT_ID_PREFIX_CHARS: usize = 8;
2633 /// ChatGPT plans that belong to one person, where email and plan already
2634 /// identify the account. Any other plan (team, business, enterprise, edu,
2635 /// or one this list has not seen) is a workspace a person may hold several
2636 /// of under one email, so its label carries an account-id prefix.
2637 const PERSONAL_CHATGPT_PLANS: &[&str] = &["free", "go", "plus", "pro"];
2638
2639 /// Non-secret account label from an ID token's claims, decoded locally:
2640 /// `email`, `email (plan)`, or for a workspace plan
2641 /// `email (plan, workspace 1a2b3c4d)`. The signature is not verified: the
2642 /// label is for display only and never authorizes anything. Claim text is
2643 /// bounded and stripped of control characters so a hostile token cannot
2644 /// smuggle terminal escapes into status output. `None` when no usable email
2645 /// claim exists.
2646 #[must_use]
2647 pub fn account_label_from_id_token(token: &str) -> Option<String> {
2648 let payload = jwt_payload(token)?;
2649 let claim_text = |value: Option<&Value>, max: usize| {
2650 let text: String = value?
2651 .as_str()?
2652 .chars()
2653 .filter(|ch| !ch.is_control())
2654 .take(max)
2655 .collect();
2656 let text = text.trim();
2657 (!text.is_empty()).then(|| text.to_string())
2658 };
2659 let email = claim_text(payload.get("email"), ACCOUNT_EMAIL_MAX_CHARS).or_else(|| {
2660 claim_text(
2661 payload
2662 .get("https://api.openai.com/profile")
2663 .and_then(|profile| profile.get("email")),
2664 ACCOUNT_EMAIL_MAX_CHARS,
2665 )
2666 })?;
2667 let plan = claim_text(
2668 payload
2669 .get("https://api.openai.com/auth")
2670 .and_then(|auth| auth.get("chatgpt_plan_type")),
2671 ACCOUNT_PLAN_MAX_CHARS,
2672 );
2673 let workspace = plan
2674 .as_deref()
2675 .filter(|plan| {
2676 !PERSONAL_CHATGPT_PLANS
2677 .iter()
2678 .any(|personal| plan.eq_ignore_ascii_case(personal))
2679 })
2680 .and_then(|_| account_id_from_id_token(token))
2681 .map(|id| {
2682 id.chars()
2683 .filter(char::is_ascii_alphanumeric)
2684 .take(ACCOUNT_ID_PREFIX_CHARS)
2685 .collect::<String>()
2686 })
2687 .filter(|prefix| !prefix.is_empty());
2688 Some(match (plan, workspace) {
2689 (Some(plan), Some(workspace)) => format!("{email} ({plan}, workspace {workspace})"),
2690 (Some(plan), None) => format!("{email} ({plan})"),
2691 (None, _) => email,
2692 })
2693 }
2694
2695 impl OwnedAuthEntry {
2696 /// Display label for the account this entry signs in as.
2697 fn account_label(&self) -> Option<String> {
2698 self.id_token
2699 .as_deref()
2700 .and_then(account_label_from_id_token)
2701 }
2702 }
2703
2704 /// Whether an owned entry can still produce a bearer: a fresh access token,
2705 /// or a refresh token to mint one. The same test gates [`credentials_valid`]
2706 /// and the account label, so a label is never shown for an entry the
2707 /// runtime would refuse.
2708 fn owned_entry_is_usable(entry: &OwnedAuthEntry) -> bool {
2709 entry_access_token_is_fresh(entry)
2710 || entry
2711 .refresh_token
2712 .as_deref()
2713 .is_some_and(|token| !token.trim().is_empty())
2714 }
2715
2716 /// Account label of the Codewhale-owned sign-in stored in one generation
2717 /// file. Reads only that file; never refreshes, writes, or touches the
2718 /// network. `Ok(None)` when the usable entry's ID token carries no email;
2719 /// `Err` (a fixed, token-free reason) when the generation name is invalid,
2720 /// the file is missing or unreadable, or it holds no usable sign-in.
2721 pub fn owned_account_label_for_generation(
2722 provider: OAuthProvider,
2723 generation: &str,
2724 ) -> Result<Option<String>> {
2725 let path = provider
2726 .generation_path(generation)
2727 .map_err(|_| anyhow::anyhow!("invalid generation pointer"))?;
2728 owned_account_label_at(provider, &path)
2729 }
2730
2731 fn owned_account_label_at(provider: OAuthProvider, path: &Path) -> Result<Option<String>> {
2732 let mut file = load_owned_auth_file(path)
2733 .map_err(|_| anyhow::anyhow!("sign-in file is unreadable"))?
2734 .ok_or_else(|| anyhow::anyhow!("sign-in file is missing"))?;
2735 let (_, entry) = select_entry(provider, &mut file)
2736 .filter(|(_, entry)| {
2737 (provider != OAuthProvider::Chatgpt || registration_from_entry(entry).is_ok())
2738 && owned_entry_is_usable(entry)
2739 })
2740 .ok_or_else(|| anyhow::anyhow!("sign-in file holds no usable sign-in"))?;
2741 Ok(entry.account_label())
2742 }
2743
2744 /// What to tell someone whose subscription sign-in hit a plan limit: which
2745 /// account made the request (label only, never a token) and how to sign in
2746 /// with a different one, both inside a running session and from a shell.
2747 /// A shell login does not reach a session that is already open, so the
2748 /// shell route says to restart.
2749 #[must_use]
2750 pub fn usage_limit_guidance(provider: OAuthProvider, account_label: Option<&str>) -> String {
2751 let params = oauth_provider_params(provider);
2752 let name = params.display_name;
2753 let switch = if provider == OAuthProvider::Chatgpt {
2754 "To continue with a different ChatGPT account, run `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt` in a shell and then restart open Codewhale sessions. `/auth chatgpt` reauthorizes the selected account.".to_string()
2755 } else {
2756 format!(
2757 "To continue with a different {name} account, run `{}` in Codewhale, or `{}` in a shell and then restart open Codewhale sessions.",
2758 params.session_login_hint, params.relogin_hint
2759 )
2760 };
2761 match account_label {
2762 Some(label) => format!("This request used the {name} account {label}. {switch}"),
2763 None => switch,
2764 }
2765 }
2766
2767 /// Printed beside a login URL: the issuer signs in whichever account the
2768 /// browser already holds unless the user picks another.
2769 fn account_choice_hint(display_name: &str) -> String {
2770 if display_name == "ChatGPT" {
2771 return "Returning sign-in must use the selected ChatGPT account. To replace it, start a new login with `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`. If the browser shows a different account, sign out there first or open the login URL in a private window.".to_string();
2772 }
2773 format!(
2774 "Approve with the {display_name} account Codewhale should use. If the page is already \
2775 signed in to a different account, sign out there first or open the URL above in a \
2776 private window."
2777 )
2778 }
2779
2780 fn jwt_payload(token: &str) -> Option<Value> {
2781 let mut parts = token.split('.');
2782 let _header = parts.next()?;
2783 let payload = parts.next()?;
2784 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
2785 serde_json::from_slice(&decoded).ok()
2786 }
2787
2788 fn now_unix_secs() -> Option<i64> {
2789 SystemTime::now()
2790 .duration_since(UNIX_EPOCH)
2791 .ok()
2792 .and_then(|d| i64::try_from(d.as_secs()).ok())
2793 }
2794
2795 fn parse_rfc3339_secs(raw: &str) -> Option<i64> {
2796 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
2797 return Some(dt.timestamp());
2798 }
2799 // Simple UTC forms chrono's strict parser rejects, e.g. a missing
2800 // fractional second on an offset-less timestamp.
2801 let trimmed = raw.trim().trim_end_matches('Z');
2802 let (date, time) = trimmed.split_once('T')?;
2803 let mut d = date.split('-');
2804 let y: i32 = d.next()?.parse().ok()?;
2805 let m: u32 = d.next()?.parse().ok()?;
2806 let day: u32 = d.next()?.parse().ok()?;
2807 let time = time.split('+').next()?.split('-').next()?;
2808 let mut t = time.split(':');
2809 let hh: u32 = t.next()?.parse().ok()?;
2810 let mm: u32 = t.next()?.parse().ok()?;
2811 let ss: u32 = t
2812 .next()
2813 .and_then(|s| s.split('.').next())
2814 .and_then(|s| s.parse().ok())
2815 .unwrap_or(0);
2816 let ndt = chrono::NaiveDate::from_ymd_opt(y, m, day)?.and_hms_opt(hh, mm, ss)?;
2817 Some(ndt.and_utc().timestamp())
2818 }
2819
2820 fn rfc3339_from_now(expires_in: u64) -> String {
2821 let ts = now_unix_secs().unwrap_or(0) + expires_in as i64;
2822 rfc3339_from_unix(ts)
2823 }
2824
2825 fn rfc3339_from_unix(ts: i64) -> String {
2826 chrono::DateTime::from_timestamp(ts, 0)
2827 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true))
2828 .unwrap_or_else(|| format!("{ts}"))
2829 }
2830
2831 /// Refresh through the seam, resolving the token endpoint the provider's
2832 /// row demands (discovered for xAI, pinned for ChatGPT).
2833 fn refresh_for_provider(
2834 provider: OAuthProvider,
2835 client: &dyn OAuthFormClient,
2836 issuer: &str,
2837 client_id: &str,
2838 refresh_token: &str,
2839 ) -> Result<OAuthTokenMaterial> {
2840 let params = oauth_provider_params(provider);
2841 let token_url = if params.discover_endpoints {
2842 resolve_oauth_endpoints(params, issuer).token_endpoint
2843 } else {
2844 form_token_url(params, issuer)
2845 };
2846 refresh_access_token_via(client, params, &token_url, client_id, refresh_token)
2847 }
2848
2849 fn configured_owned_auth_file_path(
2850 provider: OAuthProvider,
2851 config: &Config,
2852 ) -> Result<Option<PathBuf>> {
2853 let identity = config
2854 .builtin_provider_identity(provider.api())
2855 .map_err(anyhow::Error::msg)?;
2856 let generation = config
2857 .provider_config_for(&identity)
2858 .and_then(|entry| entry.oauth_credential_generation.as_deref());
2859 match generation {
2860 Some(generation) => provider.generation_path(generation).map(Some),
2861 None => Ok(None),
2862 }
2863 }
2864
2865 /// A subscription sign-in the structural check found usable, named by the
2866 /// same read that proved it usable. Holds a display label only.
2867 #[derive(Debug, Clone, PartialEq, Eq)]
2868 pub struct UsableSignIn {
2869 /// Email (and plan) of the account that entry signs in as; `None` when
2870 /// its ID token carries no email claim.
2871 pub account_label: Option<String>,
2872 }
2873
2874 /// Prompt-free structural check for owned OAuth material. Never refreshes,
2875 /// writes, or makes network requests. External storage is not inspected
2876 /// until exact read-only consent has been persisted, and then only at the
2877 /// exact consented path — no ambient candidate is ever resolved or opened
2878 /// (#5772).
2879 #[must_use]
2880 pub fn credentials_valid(provider: OAuthProvider, config: &Config) -> bool {
2881 usable_sign_in(provider, config).is_some()
2882 }
2883
2884 /// [`credentials_valid`], also naming the account of the entry that passed:
2885 /// the configured Codewhale-owned generation, else (xAI) the legacy owned
2886 /// file or the consented Grok CLI import — the credential the route would
2887 /// send. Readiness surfaces take the label from here so naming the account
2888 /// costs no second credential read.
2889 #[must_use]
2890 pub fn usable_sign_in(provider: OAuthProvider, config: &Config) -> Option<UsableSignIn> {
2891 let identity = config.builtin_provider_identity(provider.api()).ok()?;
2892 let usable = |entry: &OwnedAuthEntry| UsableSignIn {
2893 account_label: entry.account_label(),
2894 };
2895 // Codewhale-owned OAuth bytes are inert until the provider route
2896 // explicitly selects OAuth. A failed post-login config finalization can
2897 // therefore never make a newly written token silently ready on the next
2898 // launch.
2899 if provider == OAuthProvider::Chatgpt
2900 && config
2901 .provider_config_for(&identity)
2902 .and_then(|entry| entry.auth_mode.as_deref())
2903 != Some("oauth")
2904 {
2905 return None;
2906 }
2907 if provider == OAuthProvider::Xai
2908 && !config
2909 .provider_config_for(&identity)
2910 .and_then(|entry| entry.auth_mode.as_deref())
2911 .is_some_and(auth_mode_uses_xai_oauth)
2912 {
2913 return None;
2914 }
2915 if let Ok(Some(path)) = configured_owned_auth_file_path(provider, config)
2916 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
2917 && let Some((_, entry)) = select_entry(provider, &mut file)
2918 && (provider != OAuthProvider::Chatgpt || registration_from_entry(&entry).is_ok())
2919 && owned_entry_is_usable(&entry)
2920 {
2921 return Some(usable(&entry));
2922 }
2923 if config
2924 .provider_config_for(&identity)
2925 .and_then(|entry| entry.oauth_credential_generation.as_deref())
2926 .is_some()
2927 {
2928 // A configured generation is authoritative. Invalid, missing, unsafe,
2929 // or malformed owned storage must not fall through to an external CLI.
2930 return None;
2931 }
2932 if provider == OAuthProvider::Xai {
2933 // The pre-generation legacy file is the last owned location.
2934 if let Ok(path) = codewhale_config::legacy_xai_oauth_path()
2935 && let Ok(Some(mut file)) = load_owned_auth_file(&path)
2936 && let Some((_, entry)) = select_entry(provider, &mut file)
2937 && owned_entry_is_usable(&entry)
2938 {
2939 return Some(usable(&entry));
2940 }
2941 // #5772: with no persisted consent record there is no external path
2942 // to resolve and nothing to open.
2943 if let Some(consent_path) = config
2944 .provider_config_for(&identity)
2945 .and_then(|entry| entry.external_credentials.as_ref())
2946 .map(|consent| consent.path.clone())
2947 && let Ok(grant) = config.external_credential_read_grant(
2948 &identity,
2949 codewhale_config::ExternalCredentialSource::GrokCli,
2950 &consent_path,
2951 )
2952 && let Ok(mut file) = load_external_auth_file(&grant)
2953 {
2954 return select_entry(provider, &mut file)
2955 .filter(|(_, entry)| entry_access_token_is_fresh(entry))
2956 .map(|(_, entry)| usable(&entry));
2957 }
2958 }
2959 None
2960 }
2961
2962 #[must_use]
2963 pub fn credentials_present(provider: OAuthProvider, config: &Config) -> bool {
2964 credentials_valid(provider, config)
2965 }
2966
2967 /// Grant-time validation for an external Grok CLI credential file (#5772).
2968 ///
2969 /// Reads exactly the granted path through the secure adapter and requires a
2970 /// usable, unexpired entry. Never refreshes, rewrites, or makes a network
2971 /// request. Consent is persisted only after this succeeds, so a consent
2972 /// record can never be written for a file that holds nothing usable.
2973 pub fn validate_grok_external_credentials(
2974 grant: &codewhale_config::ExternalCredentialReadGrant,
2975 ) -> Result<()> {
2976 let mut file = load_external_auth_file(grant)?;
2977 let (_, entry) = select_entry(OAuthProvider::Xai, &mut file).ok_or_else(|| {
2978 anyhow::anyhow!(
2979 "xAI OAuth credentials at {} have no usable entry. Run `grok login` again or use `codewhale auth xai-device` for Codewhale-owned storage.",
2980 codewhale_config::quote_os_path(grant.path())
2981 )
2982 })?;
2983 if !entry_access_token_is_fresh(&entry) {
2984 bail!(
2985 "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`.",
2986 codewhale_config::quote_os_path(grant.path())
2987 );
2988 }
2989 Ok(())
2990 }
2991
2992 /// Load xAI OAuth credentials with full precedence: configured generation,
2993 /// legacy owned file, then the consented Grok CLI import. Codewhale-owned
2994 /// credentials may refresh and rewrite Codewhale-owned storage; external
2995 /// credentials are read-only.
2996 pub fn get_xai_credentials(config: &Config) -> Result<OwnedOAuthCredentials> {
2997 let identity = config
2998 .active_provider_identity()
2999 .map_err(anyhow::Error::msg)?;
3000 anyhow::ensure!(
3001 identity.provider == crate::config::ProviderKind::Xai
3002 && identity.key.as_str() == crate::config::ProviderKind::Xai.as_str()
3003 && config
3004 .provider_config_for(&identity)
3005 .and_then(|entry| entry.auth_mode.as_deref())
3006 .is_some_and(auth_mode_uses_xai_oauth),
3007 "Codewhale-owned xAI OAuth credentials are inactive until the xAI route explicitly selects OAuth"
3008 );
3009 if let Some(owned_path) = configured_owned_auth_file_path(OAuthProvider::Xai, config)? {
3010 return get_owned_credentials_at(OAuthProvider::Xai, &owned_path);
3011 }
3012 let owned_path = codewhale_config::legacy_xai_oauth_path()?;
3013 if load_owned_auth_file(&owned_path)?.is_some() {
3014 return get_owned_credentials_at(OAuthProvider::Xai, &owned_path);
3015 }
3016
3017 let external_path = grok_auth_file_path();
3018 let grant = config.external_credential_read_grant(
3019 &identity,
3020 codewhale_config::ExternalCredentialSource::GrokCli,
3021 &external_path,
3022 )?;
3023 let mut file = load_external_auth_file(&grant)?;
3024 let (scope, entry) = select_entry(OAuthProvider::Xai, &mut file).ok_or_else(|| {
3025 anyhow::anyhow!(
3026 "xAI OAuth credentials at {} have no usable entry. Run `grok login` again or use `codewhale auth xai-device` for Codewhale-owned storage.",
3027 codewhale_config::quote_os_path(grant.path())
3028 )
3029 })?;
3030 if !entry_access_token_is_fresh(&entry) {
3031 bail!(
3032 "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`.",
3033 codewhale_config::quote_os_path(grant.path())
3034 );
3035 }
3036 let token = entry
3037 .access_token
3038 .clone()
3039 .filter(|token| !token.trim().is_empty())
3040 .context("xAI OAuth access token is empty")?;
3041 Ok(credentials_from_entry(
3042 OAuthProvider::Xai,
3043 &scope,
3044 &entry,
3045 token,
3046 ))
3047 }
3048
3049 /// Load ChatGPT owned credentials from the configured generation,
3050 /// refreshing through the seam when stale.
3051 pub fn get_owned_credentials(
3052 provider: OAuthProvider,
3053 config: &Config,
3054 ) -> Result<OwnedOAuthCredentials> {
3055 let identity = config
3056 .builtin_provider_identity(provider.api())
3057 .map_err(anyhow::Error::msg)?;
3058 if provider == OAuthProvider::Chatgpt {
3059 anyhow::ensure!(
3060 config
3061 .provider_config_for(&identity)
3062 .and_then(|entry| entry.auth_mode.as_deref())
3063 == Some("oauth"),
3064 "ChatGPT credentials are inactive; run `codewhale auth chatgpt`"
3065 );
3066 }
3067 get_owned_credentials_with(provider, config, &ReqwestOAuthFormClient)
3068 }
3069
3070 /// Read-only diagnostics: never refreshes or changes protected storage.
3071 pub(crate) fn get_owned_credentials_read_only(
3072 provider: OAuthProvider,
3073 config: &Config,
3074 ) -> Result<OwnedOAuthCredentials> {
3075 anyhow::ensure!(
3076 provider == OAuthProvider::Chatgpt,
3077 "Read-only official credentials require ChatGPT"
3078 );
3079 official_chatgpt_registration(config)?;
3080 let path = configured_owned_auth_file_path(provider, config)?
3081 .context("ChatGPT credentials are not configured")?;
3082 let mut file = load_owned_auth_file(&path)?.context("ChatGPT credentials are missing")?;
3083 let (scope, entry) =
3084 select_entry(provider, &mut file).context("ChatGPT credentials are unavailable")?;
3085 registration_from_entry(&entry)?;
3086 anyhow::ensure!(
3087 entry_access_token_is_fresh(&entry),
3088 "ChatGPT access token needs runtime refresh; read-only diagnostics do not refresh"
3089 );
3090 let access = entry
3091 .access_token
3092 .clone()
3093 .context("ChatGPT access token is missing")?;
3094 Ok(credentials_from_entry(provider, &scope, &entry, access))
3095 }
3096
3097 fn get_owned_credentials_with(
3098 provider: OAuthProvider,
3099 config: &Config,
3100 client: &dyn OAuthFormClient,
3101 ) -> Result<OwnedOAuthCredentials> {
3102 let Some(path) = configured_owned_auth_file_path(provider, config)? else {
3103 bail!(
3104 "Codewhale-owned {} OAuth credentials are not configured",
3105 oauth_provider_params(provider).display_name
3106 );
3107 };
3108 let name = path
3109 .file_name()
3110 .and_then(|name| name.to_str())
3111 .context("Codewhale-owned OAuth path must have a UTF-8 basename")?;
3112 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
3113 get_owned_credentials_locked(
3114 provider,
3115 store,
3116 name,
3117 client,
3118 |issuer, client_id, refresh| {
3119 refresh_for_provider(provider, client, issuer, client_id, refresh)
3120 },
3121 )
3122 })
3123 }
3124
3125 fn get_owned_credentials_at(provider: OAuthProvider, path: &Path) -> Result<OwnedOAuthCredentials> {
3126 let directory = codewhale_config::xai_oauth_credentials_dir()?;
3127 anyhow::ensure!(
3128 path.parent() == Some(directory.as_path()),
3129 "Codewhale-owned OAuth path escaped the credentials directory"
3130 );
3131 let name = path
3132 .file_name()
3133 .and_then(|name| name.to_str())
3134 .context("Codewhale-owned OAuth path must have a UTF-8 basename")?;
3135 anyhow::ensure!(
3136 name == provider.legacy_file_name() || provider.is_valid_generation(name),
3137 "Codewhale-owned OAuth path has an invalid basename"
3138 );
3139 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
3140 get_owned_credentials_locked(
3141 provider,
3142 store,
3143 name,
3144 &ReqwestOAuthFormClient,
3145 |issuer, client_id, refresh| {
3146 refresh_for_provider(
3147 provider,
3148 &ReqwestOAuthFormClient,
3149 issuer,
3150 client_id,
3151 refresh,
3152 )
3153 },
3154 )
3155 })
3156 }
3157
3158 fn get_owned_credentials_locked<F>(
3159 provider: OAuthProvider,
3160 store: &codewhale_config::XaiOAuthCredentialStore,
3161 name: &str,
3162 client: &dyn OAuthFormClient,
3163 refresh_access: F,
3164 ) -> Result<OwnedOAuthCredentials>
3165 where
3166 F: FnOnce(&str, &str, &str) -> Result<OAuthTokenMaterial>,
3167 {
3168 let hint = oauth_provider_params(provider).relogin_hint;
3169 let path = store.path_for(name)?;
3170 let mut file = load_owned_auth_file_from_store(store, name)?.ok_or_else(|| {
3171 anyhow::anyhow!(
3172 "Codewhale-owned OAuth credentials were not found at {}. Run `{hint}` again.",
3173 codewhale_config::quote_os_path(&path)
3174 )
3175 })?;
3176 let (scope, mut entry) = select_entry(provider, &mut file).ok_or_else(|| {
3177 anyhow::anyhow!(
3178 "Codewhale-owned OAuth credentials at {} have no usable entry. Run `{hint}` again.",
3179 codewhale_config::quote_os_path(&path)
3180 )
3181 })?;
3182 let registration = if provider == OAuthProvider::Chatgpt {
3183 Some(registration_from_entry(&entry)?)
3184 } else {
3185 None
3186 };
3187
3188 if entry_access_token_is_fresh(&entry) {
3189 let token = entry
3190 .access_token
3191 .clone()
3192 .filter(|t| !t.trim().is_empty())
3193 .context("OAuth access token is empty")?;
3194 return Ok(credentials_from_entry(provider, &scope, &entry, token));
3195 }
3196
3197 let refresh = entry
3198 .refresh_token
3199 .as_deref()
3200 .filter(|t| !t.trim().is_empty())
3201 .context(format!(
3202 "OAuth access token expired and no refresh_token is stored. Run `{hint}` again."
3203 ))?;
3204 let issuer = entry
3205 .oidc_issuer
3206 .clone()
3207 .filter(|s| !s.trim().is_empty())
3208 .unwrap_or_else(|| issuer_from_scope(provider, &scope));
3209 let client_id = entry
3210 .oidc_client_id
3211 .clone()
3212 .filter(|s| !s.trim().is_empty())
3213 .unwrap_or_else(|| client_id_from_scope(provider, &scope));
3214
3215 if let Some(value) = entry.extra.get("siwc_earliest_refresh_at") {
3216 anyhow::ensure!(
3217 chatgpt_refresh_not_before(value)?
3218 <= now_unix_secs().context("System clock is unavailable for ChatGPT refresh")?,
3219 "ChatGPT refresh window has not opened; retry later"
3220 );
3221 }
3222 let mut refreshed = refresh_access(&issuer, &client_id, refresh)?;
3223 if let Some(registration) = registration {
3224 require_chatgpt_scopes(refreshed.scope.as_deref())?;
3225 anyhow::ensure!(
3226 refreshed
3227 .token_type
3228 .as_deref()
3229 .is_some_and(|value| value.eq_ignore_ascii_case("bearer")),
3230 "ChatGPT refresh did not return Bearer credentials"
3231 );
3232 anyhow::ensure!(
3233 refreshed
3234 .refresh_token
3235 .as_deref()
3236 .is_some_and(|token| !token.trim().is_empty()),
3237 "ChatGPT refresh omitted its rotating refresh token; sign in again"
3238 );
3239 let keys = client.chatgpt_jwks(&issuer)?;
3240 verify_chatgpt_access_token(
3241 refreshed
3242 .access_token
3243 .as_deref()
3244 .context("ChatGPT refresh omitted its access token")?,
3245 &keys,
3246 &registration,
3247 )?;
3248 if let Some(id_token) = &refreshed.id_token {
3249 let refreshed_registration = verify_chatgpt_id_token(
3250 id_token,
3251 &keys,
3252 &issuer,
3253 &client_id,
3254 None,
3255 Some(&registration.subject),
3256 &registration.host_id,
3257 )?;
3258 refreshed.verified_chatgpt = Some(refreshed_registration);
3259 } else {
3260 refreshed.verified_chatgpt = Some(registration);
3261 }
3262 }
3263 apply_token_response(provider, &mut entry, &issuer, &client_id, &refreshed)?;
3264 file.insert(scope.clone(), entry.clone());
3265 write_auth_file_to_store(store, name, &file, true)?;
3266
3267 let token = entry
3268 .access_token
3269 .clone()
3270 .filter(|t| !t.trim().is_empty())
3271 .context("OAuth refresh returned an empty access token")?;
3272 Ok(credentials_from_entry(provider, &scope, &entry, token))
3273 }
3274
3275 /// Commit a pending login as a uniquely named owned generation and
3276 /// atomically point the provider's config table at it under the shared
3277 /// config lock.
3278 ///
3279 /// The credential file is staged while the config lock is held. If config
3280 /// persistence fails, the unreferenced stage is removed. Only after the new
3281 /// pointer commits is the previously selected generation removed best-effort.
3282 pub fn activate_login(
3283 pending: PendingOAuthLogin,
3284 config_path: Option<&Path>,
3285 live_config: Option<&mut Config>,
3286 ) -> Result<OAuthActivation> {
3287 codewhale_config::with_xai_oauth_lifecycle_lock(move |store| {
3288 activate_login_locked(pending, config_path, live_config, store)
3289 })
3290 }
3291
3292 fn activate_login_locked(
3293 pending: PendingOAuthLogin,
3294 config_path: Option<&Path>,
3295 live_config: Option<&mut Config>,
3296 store: &codewhale_config::XaiOAuthCredentialStore,
3297 ) -> Result<OAuthActivation> {
3298 let provider = pending.provider;
3299 let display_name = oauth_provider_params(provider).display_name;
3300 let config_path = crate::config_persistence::config_toml_path(config_path)?;
3301 let generation = provider.new_generation();
3302 provider.validate_generation(&generation)?;
3303 let auth_path = store.path_for(&generation)?;
3304 let key_inside = provider.config_key();
3305 let mut stage_written = false;
3306
3307 let activation = codewhale_config::mutate_config_document(&config_path, |document| {
3308 let previous_generation_item = document
3309 .get("providers")
3310 .and_then(toml_edit::Item::as_table_like)
3311 .and_then(|providers| providers.get(key_inside))
3312 .and_then(toml_edit::Item::as_table_like)
3313 .and_then(|provider| provider.get("oauth_credential_generation"));
3314 let previous_generation = previous_generation_item
3315 .map(|item| {
3316 item.as_str()
3317 .context(format!(
3318 "refusing {display_name} login because the existing credential generation pointer is not a string"
3319 ))
3320 .map(ToOwned::to_owned)
3321 })
3322 .transpose()?;
3323 if let Some(previous) = previous_generation.as_deref() {
3324 provider.validate_generation(previous).with_context(|| {
3325 format!(
3326 "refusing {display_name} login because the existing credential generation pointer is invalid"
3327 )
3328 })?;
3329 }
3330
3331 let previous_owned_name = match previous_generation.as_deref() {
3332 Some(previous) => Some(previous.to_string()),
3333 None if store.read_to_string(provider.legacy_file_name())?.is_some() => {
3334 Some(provider.legacy_file_name().to_string())
3335 }
3336 None => None,
3337 };
3338 // Read the previous generation only to name the account it signed
3339 // in as. A valid pointer whose file is gone (interrupted revocation,
3340 // external cleanup) must not brick login: only a successful
3341 // activation can ever rewrite the pointer, so treat the missing
3342 // generation like a fresh start instead of failing (#5032).
3343 let mut previous_file = match previous_owned_name.as_deref() {
3344 Some(name) => load_owned_auth_file_from_store(store, name)?.unwrap_or_else(|| {
3345 tracing::warn!(
3346 target: "codewhale::oauth",
3347 generation = name,
3348 "config pointed at a missing owned OAuth generation; starting a fresh credential file"
3349 );
3350 BTreeMap::new()
3351 }),
3352 None => BTreeMap::new(),
3353 };
3354 let scope = format!("{}::{}", pending.issuer, pending.client_id);
3355 // A new login replaces the whole sign-in: the new generation holds
3356 // only this entry. Merging into the old entry would let a previous
3357 // account's refresh token, id token or account id survive whenever
3358 // the new grant omits one; carrying other scopes forward would let
3359 // an older account's entry under a differently spelled issuer
3360 // outrank this login in `select_entry` and keep its refresh token
3361 // on disk. `replaced` names the entry the runtime was actually using.
3362 let replaced =
3363 select_entry(provider, &mut previous_file).map(|(_, entry)| entry.account_label());
3364 let mut file = AuthFile::new();
3365 let mut entry = OwnedAuthEntry {
3366 access_token: None,
3367 refresh_token: None,
3368 expires_at: None,
3369 id_token: None,
3370 account_id: None,
3371 oidc_issuer: Some(pending.issuer.clone()),
3372 oidc_client_id: Some(pending.client_id.clone()),
3373 originator: None,
3374 auth_mode: Some("oidc".to_string()),
3375 extra: BTreeMap::new(),
3376 };
3377 apply_token_response(
3378 provider,
3379 &mut entry,
3380 &pending.issuer,
3381 &pending.client_id,
3382 &pending.token,
3383 )?;
3384 let access = entry
3385 .access_token
3386 .clone()
3387 .filter(|token| !token.trim().is_empty())
3388 .context(format!(
3389 "{display_name} login returned an empty access token"
3390 ))?;
3391 file.insert(scope.clone(), entry.clone());
3392 write_auth_file_to_store(store, &generation, &file, false)?;
3393 stage_written = true;
3394
3395 codewhale_config::set_config_document_value(
3396 document,
3397 &["providers", key_inside, "auth_mode"],
3398 "oauth",
3399 )?;
3400 codewhale_config::set_config_document_value(
3401 document,
3402 &["providers", key_inside, "oauth_credential_generation"],
3403 generation.clone(),
3404 )?;
3405 codewhale_config::unset_config_document_value(
3406 document,
3407 &["providers", key_inside, "external_credentials"],
3408 )?;
3409 Ok((
3410 previous_owned_name,
3411 credentials_from_entry(provider, &scope, &entry, access),
3412 entry.account_label(),
3413 replaced,
3414 ))
3415 });
3416
3417 let (previous_owned_name, credentials, account_label, replaced) = match activation {
3418 Ok(activation) => activation,
3419 Err(error) => {
3420 if stage_written && let Err(cleanup_error) = store.remove(&generation) {
3421 return Err(error).context(format!(
3422 "{display_name} login was not activated; also failed to remove unreferenced staged credentials at {}: {cleanup_error}",
3423 codewhale_config::quote_os_path(&auth_path)
3424 ));
3425 }
3426 return Err(error).context(format!(
3427 "{display_name} login was not activated; provider configuration is unchanged"
3428 ));
3429 }
3430 };
3431 if let Some(registration) = pending.token.verified_chatgpt.as_ref() {
3432 let host = ChatgptHost {
3433 host_id: registration.host_id.clone(),
3434 registration: Some(registration.clone()),
3435 };
3436 if let Err(error) = serde_json::to_vec(&host)
3437 .map_err(anyhow::Error::from)
3438 .and_then(|bytes| store.write(codewhale_config::CHATGPT_HOST_FILE_NAME, &bytes, true))
3439 {
3440 tracing::warn!(target: "codewhale::oauth", error = %error, "ChatGPT grant activated; retaining its signed-out registration failed");
3441 }
3442 }
3443
3444 if let Some(config) = live_config {
3445 match provider {
3446 OAuthProvider::Xai => config.mark_codewhale_owned_xai_oauth(generation.clone())?,
3447 OAuthProvider::Chatgpt => {
3448 config.mark_codewhale_owned_chatgpt_oauth(generation.clone())?;
3449 }
3450 }
3451 }
3452 if let Some(previous) = previous_owned_name
3453 && previous != generation
3454 && let Err(error) = store.remove(&previous)
3455 {
3456 tracing::warn!(
3457 target: "codewhale::oauth",
3458 error = %error,
3459 "new OAuth generation committed but superseded generation cleanup failed"
3460 );
3461 }
3462 Ok(OAuthActivation {
3463 credentials,
3464 config_path,
3465 auth_path,
3466 provider,
3467 account_label,
3468 replaced,
3469 })
3470 }
3471
3472 /// Remove Codewhale-owned tokens and the config pointer for one provider.
3473 ///
3474 /// ChatGPT's remote revoke is best-effort against the row's pinned revoke
3475 /// path (see [`OAuthProviderParams::revoke_path`]): a failed or unreachable
3476 /// revoke must never stop the local credentials from being removed. xAI
3477 /// revokes locally only. External CLI consent is left untouched.
3478 pub fn revoke_owned_login(
3479 provider: OAuthProvider,
3480 config_path: Option<&Path>,
3481 live_config: Option<&mut Config>,
3482 ) -> Result<()> {
3483 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
3484 revoke_owned_login_locked(provider, config_path, live_config, store)
3485 })
3486 }
3487
3488 fn revoke_owned_login_locked(
3489 provider: OAuthProvider,
3490 config_path: Option<&Path>,
3491 live_config: Option<&mut Config>,
3492 store: &codewhale_config::XaiOAuthCredentialStore,
3493 ) -> Result<()> {
3494 revoke_owned_login_locked_with(
3495 provider,
3496 config_path,
3497 live_config,
3498 store,
3499 &ReqwestOAuthFormClient,
3500 )
3501 }
3502
3503 fn revoke_owned_login_locked_with(
3504 provider: OAuthProvider,
3505 config_path: Option<&Path>,
3506 live_config: Option<&mut Config>,
3507 store: &codewhale_config::XaiOAuthCredentialStore,
3508 client: &dyn OAuthFormClient,
3509 ) -> Result<()> {
3510 let config_path = crate::config_persistence::config_toml_path(config_path)?;
3511 let key_inside = provider.config_key();
3512 let previous = codewhale_config::mutate_config_document(&config_path, |document| {
3513 let previous = document
3514 .get("providers")
3515 .and_then(toml_edit::Item::as_table_like)
3516 .and_then(|providers| providers.get(key_inside))
3517 .and_then(toml_edit::Item::as_table_like)
3518 .and_then(|provider| provider.get("oauth_credential_generation"))
3519 .and_then(toml_edit::Item::as_str)
3520 .map(ToOwned::to_owned);
3521 codewhale_config::unset_config_document_value(
3522 document,
3523 &["providers", key_inside, "oauth_credential_generation"],
3524 )?;
3525 let auth_mode_is_oauth = document
3526 .get("providers")
3527 .and_then(toml_edit::Item::as_table_like)
3528 .and_then(|providers| providers.get(key_inside))
3529 .and_then(toml_edit::Item::as_table_like)
3530 .and_then(|provider| provider.get("auth_mode"))
3531 .and_then(toml_edit::Item::as_str)
3532 == Some("oauth");
3533 if auth_mode_is_oauth {
3534 codewhale_config::unset_config_document_value(
3535 document,
3536 &["providers", key_inside, "auth_mode"],
3537 )?;
3538 }
3539 Ok(previous)
3540 })?;
3541 let live_config_clear = match live_config {
3542 Some(config) if provider == OAuthProvider::Chatgpt => {
3543 config.clear_codewhale_owned_chatgpt_oauth()
3544 }
3545 _ => Ok(()),
3546 };
3547 let names = match previous.as_deref() {
3548 Some(generation) if provider.is_valid_generation(generation) => {
3549 vec![generation.to_string()]
3550 }
3551 _ => vec![provider.legacy_file_name().to_string()],
3552 };
3553 let mut remote_unconfirmed = false;
3554 for name in names {
3555 if let Ok(Some(raw)) = store.read_to_string(&name)
3556 && let Ok(file) = parse_auth_file(&raw, &store.path_for(&name)?)
3557 {
3558 for entry in file.values() {
3559 if let Some(token) = entry
3560 .refresh_token
3561 .as_deref()
3562 .or(entry.access_token.as_deref())
3563 .filter(|token| !token.trim().is_empty())
3564 {
3565 let issuer = entry
3566 .oidc_issuer
3567 .as_deref()
3568 .unwrap_or_else(|| oauth_provider_params(provider).default_issuer);
3569 let client_id = entry
3570 .oidc_client_id
3571 .as_deref()
3572 .unwrap_or_else(|| oauth_provider_params(provider).default_client_id);
3573 if let Err(error) = revoke_remote_token_via(
3574 client,
3575 oauth_provider_params(provider),
3576 issuer,
3577 client_id,
3578 token,
3579 ) {
3580 remote_unconfirmed |= provider == OAuthProvider::Chatgpt;
3581 tracing::warn!(
3582 target: "codewhale::oauth",
3583 error = %error,
3584 "remote OAuth revoke failed; local credentials will still be removed"
3585 );
3586 }
3587 }
3588 }
3589 }
3590 store
3591 .remove(&name)
3592 .context("OAuth sign-out could not clear local credential storage")?;
3593 }
3594 // A refused live mirror must not leave durable tokens behind. Report it
3595 // only after local removal and preserve an unconfirmed remote outcome.
3596 if remote_unconfirmed {
3597 return live_config_clear.context(
3598 "Signed out locally, but the live route could not be refreshed and remote revocation was not confirmed.",
3599 ).and_then(|()| anyhow::bail!(
3600 "Signed out locally, but remote revocation was not confirmed. Disconnect Codewhale in ChatGPT Settings to end the renewable session."
3601 ));
3602 }
3603 live_config_clear.context("Signed out locally, but the live route could not be refreshed")?;
3604 Ok(())
3605 }
3606
3607 /// Detect the [#5032] bricked-launch state: the provider's config selects
3608 /// OAuth and points `oauth_credential_generation` at a Codewhale-owned
3609 /// credential file that no longer exists. This is a distinct, more specific
3610 /// failure than "unconfigured" — the pointer is present and authoritative,
3611 /// so [`credentials_valid`] returns false and cannot fall through to a
3612 /// legacy or external credential, which is exactly what bricked the
3613 /// dogfood machine.
3614 ///
3615 /// Returns false for any other state: OAuth not selected, no generation
3616 /// configured, a malformed generation pointer (a different, already
3617 /// fail-closed failure), or a generation whose owned file is present.
3618 ///
3619 /// [#5032]: https://github.com/codewhale-hq/CodeWhale/issues/5032
3620 #[must_use]
3621 pub fn owned_generation_is_dangling(provider: OAuthProvider, config: &Config) -> bool {
3622 let Ok(identity) = config.builtin_provider_identity(provider.api()) else {
3623 return false;
3624 };
3625 if provider == OAuthProvider::Xai
3626 && !config
3627 .provider_config_for(&identity)
3628 .and_then(|entry| entry.auth_mode.as_deref())
3629 .is_some_and(auth_mode_uses_xai_oauth)
3630 {
3631 return false;
3632 }
3633 match configured_owned_auth_file_path(provider, config) {
3634 Ok(Some(path)) => !path.exists(),
3635 // `None` => no generation configured (not a dangling pointer). `Err` =>
3636 // the generation is malformed/invalid; that is a different, already
3637 // fail-closed failure, not the missing-file state this detects.
3638 _ => false,
3639 }
3640 }
3641
3642 /// Best-effort repair for the [#5032] bricked-launch state: remove the stale
3643 /// `oauth_credential_generation` pointer from the PERSISTED config file so
3644 /// the next launch is no longer bricked. Mirrors the document edits in
3645 /// [`activate_login_locked`] (which replaces the pointer under the config
3646 /// lock) and [`crate::config::clear_api_key`]'s unlocked scrub.
3647 ///
3648 /// Leaves `auth_mode = "oauth"` intact: the user still wants OAuth, they
3649 /// simply need to re-authenticate. The launch-path caller must treat any
3650 /// error as non-fatal — log a warning and continue. Returns `Ok(())` when
3651 /// the stale pointer was removed (or was already absent).
3652 ///
3653 /// [#5032]: https://github.com/codewhale-hq/CodeWhale/issues/5032
3654 pub fn clear_dangling_generation(
3655 provider: OAuthProvider,
3656 config_path: Option<&Path>,
3657 ) -> Result<()> {
3658 let config_path = crate::config_persistence::config_toml_path(config_path)?;
3659 let key_inside = provider.config_key();
3660 codewhale_config::mutate_config_document(&config_path, |document| {
3661 codewhale_config::unset_config_document_value(
3662 document,
3663 &["providers", key_inside, "oauth_credential_generation"],
3664 )?;
3665 Ok(())
3666 })
3667 }
3668
3669 /// Whether `[providers.xai] auth_mode` selects the OAuth path.
3670 #[must_use]
3671 pub fn auth_mode_uses_xai_oauth(mode: &str) -> bool {
3672 matches!(
3673 normalize_auth_mode(mode).as_str(),
3674 "oauth"
3675 | "xai_oauth"
3676 | "xai"
3677 | "grok"
3678 | "grok_oauth"
3679 | "grok_cli"
3680 | "device"
3681 | "device_code"
3682 | "device_auth"
3683 )
3684 }
3685
3686 fn normalize_auth_mode(mode: &str) -> String {
3687 mode.trim().to_ascii_lowercase().replace(['-', ' '], "_")
3688 }
3689
3690 /// Resolve the Grok CLI auth file path.
3691 ///
3692 /// Priority:
3693 /// 1. `GROK_AUTH_PATH` / `XAI_AUTH_PATH`
3694 /// 2. `$GROK_HOME/auth.json`
3695 /// 3. `~/.grok/auth.json`
3696 #[must_use]
3697 pub fn grok_auth_file_path() -> PathBuf {
3698 for key in ["GROK_AUTH_PATH", "XAI_AUTH_PATH"] {
3699 if let Ok(path) = std::env::var(key) {
3700 let p = PathBuf::from(path.trim());
3701 if !p.as_os_str().is_empty() {
3702 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
3703 }
3704 }
3705 }
3706 if let Ok(home) = std::env::var("GROK_HOME") {
3707 let p = PathBuf::from(home.trim());
3708 if !p.as_os_str().is_empty() {
3709 let path = p.join("auth.json");
3710 return codewhale_config::resolve_external_credential_path(&path).unwrap_or(path);
3711 }
3712 }
3713 let path = crate::config::effective_home_dir()
3714 .unwrap_or_else(|| PathBuf::from("."))
3715 .join(".grok")
3716 .join("auth.json");
3717 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
3718 }
3719
3720 #[must_use]
3721 pub fn missing_auth_message(provider: OAuthProvider) -> String {
3722 match provider {
3723 OAuthProvider::Xai => format!(
3724 "xAI OAuth credentials not found.\n\
3725 Options:\n\
3726 1. Run `codewhale auth xai-device` for Codewhale-owned OAuth storage\n\
3727 2. To read an existing Grok CLI login without changing it, run \
3728 `codewhale auth external-consent --provider xai --mode read-only --path {}`\n\
3729 3. Or use API-key auth: export XAI_API_KEY=... / \
3730 codewhale auth set --provider xai",
3731 codewhale_config::quote_os_path(&grok_auth_file_path())
3732 ),
3733 OAuthProvider::Chatgpt => "Official ChatGPT credentials are unavailable.
3734 \
3735 Run `codewhale auth chatgpt` or /provider setup openai-codex.
3736 \
3737 Authorize ChatGPT plan usage to use your plan allowance.
3738 \
3739 Revoke Codewhale-owned tokens with `codewhale auth chatgpt-revoke`.
3740 \
3741 The openai API-key route uses separate API billing."
3742 .to_string(),
3743 }
3744 }
3745
3746 /// Pending-login test constructor shared by the activation tests.
3747 #[cfg(test)]
3748 pub(crate) fn pending_login_for_test(
3749 provider: OAuthProvider,
3750 access_token: &str,
3751 refresh_token: &str,
3752 ) -> PendingOAuthLogin {
3753 pending_login_with_id_token_for_test(provider, access_token, refresh_token, None)
3754 }
3755
3756 #[cfg(test)]
3757 pub(crate) fn pending_login_with_id_token_for_test(
3758 provider: OAuthProvider,
3759 access_token: &str,
3760 refresh_token: &str,
3761 id_token: Option<&str>,
3762 ) -> PendingOAuthLogin {
3763 let registration = (provider == OAuthProvider::Chatgpt).then(|| ChatgptRegistration {
3764 issuer: CHATGPT_OAUTH_ISSUER.to_string(),
3765 client_id: "oaiapp_codewhale_test".to_string(),
3766 subject: id_token
3767 .and_then(account_id_from_id_token)
3768 .unwrap_or_else(|| "test-sub".to_string()),
3769 email: None,
3770 host_id: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
3771 });
3772 PendingOAuthLogin {
3773 provider,
3774 issuer: oauth_provider_params(provider).default_issuer.to_string(),
3775 client_id: registration.as_ref().map_or_else(
3776 || {
3777 oauth_provider_params(provider)
3778 .default_client_id
3779 .to_string()
3780 },
3781 |registration| registration.client_id.clone(),
3782 ),
3783 token: OAuthTokenMaterial {
3784 earliest_refresh_at: None,
3785 scope: registration
3786 .as_ref()
3787 .map(|_| CHATGPT_OAUTH_SCOPE.to_string()),
3788 token_type: Some("Bearer".to_string()),
3789 verified_chatgpt: registration,
3790 access_token: Some(access_token.to_string()),
3791 refresh_token: Some(refresh_token.to_string()),
3792 expires_in: Some(3600),
3793 id_token: id_token.map(ToOwned::to_owned),
3794 interval: None,
3795 error: None,
3796 error_description: None,
3797 },
3798 }
3799 }
3800
3801 /// Local stored-proof fixture only; signature verification has separate tests.
3802 /// The caller must isolate CODEWHALE_HOME before using this helper.
3803 #[cfg(test)]
3804 pub(crate) fn install_test_chatgpt_registration(config: &mut Config) -> Result<String> {
3805 install_test_chatgpt_registration_for(config, "test-sub", "oaiapp_codewhale_test")
3806 }
3807
3808 #[cfg(test)]
3809 pub(crate) fn install_test_chatgpt_registration_for(
3810 config: &mut Config,
3811 subject: &str,
3812 client_id: &str,
3813 ) -> Result<String> {
3814 let mut pending = pending_login_for_test(
3815 OAuthProvider::Chatgpt,
3816 "siwc-test-access",
3817 "siwc-test-refresh",
3818 );
3819 pending.client_id = client_id.to_string();
3820 let registration = pending
3821 .token
3822 .verified_chatgpt
3823 .as_mut()
3824 .expect("fixture registration");
3825 registration.subject = subject.to_string();
3826 registration.client_id = client_id.to_string();
3827 let generation = OAuthProvider::Chatgpt.new_generation();
3828 let mut entry = OwnedAuthEntry {
3829 access_token: None,
3830 refresh_token: None,
3831 expires_at: None,
3832 id_token: None,
3833 account_id: None,
3834 oidc_issuer: None,
3835 oidc_client_id: None,
3836 originator: None,
3837 auth_mode: None,
3838 extra: BTreeMap::new(),
3839 };
3840 apply_token_response(
3841 OAuthProvider::Chatgpt,
3842 &mut entry,
3843 &pending.issuer,
3844 &pending.client_id,
3845 &pending.token,
3846 )?;
3847 let file = BTreeMap::from([(format!("{}::{}", pending.issuer, pending.client_id), entry)]);
3848 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
3849 write_auth_file_to_store(store, &generation, &file, false)
3850 })?;
3851 config.mark_codewhale_owned_chatgpt_oauth(generation)?;
3852 Ok("siwc-test-access".to_string())
3853 }
3854
3855 #[cfg(test)]
3856 mod tests {
3857 use super::*;
3858 use crate::config::ProviderKind;
3859 use std::fs;
3860 #[cfg(unix)]
3861 use std::os::unix::fs::PermissionsExt;
3862
3863 #[test]
3864 fn oauth_endpoint_requires_https_or_parsed_loopback_http() {
3865 for raw in [
3866 "https://issuer.example/token",
3867 "http://localhost:8123/token",
3868 "http://127.0.0.1:8123/token",
3869 "http://127.0.0.2/token",
3870 "http://[::1]:8123/token",
3871 ] {
3872 assert!(oauth_endpoint_url(raw).is_ok());
3873 }
3874 for raw in [
3875 "http://issuer.example/token",
3876 "http://localhost.example/token",
3877 "http://127.0.0.1.example/token",
3878 "http://192.0.2.1/token",
3879 "https://user:example@issuer.example/token",
3880 "file:///tmp/token",
3881 "not a URL",
3882 ] {
3883 assert!(oauth_endpoint_url(raw).is_err());
3884 }
3885 }
3886
3887 #[test]
3888 fn oauth_discovery_rejects_an_initial_plaintext_remote_issuer() {
3889 let issuer = "http://issuer.example";
3890 assert!(validate_discovered_issuer(Some(issuer.into()), issuer).is_err());
3891 assert!(
3892 validate_discovered_oauth_endpoint(
3893 Some(format!("{issuer}/token")),
3894 "token_endpoint",
3895 issuer,
3896 )
3897 .is_err()
3898 );
3899 let fallback = fallback_oauth_endpoints(&XAI_OAUTH_PARAMS, issuer);
3900 assert!(oauth_endpoint_url(&fallback.token_endpoint).is_err());
3901 assert!(
3902 oauth_endpoint_url(fallback.device_authorization_endpoint.as_deref().unwrap()).is_err()
3903 );
3904 }
3905
3906 #[test]
3907 fn oauth_authorize_url_refuses_plaintext_remote_issuer_before_browser_use() {
3908 let pkce = PkceChallenge {
3909 verifier: "test-verifier".into(),
3910 challenge: "test-challenge".into(),
3911 };
3912 for issuer in [
3913 "http://issuer.example",
3914 "https://user:example@issuer.example",
3915 ] {
3916 assert!(
3917 build_authorize_url(
3918 &CHATGPT_OAUTH_PARAMS,
3919 issuer,
3920 "test-client",
3921 "openid",
3922 "http://localhost:1455/auth/callback",
3923 "test-state",
3924 &pkce,
3925 )
3926 .is_err()
3927 );
3928 }
3929 }
3930
3931 fn grant(path: &std::path::Path) -> ExternalCredentialReadGrant {
3932 codewhale_config::ExternalCredentialConsentToml::read_only(
3933 codewhale_config::ProviderKind::OpenaiCodex,
3934 codewhale_config::ExternalCredentialSource::CodexCli,
3935 path.to_path_buf(),
3936 )
3937 .read_grant(
3938 codewhale_config::ProviderKind::OpenaiCodex,
3939 codewhale_config::ExternalCredentialSource::CodexCli,
3940 path,
3941 )
3942 .expect("test read grant")
3943 }
3944
3945 #[test]
3946 fn jwt_expiry_parses_valid_token() {
3947 // A minimal JWT with {"exp": 9999999999} as payload.
3948 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
3949 let token = format!("header.{payload}.signature");
3950 assert_eq!(jwt_expiry_seconds(&token), Some(9999999999));
3951 }
3952
3953 #[test]
3954 fn jwt_expiry_returns_none_for_malformed() {
3955 assert_eq!(jwt_expiry_seconds("not.a.jwt"), None);
3956 assert_eq!(jwt_expiry_seconds(""), None);
3957 assert_eq!(jwt_expiry_seconds("x"), None);
3958 }
3959
3960 #[test]
3961 fn token_is_expired_detects_future() {
3962 // Far future — should not be expired.
3963 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
3964 let token = format!("header.{payload}.sig");
3965 assert!(!token_is_expired(&token));
3966 }
3967
3968 #[test]
3969 fn token_is_expired_detects_past() {
3970 // Way in the past.
3971 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
3972 let token = format!("header.{payload}.sig");
3973 assert!(token_is_expired(&token));
3974 }
3975
3976 #[test]
3977 fn owned_token_freshness_honors_both_expiries_and_preserves_fallbacks() {
3978 let now = now_unix_secs().expect("clock");
3979 let future = rfc3339_from_unix(now + 3600);
3980 let past = rfc3339_from_unix(now - 3600);
3981 let near = rfc3339_from_unix(now + 30);
3982 let fresh_token = jwt_with_exp((now + 3600) as u64);
3983 let expired_token = jwt_with_exp((now - 3600) as u64);
3984 let near_token = jwt_with_exp((now + 30) as u64);
3985
3986 for (stored, token, expected) in [
3987 (Some(future.as_str()), expired_token.as_str(), false),
3988 (Some(past.as_str()), fresh_token.as_str(), false),
3989 (Some(future.as_str()), fresh_token.as_str(), true),
3990 (Some(future.as_str()), near_token.as_str(), false),
3991 (Some(near.as_str()), fresh_token.as_str(), false),
3992 (None, fresh_token.as_str(), true),
3993 (None, expired_token.as_str(), false),
3994 (Some("invalid-date"), fresh_token.as_str(), true),
3995 (Some(future.as_str()), "opaque-token", true),
3996 (Some(past.as_str()), "opaque-token", false),
3997 (None, "opaque-token", false),
3998 (Some("invalid-date"), "opaque-token", false),
3999 (Some(future.as_str()), "", false),
4000 ] {
4001 let entry: OwnedAuthEntry = serde_json::from_value(serde_json::json!({
4002 "access_token": token,
4003 "expires_at": stored,
4004 }))
4005 .expect("synthetic owned entry");
4006 assert_eq!(
4007 entry_access_token_is_fresh(&entry),
4008 expected,
4009 "stored={stored:?}, JWT expiry={:?}",
4010 jwt_expiry_seconds(token),
4011 );
4012 }
4013 }
4014
4015 #[test]
4016 fn credential_presence_rejects_empty_and_malformed_files_without_refresh() {
4017 let _lock = crate::test_support::lock_test_env();
4018 let home = tempfile::tempdir().expect("temp Codex home");
4019 let auth_path = home
4020 .path()
4021 .canonicalize()
4022 .expect("canonical temp root")
4023 .join("auth.json");
4024 let _auth = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &auth_path);
4025 let _access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
4026 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
4027 let grant = grant(&auth_path);
4028
4029 std::fs::write(&auth_path, "{}").expect("empty auth");
4030 crate::external_credentials::reset_side_effect_trap();
4031 assert!(!stored_credentials_present(&grant));
4032 assert_eq!(
4033 crate::external_credentials::side_effect_trap_counts(),
4034 (1, 1)
4035 );
4036 assert_eq!(
4037 crate::external_credentials::complete_side_effect_trap_counts(),
4038 (1, 1, 0, 0, 0)
4039 );
4040 std::fs::write(&auth_path, "{not-json").expect("malformed auth");
4041 crate::external_credentials::reset_side_effect_trap();
4042 assert!(!stored_credentials_present(&grant));
4043 assert_eq!(
4044 crate::external_credentials::side_effect_trap_counts(),
4045 (1, 1)
4046 );
4047
4048 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
4049 let access_token = format!("header.{payload}.signature");
4050 std::fs::write(
4051 &auth_path,
4052 serde_json::to_vec(&serde_json::json!({
4053 "tokens": {"access_token": access_token}
4054 }))
4055 .expect("valid auth json"),
4056 )
4057 .expect("valid auth");
4058 crate::external_credentials::reset_side_effect_trap();
4059 assert!(stored_credentials_present(&grant));
4060 assert_eq!(
4061 crate::external_credentials::side_effect_trap_counts(),
4062 (1, 1)
4063 );
4064 }
4065
4066 #[test]
4067 fn expired_external_token_fails_without_refresh_or_rewrite() {
4068 let _lock = crate::test_support::lock_test_env();
4069 let home = tempfile::tempdir().expect("temp Codex home");
4070 let auth_path = home
4071 .path()
4072 .canonicalize()
4073 .expect("canonical temp root")
4074 .join("auth.json");
4075 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
4076 let access_token = format!("header.{payload}.signature");
4077 let raw = serde_json::to_string_pretty(&serde_json::json!({
4078 "tokens": {
4079 "access_token": access_token,
4080 "refresh_token": "must-never-be-used",
4081 "account_id": "acct-test",
4082 "future_field": {"preserve": true}
4083 },
4084 "future_top_level": [1, 2, 3]
4085 }))
4086 .expect("auth fixture");
4087 std::fs::write(&auth_path, &raw).expect("expired auth fixture");
4088
4089 crate::external_credentials::reset_side_effect_trap();
4090 let error = get_credentials(&grant(&auth_path))
4091 .expect_err("read-only external tokens must not refresh");
4092 assert!(error.to_string().contains("never refreshes or rewrites"));
4093 assert_eq!(
4094 crate::external_credentials::side_effect_trap_counts(),
4095 (1, 1)
4096 );
4097 assert_eq!(
4098 std::fs::read_to_string(&auth_path).expect("unchanged auth file"),
4099 raw
4100 );
4101 }
4102
4103 #[test]
4104 fn auth_file_path_respects_env() {
4105 // Just verify it returns a path without panicking.
4106 let path = auth_file_path();
4107 assert!(path.to_string_lossy().contains("auth.json"));
4108 }
4109
4110 #[test]
4111 fn missing_auth_message_guides_official_sign_in() {
4112 let message = missing_auth_message(OAuthProvider::Chatgpt);
4113 assert!(message.contains("Official ChatGPT credentials"));
4114 assert!(message.contains("codewhale auth chatgpt"));
4115 assert!(message.contains("ChatGPT plan usage"));
4116 assert!(message.contains("openai API-key"));
4117 assert!(message.contains("chatgpt-revoke"));
4118 assert!(!message.contains("external-consent"));
4119 assert!(!message.contains("CODEX_ACCESS_TOKEN"));
4120 }
4121
4122 #[test]
4123 fn provider_table_separates_device_flow_from_browser_flow() {
4124 let xai = oauth_provider_params(OAuthProvider::Xai);
4125 assert_eq!(xai.device_code_path, Some("oauth2/device/code"));
4126 assert!(xai.discover_endpoints);
4127 let chatgpt = oauth_provider_params(OAuthProvider::Chatgpt);
4128 assert_eq!(chatgpt.device_code_path, None);
4129 assert!(!chatgpt.discover_endpoints);
4130 assert_ne!(xai.default_client_id, chatgpt.default_client_id);
4131 }
4132
4133 #[test]
4134 fn provider_inputs_resolve_from_defaults_without_env() {
4135 let _lock = crate::test_support::lock_test_env();
4136 let _guards: Vec<_> = [
4137 "GROK_OIDC_ISSUER",
4138 "XAI_OIDC_ISSUER",
4139 "GROK_OIDC_CLIENT_ID",
4140 "XAI_OIDC_CLIENT_ID",
4141 "GROK_OIDC_SCOPES",
4142 "XAI_OIDC_SCOPES",
4143 "CODEWHALE_XAI_OAUTH_NO_BROWSER",
4144 ]
4145 .into_iter()
4146 .map(crate::test_support::EnvVarGuard::remove)
4147 .collect();
4148 let inputs = XAI_OAUTH_PARAMS.resolve_inputs();
4149 assert_eq!(inputs.issuer, "https://auth.x.ai");
4150 assert!(inputs.scopes.contains("grok-cli:access"));
4151 assert!(inputs.open_browser);
4152 }
4153
4154 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4155 async fn request_device_grant_round_trips_a_mock_grant() {
4156 use wiremock::matchers::{method, path};
4157 use wiremock::{Mock, MockServer, ResponseTemplate};
4158 let server = MockServer::start().await;
4159 Mock::given(method("POST"))
4160 .and(path("/oauth2/device/code"))
4161 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4162 "device_code": "device-123",
4163 "user_code": "USER-456",
4164 "verification_uri": "https://example.com/device",
4165 "expires_in": 900
4166 })))
4167 .expect(1)
4168 .mount(&server)
4169 .await;
4170 let grant = tokio::task::block_in_place(|| {
4171 request_device_grant(
4172 &format!("{}/oauth2/device/code", server.uri()),
4173 "test-client",
4174 "openid",
4175 )
4176 })
4177 .expect("mock grant");
4178 assert_eq!(grant.device_code.as_deref(), Some("device-123"));
4179 assert_eq!(grant.user_code.as_deref(), Some("USER-456"));
4180 }
4181
4182 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4183 async fn request_device_grant_rejects_success_without_codes() {
4184 use wiremock::matchers::{method, path};
4185 use wiremock::{Mock, MockServer, ResponseTemplate};
4186 let server = MockServer::start().await;
4187 Mock::given(method("POST"))
4188 .and(path("/oauth2/device/code"))
4189 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
4190 .expect(1)
4191 .mount(&server)
4192 .await;
4193 let result = tokio::task::block_in_place(|| {
4194 request_device_grant(
4195 &format!("{}/oauth2/device/code", server.uri()),
4196 "test-client",
4197 "openid",
4198 )
4199 });
4200 let Err(error) = result else {
4201 panic!("a grant without codes must fail");
4202 };
4203 assert!(error.to_string().contains("without a device and user code"));
4204 }
4205
4206 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4207 async fn poll_device_grant_classifies_rfc8628_states() {
4208 use codewhale_config::device_code::DevicePollOutcome;
4209 use wiremock::matchers::{method, path};
4210 use wiremock::{Mock, MockServer, ResponseTemplate};
4211 async fn outcome(
4212 body: serde_json::Value,
4213 status: u16,
4214 ) -> Result<DevicePollOutcome<OAuthTokenMaterial>> {
4215 let server = MockServer::start().await;
4216 Mock::given(method("POST"))
4217 .and(path("/oauth2/token"))
4218 .respond_with(ResponseTemplate::new(status).set_body_json(body))
4219 .expect(1)
4220 .mount(&server)
4221 .await;
4222 tokio::task::block_in_place(|| {
4223 poll_device_grant(
4224 &format!("{}/oauth2/token", server.uri()),
4225 "test-client",
4226 "device-token",
4227 )
4228 })
4229 }
4230 assert!(matches!(
4231 outcome(serde_json::json!({ "error": "authorization_pending" }), 400).await,
4232 Ok(DevicePollOutcome::Pending)
4233 ));
4234 assert!(matches!(
4235 outcome(
4236 serde_json::json!({ "error": "slow_down", "interval": 12 }),
4237 400
4238 )
4239 .await,
4240 Ok(DevicePollOutcome::SlowDown {
4241 interval_seconds: Some(12)
4242 })
4243 ));
4244 for error in ["access_denied", "expired_token"] {
4245 let result = outcome(serde_json::json!({ "error": error }), 400).await;
4246 let Err(failure) = result else {
4247 panic!("{error} must stop polling");
4248 };
4249 assert!(failure.to_string().contains(error), "{failure}");
4250 }
4251 let result = outcome(
4252 serde_json::json!({ "access_token": "at", "expires_in": 3600 }),
4253 200,
4254 )
4255 .await;
4256 let Ok(DevicePollOutcome::Complete(material)) = result else {
4257 panic!("success must complete");
4258 };
4259 assert_eq!(material.access_token.as_deref(), Some("at"));
4260 }
4261
4262 #[test]
4263 fn oauth_challenges_require_a_terminal_and_avoid_redirected_streams() {
4264 assert_eq!(
4265 oauth_challenge_stream(true, false).unwrap(),
4266 OAuthChallengeStream::Stderr
4267 );
4268 assert_eq!(
4269 oauth_challenge_stream(true, true).unwrap(),
4270 OAuthChallengeStream::Stderr
4271 );
4272 assert_eq!(
4273 oauth_challenge_stream(false, true).unwrap(),
4274 OAuthChallengeStream::Stdout
4275 );
4276 let error = oauth_challenge_stream(false, false).unwrap_err();
4277 assert!(error.to_string().contains("requires a terminal"));
4278 assert!(!error.to_string().contains("token"));
4279 }
4280
4281 #[tokio::test]
4282 async fn device_login_without_a_device_flow_fails_before_network() {
4283 let result = device_code_login(OAuthProvider::Chatgpt).await;
4284 let Err(error) = result else {
4285 panic!("ChatGPT has no device flow");
4286 };
4287 assert!(
4288 error.to_string().contains("no device-code flow"),
4289 "{error:#}"
4290 );
4291 }
4292
4293 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4294 async fn oauth_transports_never_forward_forms_to_redirect_destinations() {
4295 use wiremock::matchers::{method, path};
4296 use wiremock::{Mock, MockServer, ResponseTemplate};
4297
4298 let issuer = MockServer::start().await;
4299 let destination = MockServer::start().await;
4300 Mock::given(method("POST"))
4301 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4302 "access_token": "synthetic-access",
4303 })))
4304 .expect(0)
4305 .mount(&destination)
4306 .await;
4307 for status in [301, 302, 303, 307, 308] {
4308 let endpoint = format!("{}/redirect-{status}", issuer.uri());
4309 Mock::given(path(format!("/redirect-{status}")))
4310 .respond_with(
4311 ResponseTemplate::new(status)
4312 .insert_header("Location", format!("{}/token", destination.uri()))
4313 .set_body_json(serde_json::json!({})),
4314 )
4315 .expect(3)
4316 .mount(&issuer)
4317 .await;
4318 tokio::task::block_in_place(|| {
4319 assert!(request_device_grant(&endpoint, "synthetic-client", "scope").is_err());
4320 assert!(
4321 poll_device_grant(&endpoint, "synthetic-client", "synthetic-device").is_err()
4322 );
4323 let (actual_status, _) = ReqwestOAuthFormClient
4324 .post_form(&endpoint, &[("refresh_token", "synthetic-refresh")])
4325 .unwrap();
4326 assert_eq!(actual_status, status);
4327 });
4328 }
4329 // An explicitly selected issuer remains usable without a redirect.
4330 Mock::given(path("/token"))
4331 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4332 "device_code": "synthetic-device",
4333 "user_code": "synthetic-user",
4334 "access_token": "synthetic-access",
4335 })))
4336 .expect(3)
4337 .mount(&issuer)
4338 .await;
4339 let endpoint = format!("{}/token", issuer.uri());
4340 tokio::task::block_in_place(|| {
4341 assert!(request_device_grant(&endpoint, "synthetic-client", "scope").is_ok());
4342 assert!(poll_device_grant(&endpoint, "synthetic-client", "synthetic-device").is_ok());
4343 assert_eq!(
4344 ReqwestOAuthFormClient
4345 .post_form(&endpoint, &[("refresh_token", "synthetic-refresh")])
4346 .unwrap()
4347 .0,
4348 200
4349 );
4350 });
4351 assert!(destination.received_requests().await.unwrap().is_empty());
4352 }
4353
4354 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4355 async fn discovery_honors_advertised_endpoints() {
4356 use wiremock::matchers::{method, path};
4357 use wiremock::{Mock, MockServer, ResponseTemplate};
4358 let server = MockServer::start().await;
4359 Mock::given(method("GET"))
4360 .and(path("/.well-known/openid-configuration"))
4361 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4362 "issuer": server.uri(),
4363 "device_authorization_endpoint": format!("{}/custom/device", server.uri()),
4364 "token_endpoint": format!("{}/custom/token", server.uri()),
4365 })))
4366 .expect(1)
4367 .mount(&server)
4368 .await;
4369 let endpoints = tokio::task::block_in_place(|| {
4370 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
4371 });
4372 assert_eq!(
4373 endpoints.device_authorization_endpoint.as_deref(),
4374 Some(format!("{}/custom/device", server.uri()).as_str())
4375 );
4376 assert_eq!(
4377 endpoints.token_endpoint,
4378 format!("{}/custom/token", server.uri())
4379 );
4380 }
4381
4382 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4383 async fn discovery_issuer_mismatch_falls_back_to_documented_paths() {
4384 use wiremock::matchers::{method, path};
4385 use wiremock::{Mock, MockServer, ResponseTemplate};
4386 let server = MockServer::start().await;
4387 Mock::given(method("GET"))
4388 .and(path("/.well-known/openid-configuration"))
4389 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4390 "issuer": "https://someone-else.example",
4391 "device_authorization_endpoint": "https://someone-else.example/device",
4392 "token_endpoint": "https://someone-else.example/token",
4393 })))
4394 .expect(1)
4395 .mount(&server)
4396 .await;
4397 let endpoints = tokio::task::block_in_place(|| {
4398 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
4399 });
4400 assert_eq!(
4401 endpoints.device_authorization_endpoint.as_deref(),
4402 Some(format!("{}/oauth2/device/code", server.uri()).as_str())
4403 );
4404 assert_eq!(
4405 endpoints.token_endpoint,
4406 format!("{}/oauth2/token", server.uri())
4407 );
4408 }
4409
4410 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4411 async fn device_login_aborts_on_untrusted_verification_uri() {
4412 use wiremock::matchers::{method, path};
4413 use wiremock::{Mock, MockServer, ResponseTemplate};
4414 let server = MockServer::start().await;
4415 Mock::given(method("GET"))
4416 .and(path("/.well-known/openid-configuration"))
4417 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4418 "issuer": server.uri(),
4419 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
4420 "token_endpoint": format!("{}/oauth2/token", server.uri()),
4421 })))
4422 .mount(&server)
4423 .await;
4424 Mock::given(method("POST"))
4425 .and(path("/oauth2/device-advertised"))
4426 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4427 "device_code": "device-token",
4428 "user_code": "CW-TEST",
4429 "verification_uri": "https://auth.x.ai/device",
4430 "verification_uri_complete": "vscode://attacker/run?code=CW-TEST",
4431 "expires_in": 60,
4432 "interval": 1
4433 })))
4434 .expect(1)
4435 .mount(&server)
4436 .await;
4437 // No token-endpoint mock: the flow must fail before it ever polls.
4438 let inputs = ResolvedOAuthInputs {
4439 issuer: server.uri(),
4440 client_id: "test-client".to_string(),
4441 scopes: "openid".to_string(),
4442 open_browser: false,
4443 };
4444 let result = tokio::task::block_in_place(|| {
4445 device_code_login_with(OAuthProvider::Xai, &inputs, &mut std::io::sink())
4446 });
4447 let Err(error) = result else {
4448 panic!("a non-web verification URI must abort login");
4449 };
4450 assert!(
4451 format!("{error:#}").contains("untrusted verification URI"),
4452 "{error:#}"
4453 );
4454 }
4455
4456 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4457 async fn device_login_refuses_terminal_controls_before_display_or_polling() {
4458 use wiremock::matchers::{method, path};
4459 use wiremock::{Mock, MockServer, ResponseTemplate};
4460 let server = MockServer::start().await;
4461 Mock::given(method("GET"))
4462 .and(path("/.well-known/openid-configuration"))
4463 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4464 "issuer": server.uri(),
4465 "device_authorization_endpoint": format!("{}/device", server.uri()),
4466 "token_endpoint": format!("{}/token", server.uri())
4467 })))
4468 .expect(1)
4469 .mount(&server)
4470 .await;
4471 Mock::given(method("POST"))
4472 .and(path("/device"))
4473 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4474 "device_code": "fixture-private-device-code",
4475 "user_code": "CW-\u{1b}]52;clipboard-payload",
4476 "verification_uri": format!("{}/verify", server.uri()),
4477 "expires_in": 60,
4478 "interval": 1
4479 })))
4480 .expect(1)
4481 .mount(&server)
4482 .await;
4483 let inputs = ResolvedOAuthInputs {
4484 issuer: server.uri(),
4485 client_id: "test-client".to_string(),
4486 scopes: "openid".to_string(),
4487 open_browser: false,
4488 };
4489 let mut challenge = Vec::new();
4490 let error = tokio::task::block_in_place(|| {
4491 device_code_login_with(OAuthProvider::Xai, &inputs, &mut challenge)
4492 })
4493 .err()
4494 .expect("terminal controls must be refused");
4495 assert!(error.to_string().contains("invalid user-code display data"));
4496 assert!(challenge.is_empty());
4497 assert!(!error.to_string().contains("clipboard-payload"));
4498 assert_eq!(server.received_requests().await.unwrap().len(), 2);
4499 }
4500
4501 /// Discovery + device grant run on the blocking worker: this fails with
4502 /// the grant refusal, never with a runtime-drop panic.
4503 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4504 async fn device_login_runs_blocking_http_off_the_executor() {
4505 use wiremock::matchers::{method, path};
4506 use wiremock::{Mock, MockServer, ResponseTemplate};
4507 let _lock = crate::test_support::lock_test_env();
4508 let server = MockServer::start().await;
4509 Mock::given(method("GET"))
4510 .and(path("/.well-known/openid-configuration"))
4511 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4512 "issuer": server.uri(),
4513 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
4514 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
4515 })))
4516 .expect(1)
4517 .mount(&server)
4518 .await;
4519 Mock::given(method("POST"))
4520 .and(path("/oauth2/device-advertised"))
4521 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
4522 "error": "invalid_scope",
4523 "error_description": "mock refusal before browser or polling"
4524 })))
4525 .expect(1)
4526 .mount(&server)
4527 .await;
4528 let _issuer =
4529 crate::test_support::EnvVarGuard::set("GROK_OIDC_ISSUER", server.uri().as_str());
4530 let _no_browser =
4531 crate::test_support::EnvVarGuard::set("CODEWHALE_XAI_OAUTH_NO_BROWSER", "1");
4532
4533 let result = device_code_login_on_worker(
4534 OAuthProvider::Xai,
4535 XAI_OAUTH_PARAMS.resolve_inputs(),
4536 Box::new(std::io::sink()),
4537 )
4538 .await;
4539 let Err(error) = result else {
4540 panic!("mock device request must fail without a runtime-drop panic");
4541 };
4542 let message = format!("{error:#}");
4543 assert!(message.contains("invalid_scope"), "{message}");
4544 assert!(message.contains("HTTP 400"), "{message}");
4545 }
4546
4547 /// Full orchestration against mocks: discovery, grant, one poll, done.
4548 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4549 async fn device_login_exchanges_and_returns_token_material() {
4550 use wiremock::matchers::{method, path};
4551 use wiremock::{Mock, MockServer, ResponseTemplate};
4552 let server = MockServer::start().await;
4553 Mock::given(method("GET"))
4554 .and(path("/.well-known/openid-configuration"))
4555 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4556 "issuer": server.uri(),
4557 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
4558 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
4559 })))
4560 .expect(1)
4561 .mount(&server)
4562 .await;
4563 Mock::given(method("POST"))
4564 .and(path("/oauth2/device-advertised"))
4565 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4566 "device_code": "device-token",
4567 "user_code": "CW-TEST",
4568 "verification_uri": format!("{}/verify", server.uri()),
4569 "expires_in": 60,
4570 "interval": 1
4571 })))
4572 .expect(1)
4573 .mount(&server)
4574 .await;
4575 Mock::given(method("POST"))
4576 .and(path("/oauth2/token-advertised"))
4577 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4578 "access_token": "unified-access",
4579 "refresh_token": "unified-refresh",
4580 "expires_in": 3600
4581 })))
4582 .expect(1)
4583 .mount(&server)
4584 .await;
4585 let inputs = ResolvedOAuthInputs {
4586 issuer: server.uri(),
4587 client_id: "test-client".to_string(),
4588 scopes: "openid".to_string(),
4589 open_browser: false,
4590 };
4591 let mut challenge = Vec::new();
4592 let pending = tokio::task::block_in_place(|| {
4593 device_code_login_with(OAuthProvider::Xai, &inputs, &mut challenge)
4594 })
4595 .expect("mock login exchanges");
4596 let challenge = String::from_utf8(challenge).unwrap();
4597 assert!(challenge.contains(&format!("{}/verify", server.uri())));
4598 assert!(challenge.contains("CW-TEST"));
4599 assert!(!challenge.contains("unified-access"));
4600 assert!(!challenge.contains("unified-refresh"));
4601 assert_eq!(pending.issuer, server.uri());
4602 assert_eq!(
4603 pending.token.access_token.as_deref(),
4604 Some("unified-access")
4605 );
4606 assert_eq!(
4607 pending.token.refresh_token.as_deref(),
4608 Some("unified-refresh")
4609 );
4610 }
4611
4612 /// The shared poll loop walks pending and slow_down to completion.
4613 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4614 async fn device_login_polls_through_pending_and_slow_down() {
4615 use codewhale_config::device_code::DeviceCodePoll;
4616 use wiremock::matchers::{method, path};
4617 use wiremock::{Mock, MockServer, ResponseTemplate};
4618 let server = MockServer::start().await;
4619 // wiremock matches mocks in mount order, so mount the one-shot
4620 // transient-error responses before the terminal success response:
4621 // poll 1 -> authorization_pending, poll 2 -> slow_down, poll 3 -> ok.
4622 for (body, status) in [
4623 (serde_json::json!({ "error": "authorization_pending" }), 400),
4624 (serde_json::json!({ "error": "slow_down" }), 400),
4625 ] {
4626 Mock::given(method("POST"))
4627 .and(path("/oauth2/token"))
4628 .respond_with(ResponseTemplate::new(status).set_body_json(body))
4629 .up_to_n_times(1)
4630 .expect(1)
4631 .mount(&server)
4632 .await;
4633 }
4634 Mock::given(method("POST"))
4635 .and(path("/oauth2/token"))
4636 .respond_with(ResponseTemplate::new(200).set_body_json(
4637 serde_json::json!({ "access_token": "loop-access", "expires_in": 3600 }),
4638 ))
4639 .expect(1)
4640 .mount(&server)
4641 .await;
4642 let endpoint = format!("{}/oauth2/token", server.uri());
4643 let material = tokio::task::block_in_place(|| {
4644 DeviceCodePoll::new(
4645 std::time::Duration::from_secs(60),
4646 "mock poll must complete",
4647 )
4648 .run(
4649 |_| {},
4650 || poll_device_grant(&endpoint, "test-client", "device-token"),
4651 )
4652 })
4653 .expect("poll loop completes");
4654 assert!(matches!(
4655 material.access_token.as_deref(),
4656 Some("loop-access")
4657 ));
4658 }
4659
4660 /// A denied grant stops the full login with the server's reason.
4661 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4662 async fn device_login_surfaces_user_denial() {
4663 use wiremock::matchers::{method, path};
4664 use wiremock::{Mock, MockServer, ResponseTemplate};
4665 let server = MockServer::start().await;
4666 Mock::given(method("GET"))
4667 .and(path("/.well-known/openid-configuration"))
4668 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4669 "issuer": server.uri(),
4670 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
4671 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
4672 })))
4673 .mount(&server)
4674 .await;
4675 Mock::given(method("POST"))
4676 .and(path("/oauth2/device-advertised"))
4677 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4678 "device_code": "device-token",
4679 "user_code": "CW-TEST",
4680 "verification_uri": format!("{}/verify", server.uri()),
4681 "expires_in": 60,
4682 "interval": 1
4683 })))
4684 .mount(&server)
4685 .await;
4686 Mock::given(method("POST"))
4687 .and(path("/oauth2/token-advertised"))
4688 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
4689 "error": "access_denied",
4690 "error_description": "The user denied the authorization request"
4691 })))
4692 .expect(1)
4693 .mount(&server)
4694 .await;
4695 let inputs = ResolvedOAuthInputs {
4696 issuer: server.uri(),
4697 client_id: "test-client".to_string(),
4698 scopes: "openid".to_string(),
4699 open_browser: false,
4700 };
4701 let result = tokio::task::block_in_place(|| {
4702 device_code_login_with(OAuthProvider::Xai, &inputs, &mut std::io::sink())
4703 });
4704 let Err(error) = result else {
4705 panic!("user denial must stop the login");
4706 };
4707 let message = format!("{error:#}");
4708 assert!(message.contains("access_denied"), "{message}");
4709 assert!(message.contains("HTTP 400"), "{message}");
4710 }
4711
4712 /// Non-JSON answers name the content type, never the body.
4713 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4714 async fn device_transport_reports_non_json_without_echoing_body() {
4715 use wiremock::matchers::{method, path};
4716 use wiremock::{Mock, MockServer, ResponseTemplate};
4717 let server = MockServer::start().await;
4718 // set_body_bytes carries no implicit content type, so the inserted
4719 // text/html is the only one on the wire (set_body_string would
4720 // stack text/plain next to it and the diagnostic would name both).
4721 Mock::given(method("POST"))
4722 .and(path("/oauth2/device-code"))
4723 .respond_with(
4724 ResponseTemplate::new(200)
4725 .set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
4726 .insert_header("content-type", "text/html"),
4727 )
4728 .expect(1)
4729 .mount(&server)
4730 .await;
4731 Mock::given(method("POST"))
4732 .and(path("/oauth2/token"))
4733 .respond_with(
4734 ResponseTemplate::new(200)
4735 .set_body_bytes("<html>sentinel-body-bytes</html>".as_bytes())
4736 .insert_header("content-type", "text/html"),
4737 )
4738 .expect(1)
4739 .mount(&server)
4740 .await;
4741 let grant = tokio::task::block_in_place(|| {
4742 request_device_grant(
4743 &format!("{}/oauth2/device-code", server.uri()),
4744 "test-client",
4745 "openid",
4746 )
4747 });
4748 let Err(grant_error) = grant else {
4749 panic!("non-JSON grant must fail");
4750 };
4751 let poll = tokio::task::block_in_place(|| {
4752 poll_device_grant(
4753 &format!("{}/oauth2/token", server.uri()),
4754 "test-client",
4755 "device-token",
4756 )
4757 });
4758 let Err(poll_error) = poll else {
4759 panic!("non-JSON poll must fail");
4760 };
4761 for message in [format!("{grant_error:#}"), format!("{poll_error:#}")] {
4762 assert!(message.contains("text/html"), "{message}");
4763 assert!(!message.contains("sentinel-body-bytes"), "{message}");
4764 }
4765 }
4766
4767 /// The wire format is a contract: form-encoded posts carrying the exact
4768 /// client, scope, and grant-type parameters the issuers expect.
4769 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4770 async fn device_transport_posts_exact_oauth_form_parameters() {
4771 use wiremock::matchers::{body_string_contains, header, method, path};
4772 use wiremock::{Mock, MockServer, ResponseTemplate};
4773 let server = MockServer::start().await;
4774 Mock::given(method("GET"))
4775 .and(path("/.well-known/openid-configuration"))
4776 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4777 "issuer": server.uri(),
4778 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
4779 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
4780 })))
4781 .mount(&server)
4782 .await;
4783 Mock::given(method("POST"))
4784 .and(path("/oauth2/device-advertised"))
4785 .and(header("content-type", "application/x-www-form-urlencoded"))
4786 .and(body_string_contains("client_id=test-client"))
4787 .and(body_string_contains("scope=openid"))
4788 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4789 "device_code": "device-token",
4790 "user_code": "CW-TEST",
4791 "verification_uri": format!("{}/verify", server.uri()),
4792 "expires_in": 60,
4793 "interval": 1
4794 })))
4795 .expect(1)
4796 .mount(&server)
4797 .await;
4798 Mock::given(method("POST"))
4799 .and(path("/oauth2/token-advertised"))
4800 .and(header("content-type", "application/x-www-form-urlencoded"))
4801 .and(body_string_contains(
4802 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code",
4803 ))
4804 .and(body_string_contains("client_id=test-client"))
4805 .and(body_string_contains("device_code=device-token"))
4806 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
4807 "access_token": "form-access",
4808 "expires_in": 3600
4809 })))
4810 .expect(1)
4811 .mount(&server)
4812 .await;
4813 let inputs = ResolvedOAuthInputs {
4814 issuer: server.uri(),
4815 client_id: "test-client".to_string(),
4816 scopes: "openid".to_string(),
4817 open_browser: false,
4818 };
4819 let pending = tokio::task::block_in_place(|| {
4820 device_code_login_with(OAuthProvider::Xai, &inputs, &mut std::io::sink())
4821 })
4822 .expect("mock login exchanges");
4823 assert_eq!(pending.token.access_token.as_deref(), Some("form-access"));
4824 }
4825
4826 #[test]
4827 fn access_method_labels_name_every_route() {
4828 assert_eq!(
4829 AccessMethod::OwnedOAuth(OAuthProvider::Xai).label(),
4830 "xAI subscription"
4831 );
4832 assert_eq!(
4833 AccessMethod::ExternalImport(ExternalImportSource::CodexCli).label(),
4834 "Codex CLI import"
4835 );
4836 assert_eq!(AccessMethod::ApiKey.label(), "API key");
4837 assert_eq!(AccessMethod::AcpBridge.label(), "ACP bridge");
4838 }
4839
4840 #[test]
4841 fn malformed_codex_credential_errors_never_echo_file_contents() {
4842 let _lock = crate::test_support::lock_test_env();
4843 let home = tempfile::tempdir().expect("temp Codex home");
4844 let path = home.path().canonicalize().unwrap().join("auth.json");
4845 let sentinel = "must-not-appear-in-diagnostics";
4846 std::fs::write(
4847 &path,
4848 format!(r#"{{"tokens":{{"access_token":{{"secret":"{sentinel}"}}}}}}"#),
4849 )
4850 .unwrap();
4851
4852 let error = load_credentials(&grant(&path)).expect_err("malformed schema");
4853 let message = format!("{error:#}");
4854 assert!(message.contains("not valid credential JSON"), "{message}");
4855 assert!(!message.contains(sentinel), "{message}");
4856 }
4857
4858 // ── unified PKCE core (ported from the deleted chatgpt_oauth flow) ──
4859
4860 use std::sync::Mutex;
4861
4862 type MockForm = Vec<(String, String)>;
4863 type MockPost = (String, MockForm);
4864
4865 struct MockFormClient {
4866 responses: Mutex<Vec<(u16, String)>>,
4867 posts: Mutex<Vec<MockPost>>,
4868 }
4869
4870 impl MockFormClient {
4871 fn new(responses: Vec<(u16, String)>) -> Self {
4872 Self {
4873 responses: Mutex::new(responses),
4874 posts: Mutex::new(Vec::new()),
4875 }
4876 }
4877 }
4878
4879 impl OAuthFormClient for MockFormClient {
4880 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
4881 self.posts.lock().expect("posts").push((
4882 url.to_string(),
4883 form.iter()
4884 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
4885 .collect(),
4886 ));
4887 let mut responses = self.responses.lock().expect("responses");
4888 anyhow::ensure!(
4889 !responses.is_empty(),
4890 "mock issuer has no remaining responses"
4891 );
4892 Ok(responses.remove(0))
4893 }
4894 }
4895
4896 fn jwt_with_account(account: &str) -> String {
4897 let payload = URL_SAFE_NO_PAD.encode(format!(
4898 r#"{{"https://api.openai.com/auth":{{"chatgpt_account_id":"{account}"}}}}"#
4899 ));
4900 format!("header.{payload}.sig")
4901 }
4902
4903 fn chatgpt() -> &'static OAuthProviderParams {
4904 oauth_provider_params(OAuthProvider::Chatgpt)
4905 }
4906
4907 #[test]
4908 fn pkce_verifier_and_challenge_are_s256() {
4909 let pkce = generate_pkce();
4910 assert!(pkce.verifier.len() >= 43);
4911 assert_eq!(
4912 pkce.challenge,
4913 URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier.as_bytes()))
4914 );
4915 let other = generate_pkce();
4916 assert_ne!(pkce.verifier, other.verifier);
4917 assert_ne!(generate_state(), generate_state());
4918 }
4919
4920 #[test]
4921 fn malformed_issuer_fails_loudly_not_to_production() {
4922 let pkce = PkceChallenge {
4923 verifier: "verifier".into(),
4924 challenge: "challenge".into(),
4925 };
4926 let err = build_authorize_url(
4927 chatgpt(),
4928 "not a url \\ ",
4929 "client",
4930 "openid",
4931 "http://localhost:1455/auth/callback",
4932 "state-1",
4933 &pkce,
4934 )
4935 .expect_err("malformed issuer must not produce an authorize URL");
4936 assert!(
4937 format!("{err:#}").contains("CODEWHALE_CHATGPT_OAUTH_ISSUER"),
4938 "{err:#}"
4939 );
4940 }
4941
4942 #[test]
4943 fn authorize_url_is_honest_originator_and_pkce() {
4944 let _lock = crate::test_support::lock_test_env();
4945 let _prompt = crate::test_support::EnvVarGuard::remove("CODEWHALE_CHATGPT_OAUTH_NO_PROMPT");
4946 let pkce = PkceChallenge {
4947 verifier: "verifier".into(),
4948 challenge: "challenge".into(),
4949 };
4950 let url = build_authorize_url(
4951 chatgpt(),
4952 CHATGPT_OAUTH_ISSUER,
4953 CHATGPT_OAUTH_CLIENT_ID,
4954 CHATGPT_OAUTH_SCOPE,
4955 "http://localhost:1455/auth/callback",
4956 "state-1",
4957 &pkce,
4958 )
4959 .expect("static issuer parses");
4960 assert!(url.starts_with("https://auth.openai.com/api/accounts/authorize?"));
4961 assert!(url.contains("code_challenge=challenge"));
4962 assert!(url.contains("code_challenge_method=S256"));
4963 assert!(url.contains("originator=codewhale"));
4964 assert!(!url.contains("codex_cli_rs"));
4965 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
4966 assert!(url.contains("resource=https%3A%2F%2Fapi.openai.com%2Fv1"));
4967 // Account choice: the issuer must re-prompt rather than reuse the
4968 // browser's current ChatGPT session.
4969 let query: BTreeMap<String, String> = reqwest::Url::parse(&url)
4970 .expect("authorize URL parses")
4971 .query_pairs()
4972 .into_owned()
4973 .collect();
4974 assert_eq!(query.get("prompt").map(String::as_str), Some("login"));
4975 assert_eq!(url.matches("prompt=").count(), 1, "{url}");
4976
4977 // Opt-out, should the issuer ever refuse the parameter.
4978 let _opt_out =
4979 crate::test_support::EnvVarGuard::set("CODEWHALE_CHATGPT_OAUTH_NO_PROMPT", "1");
4980 let url = build_authorize_url(
4981 chatgpt(),
4982 CHATGPT_OAUTH_ISSUER,
4983 CHATGPT_OAUTH_CLIENT_ID,
4984 CHATGPT_OAUTH_SCOPE,
4985 "http://localhost:1455/auth/callback",
4986 "state-1",
4987 &pkce,
4988 )
4989 .expect("static issuer parses");
4990 assert!(!url.contains("prompt="), "{url}");
4991 assert!(
4992 url.contains("resource=https%3A%2F%2Fapi.openai.com%2Fv1"),
4993 "{url}"
4994 );
4995 }
4996
4997 fn id_token(claims: &serde_json::Value) -> String {
4998 format!(
4999 "header.{}.sig",
5000 URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims serialize"))
5001 )
5002 }
5003
5004 #[test]
5005 fn account_label_reads_email_and_plan_claims() {
5006 let chatgpt = id_token(&serde_json::json!({
5007 "email": "a@example.com",
5008 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus", "chatgpt_account_id": "acct-1"},
5009 }));
5010 assert_eq!(
5011 account_label_from_id_token(&chatgpt).as_deref(),
5012 Some("a@example.com (plus)")
5013 );
5014 // Profile-namespaced email is the fallback the ChatGPT issuer uses.
5015 let profile = id_token(&serde_json::json!({
5016 "https://api.openai.com/profile": {"email": "b@example.com"},
5017 }));
5018 assert_eq!(
5019 account_label_from_id_token(&profile).as_deref(),
5020 Some("b@example.com")
5021 );
5022 // A workspace plan names its account-id prefix: one email can hold
5023 // several workspaces, and email + plan alone would read the same.
5024 let team = id_token(&serde_json::json!({
5025 "email": "a@corp.com",
5026 "https://api.openai.com/auth": {"chatgpt_plan_type": "team", "chatgpt_account_id": "1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809"},
5027 }));
5028 assert_eq!(
5029 account_label_from_id_token(&team).as_deref(),
5030 Some("a@corp.com (team, workspace 1a2b3c4d)")
5031 );
5032 // xAI: plain OIDC email, no plan claim.
5033 let xai = id_token(&serde_json::json!({"email": "grok@example.com", "sub": "u1"}));
5034 assert_eq!(
5035 account_label_from_id_token(&xai).as_deref(),
5036 Some("grok@example.com")
5037 );
5038 }
5039
5040 #[test]
5041 fn account_label_rejects_malformed_tokens_and_missing_claims() {
5042 for token in [
5043 "",
5044 "not-a-jwt",
5045 "header.%%%.sig",
5046 &format!("header.{}.sig", URL_SAFE_NO_PAD.encode("not json")),
5047 &id_token(&serde_json::json!({"sub": "u1"})),
5048 &id_token(&serde_json::json!({"email": " "})),
5049 &id_token(&serde_json::json!({"email": 42})),
5050 // A plan alone does not identify an account.
5051 &id_token(&serde_json::json!({
5052 "https://api.openai.com/auth": {"chatgpt_plan_type": "pro"},
5053 })),
5054 ] {
5055 assert_eq!(account_label_from_id_token(token), None, "{token}");
5056 }
5057 }
5058
5059 #[test]
5060 fn account_label_strips_control_characters_and_bounds_length() {
5061 let hostile = id_token(&serde_json::json!({
5062 "email": "\u{1b}[31mevil@example.com\u{7}",
5063 "https://api.openai.com/auth": {"chatgpt_plan_type": "x".repeat(500)},
5064 }));
5065 let label = account_label_from_id_token(&hostile).expect("label");
5066 assert!(!label.chars().any(char::is_control), "{label:?}");
5067 assert!(label.starts_with("[31mevil@example.com ("), "{label}");
5068 assert!(label.len() <= ACCOUNT_EMAIL_MAX_CHARS + ACCOUNT_PLAN_MAX_CHARS + 3);
5069 }
5070
5071 #[test]
5072 fn usage_limit_guidance_names_account_and_switch_command() {
5073 let chatgpt = usage_limit_guidance(OAuthProvider::Chatgpt, Some("a@example.com (plus)"));
5074 assert!(
5075 chatgpt.contains("a@example.com (plus)"),
5076 "usage guidance must name the selected account"
5077 );
5078 assert!(
5079 chatgpt.contains("`CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`"),
5080 "usage guidance must name the explicit account replacement command"
5081 );
5082 // A running session does not see a shell login. Returning login
5083 // stays on its verified account; explicit replacement needs a restart.
5084 assert!(
5085 chatgpt.contains("`/auth chatgpt` reauthorizes the selected account"),
5086 "{chatgpt}"
5087 );
5088 assert!(
5089 chatgpt.contains("restart open Codewhale sessions"),
5090 "{chatgpt}"
5091 );
5092 let xai = usage_limit_guidance(OAuthProvider::Xai, None);
5093 assert!(xai.contains("`codewhale auth xai-device`"), "{xai}");
5094 assert!(xai.contains("`/auth xai-device`"), "{xai}");
5095 assert!(!xai.contains("None"), "{xai}");
5096 }
5097
5098 #[test]
5099 fn relogin_with_another_account_replaces_the_owned_entry() {
5100 let _lock = crate::test_support::lock_test_env();
5101 let home = tempfile::tempdir().expect("temp home");
5102 let root = home.path().canonicalize().expect("canonical home");
5103 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5104 let config_path = root.join("config.toml");
5105 std::fs::write(&config_path, "").expect("empty config");
5106 let token_a = id_token(&serde_json::json!({
5107 "email": "a@example.com",
5108 "https://api.openai.com/auth": {"chatgpt_plan_type": "plus", "chatgpt_account_id": "acct-a"},
5109 }));
5110 let first = activate_login(
5111 pending_login_with_id_token_for_test(
5112 OAuthProvider::Chatgpt,
5113 "access-a",
5114 "refresh-a",
5115 Some(&token_a),
5116 ),
5117 Some(&config_path),
5118 None,
5119 )
5120 .expect("first login");
5121 assert_eq!(first.account_label.as_deref(), Some("a@example.com (plus)"));
5122 assert_eq!(first.replaced, None);
5123 assert_eq!(
5124 first.summary(codewhale_localization::Locale::En),
5125 "Signed in to ChatGPT as a@example.com (plus)."
5126 );
5127
5128 // Account B has its own offline grant and no account claim in the
5129 // display token: nothing from account A may survive into B's credential.
5130 let token_b = id_token(&serde_json::json!({"email": "b@example.com"}));
5131 let pending_b = pending_login_with_id_token_for_test(
5132 OAuthProvider::Chatgpt,
5133 "access-b",
5134 "refresh-b",
5135 Some(&token_b),
5136 );
5137 let second = activate_login(pending_b, Some(&config_path), None).expect("second login");
5138 assert_eq!(second.account_label.as_deref(), Some("b@example.com"));
5139 assert_eq!(
5140 second.replaced,
5141 Some(Some("a@example.com (plus)".to_string()))
5142 );
5143 let summary = second.summary(codewhale_localization::Locale::En);
5144 assert_eq!(
5145 summary,
5146 "Signed in to ChatGPT as b@example.com.\nReplaced the previous Codewhale ChatGPT sign-in (a@example.com (plus))."
5147 );
5148 assert!(!summary.contains("access-"), "{summary}");
5149 // The TUI transcript renders the same facts in the UI locale.
5150 let localized = second.summary(codewhale_localization::Locale::Ja);
5151 assert_ne!(localized, summary);
5152 assert!(localized.contains("b@example.com"), "{localized}");
5153 assert!(localized.contains("a@example.com (plus)"), "{localized}");
5154
5155 let persisted = std::fs::read_to_string(&second.auth_path).expect("generation");
5156 assert!(!persisted.contains("refresh-a"), "{persisted}");
5157 assert!(!persisted.contains("acct-a"), "{persisted}");
5158 assert!(!persisted.contains("access-a"), "{persisted}");
5159 let generation = second
5160 .auth_path
5161 .file_name()
5162 .and_then(|name| name.to_str())
5163 .expect("generation name");
5164 assert_eq!(
5165 owned_account_label_for_generation(OAuthProvider::Chatgpt, generation)
5166 .expect("usable sign-in")
5167 .as_deref(),
5168 Some("b@example.com")
5169 );
5170 let mut config = Config::default();
5171 config
5172 .mark_codewhale_owned_chatgpt_oauth(generation.to_string())
5173 .expect("admitted OAuth fixture");
5174 assert_eq!(
5175 usable_sign_in(OAuthProvider::Chatgpt, &config),
5176 Some(UsableSignIn {
5177 account_label: Some("b@example.com".to_string())
5178 })
5179 );
5180 // An invalid generation name never resolves to a path.
5181 assert!(
5182 owned_account_label_for_generation(OAuthProvider::Chatgpt, "../auth.json").is_err()
5183 );
5184 }
5185
5186 /// A login replaces the whole sign-in, not just its own scope: an older
5187 /// account's entry under a differently spelled issuer must neither
5188 /// outrank the new login nor keep its refresh token on disk.
5189 #[test]
5190 fn relogin_drops_other_scopes_and_names_the_account_it_replaced() {
5191 let _lock = crate::test_support::lock_test_env();
5192 let home = tempfile::tempdir().expect("temp home");
5193 let root = home.path().canonicalize().expect("canonical home");
5194 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5195 let config_path = root.join("config.toml");
5196 std::fs::write(&config_path, "").expect("empty config");
5197 let token_a = id_token(&serde_json::json!({"email": "a@example.com"}));
5198 let mut pending_a = pending_login_with_id_token_for_test(
5199 OAuthProvider::Xai,
5200 "access-a",
5201 "refresh-a",
5202 Some(&token_a),
5203 );
5204 // '/' sorts before ':', so this scope comes first in the file.
5205 pending_a.issuer = format!("{XAI_OIDC_ISSUER}/");
5206 activate_login(pending_a, Some(&config_path), None).expect("login A");
5207
5208 let token_b = id_token(&serde_json::json!({"email": "b@example.com"}));
5209 let second = activate_login(
5210 pending_login_with_id_token_for_test(
5211 OAuthProvider::Xai,
5212 "access-b",
5213 "refresh-b",
5214 Some(&token_b),
5215 ),
5216 Some(&config_path),
5217 None,
5218 )
5219 .expect("login B");
5220 assert_eq!(second.replaced, Some(Some("a@example.com".to_string())));
5221 let persisted = std::fs::read_to_string(&second.auth_path).expect("generation");
5222 assert!(!persisted.contains("refresh-a"), "{persisted}");
5223 assert!(!persisted.contains("access-a"), "{persisted}");
5224 let generation = second
5225 .auth_path
5226 .file_name()
5227 .and_then(|name| name.to_str())
5228 .expect("generation name");
5229 let mut config = Config::default();
5230 config
5231 .mark_codewhale_owned_xai_oauth(generation.to_string())
5232 .expect("admitted OAuth fixture");
5233 assert_eq!(
5234 usable_sign_in(OAuthProvider::Xai, &config)
5235 .and_then(|sign_in| sign_in.account_label)
5236 .as_deref(),
5237 Some("b@example.com")
5238 );
5239 }
5240
5241 /// #6715 review: a login under a non-default client id (for example
5242 /// `XAI_OIDC_CLIENT_ID`) must become the account requests use.
5243 /// `select_entry` prefers the built-in client id, so an old account's
5244 /// entry under that id, carried into the new generation, would keep
5245 /// winning both the runtime credential and the displayed account.
5246 #[test]
5247 fn relogin_under_another_client_id_switches_runtime_credentials_and_label() {
5248 let _lock = crate::test_support::lock_test_env();
5249 let home = tempfile::tempdir().expect("temp home");
5250 let root = home.path().canonicalize().expect("canonical home");
5251 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5252 let config_path = root.join("config.toml");
5253 std::fs::write(&config_path, "").expect("empty config");
5254 let mut config = Config {
5255 provider: Some(ProviderKind::Xai.as_str().to_string()),
5256 ..Config::default()
5257 };
5258 let token_a = id_token(&serde_json::json!({"email": "a@example.com"}));
5259 activate_login(
5260 pending_login_with_id_token_for_test(
5261 OAuthProvider::Xai,
5262 "access-a",
5263 "refresh-a",
5264 Some(&token_a),
5265 ),
5266 Some(&config_path),
5267 Some(&mut config),
5268 )
5269 .expect("login A");
5270 assert_eq!(
5271 get_xai_credentials(&config)
5272 .expect("account A")
5273 .access_token,
5274 "access-a"
5275 );
5276
5277 let token_b = id_token(&serde_json::json!({"email": "b@example.com"}));
5278 let mut pending_b = pending_login_with_id_token_for_test(
5279 OAuthProvider::Xai,
5280 "access-b",
5281 "refresh-b",
5282 Some(&token_b),
5283 );
5284 pending_b.client_id = "alternate-client".to_string();
5285 let second =
5286 activate_login(pending_b, Some(&config_path), Some(&mut config)).expect("login B");
5287 assert_eq!(second.account_label.as_deref(), Some("b@example.com"));
5288 assert_eq!(second.replaced, Some(Some("a@example.com".to_string())));
5289
5290 // Requests: the runtime credential is account B's, under B's client.
5291 let runtime = get_xai_credentials(&config).expect("account B");
5292 assert_eq!(runtime.access_token, "access-b");
5293 assert_eq!(runtime.client_id, "alternate-client");
5294 assert_eq!(runtime.account_label.as_deref(), Some("b@example.com"));
5295 // Status: readiness/picker and `auth status` name account B too.
5296 assert_eq!(
5297 usable_sign_in(OAuthProvider::Xai, &config)
5298 .and_then(|sign_in| sign_in.account_label)
5299 .as_deref(),
5300 Some("b@example.com")
5301 );
5302 let generation = second
5303 .auth_path
5304 .file_name()
5305 .and_then(|name| name.to_str())
5306 .expect("generation name");
5307 assert_eq!(
5308 owned_account_label_for_generation(OAuthProvider::Xai, generation)
5309 .expect("usable sign-in")
5310 .as_deref(),
5311 Some("b@example.com")
5312 );
5313 let persisted = std::fs::read_to_string(&second.auth_path).expect("generation");
5314 assert!(!persisted.contains("refresh-a"), "{persisted}");
5315 }
5316
5317 /// The label follows the same usability test as the runtime: an entry
5318 /// with an expired access token and no refresh token names no account.
5319 #[test]
5320 fn stale_owned_entry_names_no_account() {
5321 let _lock = crate::test_support::lock_test_env();
5322 let home = tempfile::tempdir().expect("temp home");
5323 let root = home.path().canonicalize().expect("canonical home");
5324 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5325 let generation = "chatgpt-auth-0123456789abcdef0123456789abcdef.json";
5326 let token_a = id_token(&serde_json::json!({"email": "a@example.com"}));
5327 let file = serde_json::json!({
5328 format!("{CHATGPT_OAUTH_ISSUER}::{CHATGPT_OAUTH_CLIENT_ID}"): {
5329 "access_token": "access-stale",
5330 "expires_at": "2000-01-01T00:00:00Z",
5331 "id_token": token_a,
5332 }
5333 });
5334 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
5335 store.write(generation, file.to_string().as_bytes(), false)
5336 })
5337 .expect("seed stale generation");
5338 let mut config = Config::default();
5339 config
5340 .mark_codewhale_owned_chatgpt_oauth(generation.to_string())
5341 .expect("admitted OAuth fixture");
5342 assert!(!credentials_valid(OAuthProvider::Chatgpt, &config));
5343 assert_eq!(usable_sign_in(OAuthProvider::Chatgpt, &config), None);
5344 let reason = owned_account_label_for_generation(OAuthProvider::Chatgpt, generation)
5345 .expect_err("stale entry is unusable");
5346 assert_eq!(reason.to_string(), "sign-in file holds no usable sign-in");
5347 }
5348
5349 #[test]
5350 fn authorize_url_rejects_providers_without_a_browser_flow() {
5351 let pkce = PkceChallenge {
5352 verifier: "verifier".into(),
5353 challenge: "challenge".into(),
5354 };
5355 let err = build_authorize_url(
5356 oauth_provider_params(OAuthProvider::Xai),
5357 "https://auth.x.ai",
5358 "client",
5359 "openid",
5360 "http://localhost:1455/auth/callback",
5361 "state-1",
5362 &pkce,
5363 )
5364 .expect_err("xAI has no browser flow");
5365 assert!(
5366 format!("{err:#}").contains("no browser sign-in flow"),
5367 "{err:#}"
5368 );
5369 }
5370
5371 #[test]
5372 fn callback_success_requires_matching_state() {
5373 let ok = parse_callback_query(chatgpt(), "code=abc&state=s1").unwrap();
5374 assert_eq!(accept_callback("s1", ok).unwrap(), "abc");
5375 let mismatch = parse_callback_query(chatgpt(), "code=abc&state=other").unwrap();
5376 let err = accept_callback("s1", mismatch).unwrap_err().to_string();
5377 assert!(err.contains("state did not match"), "{err}");
5378 }
5379
5380 #[test]
5381 fn callback_error_is_user_visible_without_code() {
5382 let outcome = parse_callback_query(
5383 chatgpt(),
5384 "error=access_denied&error_description=nope&state=s1",
5385 )
5386 .unwrap();
5387 let err = accept_callback("s1", outcome).unwrap_err().to_string();
5388 assert!(err.contains("access_denied"), "{err}");
5389 assert!(!err.contains("nope"), "{err}");
5390 assert!(!err.contains("access_token"));
5391 }
5392
5393 #[test]
5394 fn callback_missing_code_fails() {
5395 let err = parse_callback_query(chatgpt(), "state=s1")
5396 .unwrap_err()
5397 .to_string();
5398 assert!(err.contains("missing authorization code"), "{err}");
5399 }
5400
5401 #[test]
5402 fn token_exchange_uses_pkce_verifier_against_mock_issuer() {
5403 let client = MockFormClient::new(vec![(
5404 200,
5405 serde_json::json!({
5406 "access_token": "at-1",
5407 "refresh_token": "rt-1",
5408 "expires_in": 3600,
5409 "id_token": jwt_with_account("acct-9")
5410 })
5411 .to_string(),
5412 )]);
5413 let token = exchange_authorization_code(
5414 &client,
5415 chatgpt(),
5416 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
5417 CHATGPT_OAUTH_CLIENT_ID,
5418 "http://localhost:1455/auth/callback",
5419 "auth-code",
5420 "verifier",
5421 )
5422 .unwrap();
5423 assert_eq!(token.access_token.as_deref(), Some("at-1"));
5424 let posts = client.posts.lock().unwrap();
5425 assert_eq!(posts.len(), 1);
5426 assert_eq!(
5427 posts[0].0,
5428 "https://auth.openai.com/api/accounts/oauth/token"
5429 );
5430 let form: std::collections::BTreeMap<_, _> = posts[0].1.iter().cloned().collect();
5431 assert_eq!(form["grant_type"], "authorization_code");
5432 assert_eq!(form["code_verifier"], "verifier");
5433 assert_eq!(form["code"], "auth-code");
5434 }
5435
5436 #[test]
5437 fn token_exchange_error_does_not_echo_body_secrets() {
5438 let client = MockFormClient::new(vec![(
5439 400,
5440 serde_json::json!({
5441 "error": "invalid_grant",
5442 "error_description": "secret-must-not-leak"
5443 })
5444 .to_string(),
5445 )]);
5446 // OAuthTokenMaterial is deliberately Debug-free; extract the error
5447 // without demanding a Debug bound on the success type.
5448 let err = match exchange_authorization_code(
5449 &client,
5450 chatgpt(),
5451 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
5452 CHATGPT_OAUTH_CLIENT_ID,
5453 "http://localhost:1455/auth/callback",
5454 "bad",
5455 "verifier",
5456 ) {
5457 Ok(_) => panic!("invalid_grant must fail"),
5458 Err(err) => err.to_string(),
5459 };
5460 assert!(err.contains("permanently"), "{err}");
5461 assert!(!err.contains("secret-must-not-leak"), "{err}");
5462 }
5463
5464 #[test]
5465 fn callback_server_handles_success_and_error_requests() {
5466 use std::io::Write as _;
5467 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test port");
5468 listener.set_nonblocking(false).unwrap();
5469 let addr = listener.local_addr().unwrap();
5470 let state = "state-xyz".to_string();
5471 let expected = state.clone();
5472 let params = chatgpt();
5473 let server = std::thread::spawn(move || {
5474 let (stream, _) = listener.accept().expect("accept");
5475 handle_callback_stream(stream, params, &expected)
5476 });
5477 let mut client = std::net::TcpStream::connect(addr).expect("connect");
5478 write!(
5479 client,
5480 "GET /auth/callback?code=tok&state={state} HTTP/1.1\r\nHost: localhost\r\n\r\n"
5481 )
5482 .unwrap();
5483 let code = server.join().expect("server").expect("callback ok");
5484 assert_eq!(code.0, "tok");
5485
5486 let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind error port");
5487 listener.set_nonblocking(false).unwrap();
5488 let addr = listener.local_addr().unwrap();
5489 let params = chatgpt();
5490 let server = std::thread::spawn(move || {
5491 let (stream, _) = listener.accept().expect("accept");
5492 handle_callback_stream(stream, params, "state-xyz")
5493 });
5494 let mut client = std::net::TcpStream::connect(addr).expect("connect");
5495 write!(
5496 client,
5497 "GET /auth/callback?error=access_denied&state=state-xyz HTTP/1.1\r\nHost: localhost\r\n\r\n"
5498 )
5499 .unwrap();
5500 let err = server.join().expect("server").unwrap_err().to_string();
5501 assert!(err.contains("not completed"), "{err}");
5502 }
5503
5504 /// The registered redirect URI says `localhost`, which resolves to `::1`
5505 /// as readily as `127.0.0.1`. A callback arriving on the IPv6 listener has
5506 /// to be accepted, or an IPv6-first browser hangs until the timeout.
5507 #[test]
5508 fn callback_is_accepted_on_either_loopback_family() {
5509 use std::io::Write as _;
5510 for addr in [
5511 SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
5512 SocketAddr::from((Ipv6Addr::LOCALHOST, 0)),
5513 ] {
5514 let Ok(target) = TcpListener::bind(addr) else {
5515 // A host without this stack cannot exercise it; the other arm
5516 // still covers the polling loop.
5517 continue;
5518 };
5519 target.set_nonblocking(true).unwrap();
5520 let target_addr = target.local_addr().unwrap();
5521
5522 // A second, permanently idle listener stands in for the family the
5523 // browser did not pick: `wait_for_callback` must poll past it.
5524 let idle = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
5525 .expect("bind idle listener");
5526 idle.set_nonblocking(true).unwrap();
5527
5528 let listeners = vec![idle, target];
5529 let params = chatgpt();
5530 let server =
5531 std::thread::spawn(move || wait_for_callback(&listeners, params, "state-xyz"));
5532 let mut client = std::net::TcpStream::connect(target_addr).expect("connect");
5533 write!(
5534 client,
5535 "GET /auth/callback?code=tok&state=state-xyz HTTP/1.1\r\nHost: localhost\r\n\r\n"
5536 )
5537 .unwrap();
5538 let code = server
5539 .join()
5540 .expect("server")
5541 .unwrap_or_else(|error| panic!("callback on {target_addr} rejected: {error}"));
5542 assert_eq!(code.0, "tok", "callback on {target_addr}");
5543 }
5544 }
5545
5546 #[test]
5547 fn form_refresh_and_revoke_target_the_row_endpoints() {
5548 let client = MockFormClient::new(vec![
5549 (
5550 200,
5551 serde_json::json!({"access_token": "fresh", "expires_in": 3600}).to_string(),
5552 ),
5553 (200, String::new()),
5554 ]);
5555 let refreshed = refresh_access_token_via(
5556 &client,
5557 chatgpt(),
5558 &form_token_url(chatgpt(), CHATGPT_OAUTH_ISSUER),
5559 CHATGPT_OAUTH_CLIENT_ID,
5560 "rt-1",
5561 )
5562 .expect("refresh");
5563 assert_eq!(refreshed.access_token.as_deref(), Some("fresh"));
5564 revoke_remote_token_via(
5565 &client,
5566 chatgpt(),
5567 CHATGPT_OAUTH_ISSUER,
5568 CHATGPT_OAUTH_CLIENT_ID,
5569 "rt-1",
5570 )
5571 .expect("revoke");
5572 let posts = client.posts.lock().unwrap();
5573 assert_eq!(posts.len(), 2, "{posts:?}");
5574 assert!(posts[0].0.ends_with("/oauth/token"), "{posts:?}");
5575 assert!(
5576 posts[0]
5577 .1
5578 .iter()
5579 .any(|(k, v)| k == "grant_type" && v == "refresh_token")
5580 );
5581 assert!(
5582 posts[1].0.ends_with("/api/accounts/oauth/revoke"),
5583 "{posts:?}"
5584 );
5585 }
5586
5587 // ────────────────────────────────────────────────────────────────────
5588 // Ported from the deleted per-provider modules (§D security pins).
5589 // ────────────────────────────────────────────────────────────────────
5590
5591 use tempfile::TempDir;
5592 use wiremock::matchers::{method, path};
5593 use wiremock::{Mock, MockServer, ResponseTemplate};
5594
5595 #[test]
5596 fn auth_mode_accepts_oauth_aliases() {
5597 for mode in [
5598 "oauth",
5599 "xai_oauth",
5600 "XAI-OAuth",
5601 "grok",
5602 "grok_cli",
5603 "device_code",
5604 "device-auth",
5605 ] {
5606 assert!(
5607 auth_mode_uses_xai_oauth(mode),
5608 "expected oauth mode: {mode}"
5609 );
5610 }
5611 assert!(!auth_mode_uses_xai_oauth("api_key"));
5612 assert!(!auth_mode_uses_xai_oauth("keyring"));
5613 }
5614
5615 #[test]
5616 fn loads_fresh_token_from_grok_auth_json() {
5617 let _guard = crate::test_support::lock_test_env();
5618 let dir = TempDir::new().unwrap();
5619 let root = dir.path().canonicalize().expect("canonical temp root");
5620 let path = root.join("auth.json");
5621 let future = rfc3339_from_now(3600);
5622 let scope = format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}");
5623 let file = serde_json::json!({
5624 scope: {
5625 "key": "test-access-token",
5626 "refresh_token": "test-refresh",
5627 "expires_at": future,
5628 "oidc_issuer": XAI_OIDC_ISSUER,
5629 "oidc_client_id": GROK_OIDC_CLIENT_ID,
5630 "auth_mode": "oidc"
5631 }
5632 });
5633 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
5634 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
5635 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
5636 let config = Config {
5637 provider: Some(ProviderKind::Xai.as_str().to_string()),
5638 providers: Some(crate::config::ProvidersConfig {
5639 xai: crate::config::ProviderConfig {
5640 auth_mode: Some("oauth".to_string()),
5641 external_credentials: Some(
5642 codewhale_config::ExternalCredentialConsentToml::read_only(
5643 codewhale_config::ProviderKind::Xai,
5644 codewhale_config::ExternalCredentialSource::GrokCli,
5645 path.clone(),
5646 ),
5647 ),
5648 ..Default::default()
5649 },
5650 ..Default::default()
5651 }),
5652 ..Default::default()
5653 };
5654 crate::external_credentials::reset_side_effect_trap();
5655 let result = get_xai_credentials(&config);
5656 let creds = result.expect("load");
5657 assert_eq!(creds.access_token, "test-access-token");
5658 assert_eq!(creds.client_id, GROK_OIDC_CLIENT_ID);
5659 assert_eq!(
5660 crate::external_credentials::side_effect_trap_counts(),
5661 (1, 1)
5662 );
5663 }
5664
5665 #[test]
5666 fn disabled_external_grok_credentials_cause_zero_external_io() {
5667 let _guard = crate::test_support::lock_test_env();
5668 let dir = TempDir::new().unwrap();
5669 let root = dir.path().canonicalize().expect("canonical temp root");
5670 let path = root.join("external-grok-auth.json");
5671 let raw = serde_json::json!({
5672 format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}"): {
5673 "key": "must-never-be-read",
5674 "refresh_token": "must-never-be-used",
5675 "expires_at": rfc3339_from_now(3600),
5676 "future_field": {"preserve": true}
5677 }
5678 })
5679 .to_string();
5680 fs::write(&path, &raw).unwrap();
5681 let owned_home = root.join("codewhale-owned");
5682 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
5683 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
5684 let config = Config {
5685 provider: Some(ProviderKind::Xai.as_str().to_string()),
5686 providers: Some(crate::config::ProvidersConfig {
5687 xai: crate::config::ProviderConfig {
5688 auth_mode: Some("oauth".to_string()),
5689 ..Default::default()
5690 },
5691 ..Default::default()
5692 }),
5693 ..Default::default()
5694 };
5695
5696 crate::external_credentials::reset_side_effect_trap();
5697 assert!(!credentials_valid(OAuthProvider::Xai, &config));
5698 let error = match get_xai_credentials(&config) {
5699 Ok(_) => panic!("external access is disabled"),
5700 Err(e) => e,
5701 };
5702 assert!(error.to_string().contains("are disabled"));
5703 assert_eq!(
5704 crate::external_credentials::side_effect_trap_counts(),
5705 (0, 0)
5706 );
5707 assert_eq!(
5708 crate::external_credentials::complete_side_effect_trap_counts(),
5709 (0, 0, 0, 0, 0),
5710 "disabled external authority must reach no credential or OAuth sink"
5711 );
5712 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
5713 }
5714
5715 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5716 async fn expired_read_only_external_credentials_never_refresh_rewrite_or_network() {
5717 let _guard = crate::test_support::lock_test_env();
5718 let server = MockServer::start().await;
5719 let dir = TempDir::new().unwrap();
5720 let root = dir.path().canonicalize().expect("canonical temp root");
5721 let path = root.join("external-grok-auth.json");
5722 let scope = format!("{}::{GROK_OIDC_CLIENT_ID}", server.uri());
5723 let raw = serde_json::json!({
5724 scope: {
5725 "key": "expired-external-access",
5726 "refresh_token": "must-never-be-submitted",
5727 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
5728 "oidc_issuer": server.uri(),
5729 "oidc_client_id": GROK_OIDC_CLIENT_ID,
5730 "future_field": {"preserve": true}
5731 }
5732 })
5733 .to_string();
5734 fs::write(&path, &raw).unwrap();
5735 let owned_home = root.join("codewhale-owned");
5736 let _home_guard = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &owned_home);
5737 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
5738 let config = Config {
5739 provider: Some(ProviderKind::Xai.as_str().to_string()),
5740 providers: Some(crate::config::ProvidersConfig {
5741 xai: crate::config::ProviderConfig {
5742 auth_mode: Some("oauth".to_string()),
5743 external_credentials: Some(
5744 codewhale_config::ExternalCredentialConsentToml::read_only(
5745 codewhale_config::ProviderKind::Xai,
5746 codewhale_config::ExternalCredentialSource::GrokCli,
5747 path.clone(),
5748 ),
5749 ),
5750 ..Default::default()
5751 },
5752 ..Default::default()
5753 }),
5754 ..Default::default()
5755 };
5756
5757 crate::external_credentials::reset_side_effect_trap();
5758 let error = match tokio::task::block_in_place(|| get_xai_credentials(&config)) {
5759 Ok(_) => panic!("read-only external credentials must fail instead of refreshing"),
5760 Err(e) => e,
5761 };
5762 assert!(
5763 error
5764 .to_string()
5765 .contains("Read-only consent never refreshes")
5766 );
5767 assert_eq!(
5768 crate::external_credentials::side_effect_trap_counts(),
5769 (1, 1)
5770 );
5771 assert_eq!(
5772 crate::external_credentials::complete_side_effect_trap_counts(),
5773 (1, 1, 0, 0, 0),
5774 "read-only external expiry must not reach write, refresh, or network sinks"
5775 );
5776 assert_eq!(fs::read_to_string(&path).unwrap(), raw);
5777 assert!(!owned_home.join("credentials/xai-auth.json").exists());
5778 assert!(
5779 server
5780 .received_requests()
5781 .await
5782 .expect("recorded requests")
5783 .is_empty(),
5784 "external refresh tokens must never be sent over the network"
5785 );
5786 }
5787
5788 /// #4763 root trigger, re-pinned for #5772. A returning xAI-OAuth user
5789 /// whose only material is an external Grok CLI grant loses readiness the
5790 /// moment that CLI's short-lived access token expires, even though a
5791 /// refresh token sits right beside it — read-only consent deliberately
5792 /// never refreshes or rewrites another CLI's file, so there is nothing to
5793 /// renew it with. `needs_api_key` therefore flips to true and onboarding
5794 /// reopens. That is the intended invariant, not a leak, and it is what
5795 /// stops a surviving consent record from reading as a stored credential;
5796 /// this test pins it so the onboarding entry point stays explainable.
5797 #[test]
5798 fn expired_external_grok_grant_reads_as_missing_key_despite_refresh_token() {
5799 let _guard = crate::test_support::lock_test_env();
5800 let dir = TempDir::new().unwrap();
5801 let root = dir.path().canonicalize().expect("canonical temp root");
5802 let path = root.join("external-grok-auth.json");
5803 let scope = format!("https://auth.x.ai::{GROK_OIDC_CLIENT_ID}");
5804 fs::write(
5805 &path,
5806 serde_json::json!({
5807 scope.clone(): {
5808 "key": "expired-external-access",
5809 "refresh_token": "present-but-unusable-under-read-only-consent",
5810 "expires_at": rfc3339_from_unix(now_unix_secs().unwrap_or(0) - 3600),
5811 "oidc_client_id": GROK_OIDC_CLIENT_ID,
5812 }
5813 })
5814 .to_string(),
5815 )
5816 .unwrap();
5817 let _home_guard =
5818 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.join("codewhale-owned"));
5819 let _path_guard = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &path);
5820 let _key_guard = crate::test_support::EnvVarGuard::remove("XAI_API_KEY");
5821 let config = Config {
5822 provider: Some(ProviderKind::Xai.as_str().to_string()),
5823 providers: Some(crate::config::ProvidersConfig {
5824 xai: crate::config::ProviderConfig {
5825 auth_mode: Some("oauth".to_string()),
5826 external_credentials: Some(
5827 codewhale_config::ExternalCredentialConsentToml::read_only(
5828 codewhale_config::ProviderKind::Xai,
5829 codewhale_config::ExternalCredentialSource::GrokCli,
5830 path.clone(),
5831 ),
5832 ),
5833 ..Default::default()
5834 },
5835 ..Default::default()
5836 }),
5837 ..Default::default()
5838 };
5839
5840 crate::external_credentials::reset_side_effect_trap();
5841 assert!(
5842 !credentials_present(OAuthProvider::Xai, &config),
5843 "an expired external access token is not usable material"
5844 );
5845 assert!(
5846 !crate::config::has_api_key_for(
5847 &config,
5848 &(config).test_identity_for_kind(ProviderKind::Xai)
5849 ),
5850 "expired external xAI OAuth must fall through to the missing-key path"
5851 );
5852 assert_eq!(
5853 crate::external_credentials::complete_side_effect_trap_counts().2,
5854 0,
5855 "a consented read never rewrites Codewhale-owned storage"
5856 );
5857 assert_eq!(
5858 crate::external_credentials::complete_side_effect_trap_counts().3,
5859 0,
5860 "a consented read never refreshes another CLI's token"
5861 );
5862
5863 // The same file with a live access token is ready, so the check is
5864 // expiry-driven rather than a blanket rejection of external grants.
5865 fs::write(
5866 &path,
5867 serde_json::json!({
5868 scope: {
5869 "key": "fresh-external-access",
5870 "refresh_token": "unused",
5871 "expires_at": rfc3339_from_now(3600),
5872 "oidc_client_id": GROK_OIDC_CLIENT_ID,
5873 }
5874 })
5875 .to_string(),
5876 )
5877 .unwrap();
5878 assert!(credentials_present(OAuthProvider::Xai, &config));
5879 assert!(crate::config::has_api_key_for(
5880 &config,
5881 &(config).test_identity_for_kind(ProviderKind::Xai)
5882 ));
5883 }
5884
5885 #[test]
5886 fn native_login_storage_is_codewhale_owned() {
5887 let _guard = crate::test_support::lock_test_env();
5888 let dir = TempDir::new().unwrap();
5889 let grok_path = dir.path().join("external-grok-auth.json");
5890 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
5891 let _grok = crate::test_support::EnvVarGuard::set("GROK_AUTH_PATH", &grok_path);
5892
5893 let owned = codewhale_config::legacy_xai_oauth_path().expect("Codewhale-owned auth path");
5894 assert_eq!(owned, dir.path().join("credentials/xai-auth.json"));
5895 assert_ne!(owned, grok_auth_file_path());
5896 }
5897
5898 /// #4257 storage contract: the on-disk credential file is a JSON object
5899 /// keyed `{issuer}::{client_id}` whose entries use the Grok CLI's field
5900 /// names. Consolidating the device-code poller must not touch it, so pin
5901 /// the format with literal bytes rather than a round-trip through the
5902 /// writer — a round-trip would follow the code if the code drifted.
5903 #[test]
5904 fn a_token_stored_in_the_current_on_disk_format_still_loads() {
5905 let _guard = crate::test_support::lock_test_env();
5906 let dir = TempDir::new().unwrap();
5907 let home = dir
5908 .path()
5909 .canonicalize()
5910 .expect("canonical temp root")
5911 .join("owned-home");
5912 fs::create_dir_all(&home).unwrap();
5913 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
5914
5915 let generation = "xai-auth-fedcba9876543210fedcba9876543210.json";
5916 let expires_at = rfc3339_from_now(3600);
5917 let stored = format!(
5918 r#"{{
5919 "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828": {{
5920 "key": "stored-access-token",
5921 "refresh_token": "stored-refresh-token",
5922 "expires_at": "{expires_at}",
5923 "oidc_issuer": "https://auth.x.ai",
5924 "oidc_client_id": "b1a00492-073a-47ea-816f-4c329264a828",
5925 "auth_mode": "oidc",
5926 "unknown_cli_field": "preserved"
5927 }}
5928 }}"#
5929 );
5930
5931 let credentials = codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
5932 store.write(generation, stored.as_bytes(), false)?;
5933 // A fresh stored token must be used as-is: refreshing here would
5934 // mean an existing login stopped working offline.
5935 get_owned_credentials_locked(
5936 OAuthProvider::Xai,
5937 store,
5938 generation,
5939 &ReqwestOAuthFormClient,
5940 |_, _, _| panic!("a fresh stored token must not be refreshed"),
5941 )
5942 })
5943 .expect("read back a credential stored in the current format");
5944
5945 assert_eq!(credentials.access_token, "stored-access-token");
5946 assert_eq!(
5947 credentials.refresh_token.as_deref(),
5948 Some("stored-refresh-token")
5949 );
5950 assert_eq!(credentials.issuer, XAI_OIDC_ISSUER);
5951 assert_eq!(credentials.client_id, GROK_OIDC_CLIENT_ID);
5952 }
5953
5954 /// `Debug` reaches production through tracing's `?` sigil, anyhow context,
5955 /// and panic messages. Nothing that holds bearer material may print it.
5956 #[test]
5957 fn debug_output_never_contains_bearer_material() {
5958 let entry = OwnedAuthEntry {
5959 access_token: Some("secret-access-token".to_string()),
5960 refresh_token: Some("secret-refresh-token".to_string()),
5961 expires_at: Some("2030-01-01T00:00:00.000Z".to_string()),
5962 id_token: None,
5963 account_id: None,
5964 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
5965 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
5966 originator: None,
5967 auth_mode: Some("oidc".to_string()),
5968 extra: BTreeMap::new(),
5969 };
5970 let credentials = credentials_from_entry(
5971 OAuthProvider::Xai,
5972 &format!("{XAI_OIDC_ISSUER}::{GROK_OIDC_CLIENT_ID}"),
5973 &entry,
5974 "secret-access-token".to_string(),
5975 );
5976 let activation = OAuthActivation {
5977 credentials: credentials.clone(),
5978 config_path: PathBuf::from("/tmp/config.toml"),
5979 auth_path: PathBuf::from("/tmp/auth.json"),
5980 provider: OAuthProvider::Xai,
5981 account_label: None,
5982 replaced: None,
5983 };
5984
5985 let rendered = format!("{entry:?} {activation:?}");
5986 for secret in ["secret-access-token", "secret-refresh-token"] {
5987 assert!(!rendered.contains(secret), "{secret} leaked: {rendered}");
5988 }
5989 // The shape stays useful for diagnosis.
5990 assert!(rendered.contains("<redacted>"), "{rendered}");
5991 assert!(rendered.contains(GROK_OIDC_CLIENT_ID), "{rendered}");
5992 }
5993
5994 fn pending_login(access: &str, refresh: &str) -> PendingOAuthLogin {
5995 pending_login_for_test(OAuthProvider::Xai, access, refresh)
5996 }
5997
5998 fn seed_expired_owned_generation() -> String {
5999 let generation = "xai-auth-0123456789abcdef0123456789abcdef.json".to_string();
6000 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6001 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
6002 let mut file = AuthFile::new();
6003 file.insert(
6004 scope,
6005 OwnedAuthEntry {
6006 access_token: Some("expired-access".to_string()),
6007 refresh_token: Some("initial-refresh".to_string()),
6008 expires_at: Some("1970-01-01T00:00:00.000Z".to_string()),
6009 id_token: None,
6010 account_id: None,
6011 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
6012 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
6013 originator: None,
6014 auth_mode: Some("oidc".to_string()),
6015 extra: BTreeMap::new(),
6016 },
6017 );
6018 write_auth_file_to_store(store, &generation, &file, false)
6019 })
6020 .expect("seed expired owned generation");
6021 generation
6022 }
6023
6024 fn seed_legacy_owned_credentials() -> PathBuf {
6025 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6026 let scope = format!("{}::{}", XAI_OIDC_ISSUER, GROK_OIDC_CLIENT_ID);
6027 let mut legacy = AuthFile::new();
6028 legacy.insert(
6029 scope,
6030 OwnedAuthEntry {
6031 access_token: Some("legacy-access".to_string()),
6032 refresh_token: Some("legacy-refresh".to_string()),
6033 expires_at: Some(rfc3339_from_now(3600)),
6034 id_token: None,
6035 account_id: None,
6036 oidc_issuer: Some(XAI_OIDC_ISSUER.to_string()),
6037 oidc_client_id: Some(GROK_OIDC_CLIENT_ID.to_string()),
6038 originator: None,
6039 auth_mode: Some("oidc".to_string()),
6040 extra: BTreeMap::new(),
6041 },
6042 );
6043 write_auth_file_to_store(
6044 store,
6045 codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME,
6046 &legacy,
6047 false,
6048 )?;
6049 store.path_for(codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME)
6050 })
6051 .expect("seed legacy credentials")
6052 }
6053
6054 #[test]
6055 fn concurrent_refreshes_share_one_rotated_epoch() {
6056 let _guard = crate::test_support::lock_test_env();
6057 let dir = TempDir::new().unwrap();
6058 let home = dir
6059 .path()
6060 .canonicalize()
6061 .expect("canonical temp root")
6062 .join("owned-home");
6063 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6064 let generation = seed_expired_owned_generation();
6065 let refreshes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
6066 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6067 let (release_tx, release_rx) = std::sync::mpsc::channel();
6068
6069 let first_generation = generation.clone();
6070 let first_refreshes = refreshes.clone();
6071 let first = std::thread::spawn(move || {
6072 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6073 get_owned_credentials_locked(
6074 OAuthProvider::Xai,
6075 store,
6076 &first_generation,
6077 &ReqwestOAuthFormClient,
6078 |_, _, refresh| {
6079 assert_eq!(refresh, "initial-refresh");
6080 first_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6081 entered_tx.send(()).unwrap();
6082 release_rx.recv().unwrap();
6083 Ok(OAuthTokenMaterial {
6084 earliest_refresh_at: None,
6085 scope: None,
6086 token_type: None,
6087 verified_chatgpt: None,
6088 id_token: None,
6089 access_token: Some("rotated-access".to_string()),
6090 refresh_token: Some("rotated-refresh".to_string()),
6091 expires_in: Some(3600),
6092 error: None,
6093 error_description: None,
6094 interval: None,
6095 })
6096 },
6097 )
6098 })
6099 });
6100 entered_rx.recv().expect("first refresh reached barrier");
6101
6102 let second_generation = generation.clone();
6103 let second_refreshes = refreshes.clone();
6104 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
6105 let second = std::thread::spawn(move || {
6106 attempt_tx.send(()).unwrap();
6107 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6108 get_owned_credentials_locked(
6109 OAuthProvider::Xai,
6110 store,
6111 &second_generation,
6112 &ReqwestOAuthFormClient,
6113 |_, _, _| {
6114 second_refreshes.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
6115 bail!("second refresh must observe the first thread's committed token")
6116 },
6117 )
6118 })
6119 });
6120 attempt_rx.recv().expect("second refresh attempted lock");
6121 release_tx.send(()).expect("release first refresh");
6122
6123 let first = first.join().unwrap().expect("first refresh");
6124 let second = second.join().unwrap().expect("second refresh");
6125 assert_eq!(first.access_token, "rotated-access");
6126 assert_eq!(second.access_token, "rotated-access");
6127 assert_eq!(refreshes.load(std::sync::atomic::Ordering::SeqCst), 1);
6128 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6129 let mut file = load_owned_auth_file_from_store(store, &generation)?
6130 .context("generation must remain active")?;
6131 let (_, entry) = select_entry(OAuthProvider::Xai, &mut file).context("stored entry")?;
6132 assert_eq!(entry.refresh_token.as_deref(), Some("rotated-refresh"));
6133 Ok(())
6134 })
6135 .unwrap();
6136 }
6137
6138 #[test]
6139 fn logout_waits_for_refresh_then_revokes_the_committed_epoch() {
6140 let _guard = crate::test_support::lock_test_env();
6141 let dir = TempDir::new().unwrap();
6142 let home = dir
6143 .path()
6144 .canonicalize()
6145 .expect("canonical temp root")
6146 .join("owned-home");
6147 fs::create_dir_all(&home).unwrap();
6148 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6149 let generation = seed_expired_owned_generation();
6150 fs::write(
6151 home.join("config.toml"),
6152 format!(
6153 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{generation}\"\n"
6154 ),
6155 )
6156 .unwrap();
6157 let (entered_tx, entered_rx) = std::sync::mpsc::channel();
6158 let (release_tx, release_rx) = std::sync::mpsc::channel();
6159
6160 let refresh_generation = generation.clone();
6161 let refresh = std::thread::spawn(move || {
6162 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
6163 get_owned_credentials_locked(
6164 OAuthProvider::Xai,
6165 store,
6166 &refresh_generation,
6167 &ReqwestOAuthFormClient,
6168 |_, _, _| {
6169 entered_tx.send(()).unwrap();
6170 release_rx.recv().unwrap();
6171 Ok(OAuthTokenMaterial {
6172 earliest_refresh_at: None,
6173 scope: None,
6174 token_type: None,
6175 verified_chatgpt: None,
6176 id_token: None,
6177 access_token: Some("last-refresh-access".to_string()),
6178 refresh_token: Some("last-refresh-rotation".to_string()),
6179 expires_in: Some(3600),
6180 error: None,
6181 error_description: None,
6182 interval: None,
6183 })
6184 },
6185 )
6186 })
6187 });
6188 entered_rx.recv().expect("refresh reached barrier");
6189
6190 let (attempt_tx, attempt_rx) = std::sync::mpsc::channel();
6191 let config_path = home.join("config.toml");
6192 let logout = std::thread::spawn(move || {
6193 attempt_tx.send(()).unwrap();
6194 codewhale_config::with_xai_oauth_revocation_transaction(|| {
6195 codewhale_config::mutate_config_document(&config_path, |document| {
6196 codewhale_config::unset_config_document_value(
6197 document,
6198 &["providers", "xai", "oauth_credential_generation"],
6199 )?;
6200 codewhale_config::unset_config_document_value(
6201 document,
6202 &["providers", "xai", "auth_mode"],
6203 )?;
6204 Ok(())
6205 })
6206 })
6207 });
6208 attempt_rx.recv().expect("logout attempted lifecycle lock");
6209 release_tx.send(()).expect("release refresh");
6210
6211 assert_eq!(
6212 refresh.join().unwrap().expect("refresh").access_token,
6213 "last-refresh-access"
6214 );
6215 logout.join().unwrap().expect("logout");
6216 let auth_path = home.join("credentials").join(&generation);
6217 assert!(
6218 !auth_path.exists(),
6219 "logout must retire the generation written by the preceding refresh"
6220 );
6221 let config = fs::read_to_string(home.join("config.toml")).unwrap();
6222 assert!(!config.contains("oauth_credential_generation"));
6223 assert!(!config.contains("auth_mode"));
6224 }
6225
6226 #[test]
6227 fn activation_commits_unique_generation_pointer_and_revokes_external_consent() {
6228 let _guard = crate::test_support::lock_test_env();
6229 let dir = TempDir::new().unwrap();
6230 let home = dir
6231 .path()
6232 .canonicalize()
6233 .expect("canonical temp root")
6234 .join("owned-home");
6235 let config_path = dir.path().join("config.toml");
6236 let external_path = dir.path().join("grok-external.json");
6237 fs::write(&external_path, "external owner bytes").unwrap();
6238 fs::write(
6239 &config_path,
6240 format!(
6241 r#"# operator note
6242 [providers.xai]
6243 model = "grok-code-fast-1" # model note
6244 future_setting = "preserve"
6245
6246 [providers.xai.external_credentials]
6247 access = "read_only"
6248 provider = "xai"
6249 source = "grok_cli"
6250 path = {}
6251 consent_version = 1
6252 "#,
6253 toml::Value::String(external_path.display().to_string())
6254 ),
6255 )
6256 .unwrap();
6257 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6258 let consent = codewhale_config::ExternalCredentialConsentToml::read_only(
6259 codewhale_config::ProviderKind::Xai,
6260 codewhale_config::ExternalCredentialSource::GrokCli,
6261 external_path.clone(),
6262 );
6263 let mut live = Config {
6264 providers: Some(crate::config::ProvidersConfig {
6265 xai: crate::config::ProviderConfig {
6266 model: Some("grok-code-fast-1".to_string()),
6267 external_credentials: Some(consent),
6268 ..Default::default()
6269 },
6270 ..Default::default()
6271 }),
6272 ..Default::default()
6273 };
6274
6275 crate::external_credentials::reset_side_effect_trap();
6276 let activation = activate_login(
6277 pending_login("activation-access", "activation-refresh"),
6278 Some(&config_path),
6279 Some(&mut live),
6280 )
6281 .expect("activate login");
6282
6283 assert_eq!(activation.config_path, config_path);
6284 let generation = activation
6285 .auth_path
6286 .file_name()
6287 .and_then(|name| name.to_str())
6288 .expect("generation basename");
6289 assert!(codewhale_config::is_valid_xai_oauth_generation(generation));
6290 let persisted = fs::read_to_string(&config_path).unwrap();
6291 assert!(persisted.contains("# operator note"));
6292 assert!(persisted.contains("model = \"grok-code-fast-1\" # model note"));
6293 assert!(persisted.contains("future_setting = \"preserve\""));
6294 assert!(persisted.contains("auth_mode = \"oauth\""));
6295 assert!(persisted.contains(&format!("oauth_credential_generation = \"{generation}\"")));
6296 assert!(!persisted.contains("external_credentials"));
6297 assert_eq!(
6298 fs::read_to_string(&external_path).unwrap(),
6299 "external owner bytes"
6300 );
6301 let owned = fs::read_to_string(&activation.auth_path).unwrap();
6302 assert!(owned.contains("activation-access"));
6303 assert!(owned.contains("activation-refresh"));
6304 #[cfg(unix)]
6305 assert_eq!(
6306 fs::metadata(&activation.auth_path)
6307 .unwrap()
6308 .permissions()
6309 .mode()
6310 & 0o777,
6311 0o600
6312 );
6313 let live_xai = live
6314 .provider_config_for(&live.test_identity_for_kind(ProviderKind::Xai))
6315 .unwrap();
6316 assert_eq!(live_xai.auth_mode.as_deref(), Some("oauth"));
6317 assert_eq!(
6318 live_xai.oauth_credential_generation.as_deref(),
6319 Some(generation)
6320 );
6321 assert!(live_xai.external_credentials.is_none());
6322 assert_eq!(
6323 crate::external_credentials::complete_side_effect_trap_counts(),
6324 (0, 0, 1, 0, 0),
6325 "activation must reach exactly the owned write sink"
6326 );
6327 }
6328
6329 #[test]
6330 fn activation_retires_legacy_owned_file_only_after_config_commit() {
6331 let _guard = crate::test_support::lock_test_env();
6332 let dir = TempDir::new().unwrap();
6333 let home = dir
6334 .path()
6335 .canonicalize()
6336 .expect("canonical temp root")
6337 .join("owned-home");
6338 let config_path = dir.path().join("config.toml");
6339 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
6340 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6341 let legacy_path = seed_legacy_owned_credentials();
6342 assert!(legacy_path.exists());
6343
6344 let activation = activate_login(
6345 pending_login("new-access", "new-refresh"),
6346 Some(&config_path),
6347 None,
6348 )
6349 .expect("activate replacement generation");
6350
6351 assert!(activation.auth_path.exists());
6352 assert!(
6353 !legacy_path.exists(),
6354 "legacy duplicate must be removed after the generation pointer commits"
6355 );
6356 let persisted = fs::read_to_string(config_path).unwrap();
6357 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
6358 }
6359
6360 #[test]
6361 fn activation_rotation_cleans_only_the_superseded_generation_after_commit() {
6362 let _guard = crate::test_support::lock_test_env();
6363 let dir = TempDir::new().unwrap();
6364 let home = dir
6365 .path()
6366 .canonicalize()
6367 .expect("canonical temp root")
6368 .join("owned-home");
6369 let config_path = dir.path().join("config.toml");
6370 fs::write(&config_path, "[providers.xai]\nmodel = \"grok-4.5\"\n").unwrap();
6371 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6372 let mut live = Config::default();
6373
6374 let first = activate_login(
6375 pending_login("first-access", "first-refresh"),
6376 Some(&config_path),
6377 Some(&mut live),
6378 )
6379 .expect("first activation");
6380 assert!(first.auth_path.exists());
6381 let first_name = first
6382 .auth_path
6383 .file_name()
6384 .unwrap()
6385 .to_str()
6386 .unwrap()
6387 .to_string();
6388
6389 let second = activate_login(
6390 pending_login("second-access", "second-refresh"),
6391 Some(&config_path),
6392 Some(&mut live),
6393 )
6394 .expect("second activation");
6395 assert_ne!(first.auth_path, second.auth_path);
6396 assert!(second.auth_path.exists());
6397 assert!(
6398 !first.auth_path.exists(),
6399 "superseded generation must be removed only after the new pointer commits"
6400 );
6401 let persisted = fs::read_to_string(&config_path).unwrap();
6402 assert!(!persisted.contains(&first_name));
6403 assert!(persisted.contains(second.auth_path.file_name().unwrap().to_str().unwrap()));
6404 assert!(
6405 fs::read_to_string(second.auth_path)
6406 .unwrap()
6407 .contains("second-access")
6408 );
6409 }
6410
6411 #[test]
6412 fn activation_recovers_from_a_dangling_generation_pointer() {
6413 let _guard = crate::test_support::lock_test_env();
6414 let dir = TempDir::new().unwrap();
6415 let home = dir
6416 .path()
6417 .canonicalize()
6418 .expect("canonical temp root")
6419 .join("owned-home");
6420 let config_path = dir.path().join("config.toml");
6421 // A valid-looking generation pointer whose credential file does not
6422 // exist: the state Hunter's dogfood machine was bricked in (#5032).
6423 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
6424 fs::write(
6425 &config_path,
6426 format!(
6427 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
6428 ),
6429 )
6430 .unwrap();
6431 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6432 let mut live = Config::default();
6433
6434 let activation = activate_login(
6435 pending_login("recovered-access", "recovered-refresh"),
6436 Some(&config_path),
6437 Some(&mut live),
6438 )
6439 .expect("a dangling generation pointer must not brick login");
6440 assert!(activation.auth_path.exists());
6441 assert!(
6442 fs::read_to_string(&activation.auth_path)
6443 .unwrap()
6444 .contains("recovered-access")
6445 );
6446 let persisted = fs::read_to_string(&config_path).unwrap();
6447 assert!(
6448 !persisted.contains(stale),
6449 "stale pointer must be replaced: {persisted}"
6450 );
6451 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
6452 assert!(persisted.contains("auth_mode = \"oauth\""));
6453 }
6454
6455 #[test]
6456 fn dangling_generation_pointer_is_detected_and_repaired() {
6457 let _guard = crate::test_support::lock_test_env();
6458 let dir = TempDir::new().unwrap();
6459 let home = dir
6460 .path()
6461 .canonicalize()
6462 .expect("canonical temp root")
6463 .join("owned-home");
6464 let config_path = dir.path().join("config.toml");
6465 // A valid-looking generation pointer whose credential file does not
6466 // exist: the state Hunter's dogfood machine was bricked in (#5032).
6467 let stale = "xai-auth-0123456789abcdef0123456789abcdef.json";
6468 fs::write(
6469 &config_path,
6470 format!(
6471 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
6472 ),
6473 )
6474 .unwrap();
6475 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6476
6477 let config = Config {
6478 provider: Some(ProviderKind::Xai.as_str().to_string()),
6479 providers: Some(crate::config::ProvidersConfig {
6480 xai: crate::config::ProviderConfig {
6481 auth_mode: Some("oauth".to_string()),
6482 oauth_credential_generation: Some(stale.to_string()),
6483 ..Default::default()
6484 },
6485 ..Default::default()
6486 }),
6487 ..Default::default()
6488 };
6489
6490 assert!(
6491 owned_generation_is_dangling(OAuthProvider::Xai, &config),
6492 "OAuth mode pointing at a missing owned file is the #5032 bricked state"
6493 );
6494 // Specificity: OAuth selected but no generation configured is the normal
6495 // "needs auth" state, not a dangling pointer.
6496 let unconfigured = Config {
6497 provider: Some(ProviderKind::Xai.as_str().to_string()),
6498 providers: Some(crate::config::ProvidersConfig {
6499 xai: crate::config::ProviderConfig {
6500 auth_mode: Some("oauth".to_string()),
6501 ..Default::default()
6502 },
6503 ..Default::default()
6504 }),
6505 ..Default::default()
6506 };
6507 assert!(
6508 !owned_generation_is_dangling(OAuthProvider::Xai, &unconfigured),
6509 "an unconfigured OAuth mode must not be reported as dangling"
6510 );
6511
6512 clear_dangling_generation(OAuthProvider::Xai, Some(&config_path))
6513 .expect("best-effort repair must clear the stale pointer");
6514
6515 let persisted = fs::read_to_string(&config_path).unwrap();
6516 assert!(
6517 !persisted.contains(stale),
6518 "stale generation pointer must be cleared: {persisted}"
6519 );
6520 assert!(
6521 persisted.contains("auth_mode = \"oauth\""),
6522 "the user's OAuth mode selection must be preserved: {persisted}"
6523 );
6524 }
6525
6526 #[test]
6527 fn activation_rejects_a_non_string_generation_pointer_without_staging_credentials() {
6528 let _guard = crate::test_support::lock_test_env();
6529 let dir = TempDir::new().unwrap();
6530 let home = dir
6531 .path()
6532 .canonicalize()
6533 .expect("canonical temp root")
6534 .join("owned-home");
6535 let config_path = dir.path().join("config.toml");
6536 let original = "[providers.xai]\noauth_credential_generation = { path = \"attacker\" }\n";
6537 fs::write(&config_path, original).unwrap();
6538 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6539
6540 let error = activate_login(
6541 pending_login("must-not-stage", "must-not-persist"),
6542 Some(&config_path),
6543 None,
6544 )
6545 .expect_err("non-string generation pointers must fail closed");
6546 assert!(error.to_string().contains("not activated"), "{error:#}");
6547 assert_eq!(fs::read_to_string(&config_path).unwrap(), original);
6548 let credentials = home.join("credentials");
6549 assert!(credentials.exists(), "lifecycle lock directory is durable");
6550 assert!(fs::read_dir(credentials).unwrap().all(|entry| {
6551 let name = entry.unwrap().file_name();
6552 let name = name.to_string_lossy();
6553 name != codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
6554 && !codewhale_config::is_valid_xai_oauth_generation(&name)
6555 }));
6556 }
6557
6558 #[cfg(unix)]
6559 #[test]
6560 fn activation_failure_cleans_unreferenced_stage_and_keeps_live_config_inert() {
6561 let _guard = crate::test_support::lock_test_env();
6562 let dir = TempDir::new().unwrap();
6563 let home = dir
6564 .path()
6565 .canonicalize()
6566 .expect("canonical temp root")
6567 .join("owned-home");
6568 let config_dir = dir.path().join("config-parent");
6569 fs::create_dir(&config_dir).unwrap();
6570 let config_path = config_dir.join("config.toml");
6571 fs::write(&config_path, "[providers.xai]\nauth_mode = \"api_key\"\n").unwrap();
6572 fs::create_dir(config_dir.join("config.toml.bak")).unwrap();
6573 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6574 let legacy_path = seed_legacy_owned_credentials();
6575 let legacy_before = fs::read(&legacy_path).unwrap();
6576 let mut live = Config {
6577 providers: Some(crate::config::ProvidersConfig {
6578 xai: crate::config::ProviderConfig {
6579 auth_mode: Some("api_key".to_string()),
6580 api_key: Some("still-selected".to_string()),
6581 ..Default::default()
6582 },
6583 ..Default::default()
6584 }),
6585 ..Default::default()
6586 };
6587
6588 let result = activate_login(
6589 pending_login("must-be-cleaned", "must-not-persist"),
6590 Some(&config_path),
6591 Some(&mut live),
6592 );
6593 let error = result.expect_err("invalid backup path must fail activation");
6594 assert!(error.to_string().contains("not activated"), "{error:#}");
6595 let live_xai = live
6596 .provider_config_for(&live.test_identity_for_kind(ProviderKind::Xai))
6597 .unwrap();
6598 assert_eq!(live_xai.auth_mode.as_deref(), Some("api_key"));
6599 assert!(live_xai.oauth_credential_generation.is_none());
6600 assert_eq!(
6601 fs::read(&legacy_path).unwrap(),
6602 legacy_before,
6603 "legacy owned credentials must remain byte-identical until activation commits"
6604 );
6605 let credentials = home.join("credentials");
6606 if credentials.exists() {
6607 assert!(
6608 fs::read_dir(credentials).unwrap().all(|entry| {
6609 let name = entry.unwrap().file_name();
6610 let name = name.to_string_lossy();
6611 name == codewhale_config::LEGACY_XAI_OAUTH_FILE_NAME
6612 || !codewhale_config::is_valid_xai_oauth_generation(&name)
6613 }),
6614 "failed activation must remove every unreferenced generation but retain legacy"
6615 );
6616 }
6617 assert!(
6618 !fs::read_to_string(config_path)
6619 .unwrap()
6620 .contains("must-be-cleaned")
6621 );
6622 }
6623
6624 #[test]
6625 fn missing_file_message_mentions_oauth_paths() {
6626 let _guard = crate::test_support::lock_test_env();
6627 let msg = missing_auth_message(OAuthProvider::Xai);
6628 assert!(msg.contains("xAI OAuth credentials not found"), "{msg}");
6629 assert!(msg.contains("external-consent"), "{msg}");
6630 assert!(msg.contains("Codewhale-owned OAuth storage"), "{msg}");
6631 assert!(msg.contains("XAI_API_KEY"), "{msg}");
6632 }
6633
6634 #[test]
6635 fn parse_rfc3339_accepts_zulu() {
6636 let ts = parse_rfc3339_secs("2026-07-09T12:00:00.000Z").expect("parse");
6637 assert!(ts > 0);
6638 }
6639
6640 #[test]
6641 fn device_code_constants_match_discovery_shape() {
6642 assert_eq!(
6643 DEFAULT_SCOPES.split_whitespace().collect::<Vec<_>>(),
6644 [
6645 "openid",
6646 "profile",
6647 "email",
6648 "offline_access",
6649 "api:access",
6650 "grok-cli:access",
6651 ]
6652 );
6653 assert_eq!(XAI_OIDC_ISSUER, "https://auth.x.ai");
6654 assert_eq!(GROK_OIDC_CLIENT_ID.len(), 36);
6655 }
6656
6657 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6658 async fn discovery_binds_to_advertised_endpoints() {
6659 let server = MockServer::start().await;
6660 Mock::given(method("GET"))
6661 .and(path("/.well-known/openid-configuration"))
6662 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6663 "issuer": server.uri(),
6664 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
6665 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
6666 })))
6667 .expect(1)
6668 .mount(&server)
6669 .await;
6670
6671 let endpoints = tokio::task::block_in_place(|| {
6672 discover_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri()).expect("discover endpoints")
6673 });
6674
6675 assert_eq!(
6676 endpoints,
6677 OAuthEndpoints {
6678 device_authorization_endpoint: Some(format!(
6679 "{}/oauth2/device-advertised",
6680 server.uri()
6681 )),
6682 token_endpoint: format!("{}/oauth2/token-advertised", server.uri()),
6683 }
6684 );
6685 }
6686
6687 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6688 async fn refresh_uses_discovered_token_endpoint() {
6689 let server = MockServer::start().await;
6690 Mock::given(method("GET"))
6691 .and(path("/.well-known/openid-configuration"))
6692 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6693 "issuer": server.uri(),
6694 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
6695 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
6696 })))
6697 .expect(1)
6698 .mount(&server)
6699 .await;
6700 Mock::given(method("POST"))
6701 .and(path("/oauth2/token-advertised"))
6702 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6703 "access_token": "refreshed-access",
6704 "refresh_token": "rotated-refresh",
6705 "expires_in": 3600
6706 })))
6707 .expect(1)
6708 .mount(&server)
6709 .await;
6710
6711 let token = tokio::task::block_in_place(|| {
6712 refresh_for_provider(
6713 OAuthProvider::Xai,
6714 &ReqwestOAuthFormClient,
6715 &server.uri(),
6716 GROK_OIDC_CLIENT_ID,
6717 "refresh-secret",
6718 )
6719 .expect("refresh token")
6720 });
6721
6722 assert_eq!(token.access_token.as_deref(), Some("refreshed-access"));
6723 assert_eq!(token.refresh_token.as_deref(), Some("rotated-refresh"));
6724 }
6725
6726 #[test]
6727 fn https_discovery_rejects_plaintext_endpoint_downgrade() {
6728 let error = validate_discovered_oauth_endpoint(
6729 Some("http://auth.x.ai/oauth2/device/code".to_string()),
6730 "device_authorization_endpoint",
6731 XAI_OIDC_ISSUER,
6732 )
6733 .expect_err("HTTPS issuer must reject an HTTP endpoint");
6734
6735 assert!(error.to_string().contains("downgrade"), "{error}");
6736 }
6737
6738 #[test]
6739 fn https_discovery_accepts_same_origin_with_explicit_default_port() {
6740 let endpoint = "https://auth.x.ai:443/oauth2/token";
6741 let validated = validate_discovered_oauth_endpoint(
6742 Some(endpoint.to_string()),
6743 "token_endpoint",
6744 XAI_OIDC_ISSUER,
6745 )
6746 .expect("URL origins normalize the explicit default HTTPS port");
6747
6748 assert_eq!(validated, endpoint);
6749 }
6750
6751 #[test]
6752 fn https_discovery_rejects_cross_origin_endpoint() {
6753 let error = validate_discovered_oauth_endpoint(
6754 Some("https://oauth.attacker.example/oauth2/token".to_string()),
6755 "token_endpoint",
6756 XAI_OIDC_ISSUER,
6757 )
6758 .expect_err("discovered OAuth endpoints must stay on the issuer origin");
6759
6760 assert!(error.to_string().contains("different origin"), "{error}");
6761 }
6762
6763 #[test]
6764 fn discovery_rejects_mismatched_issuer() {
6765 let error = validate_discovered_issuer(
6766 Some("https://attacker.example".to_string()),
6767 XAI_OIDC_ISSUER,
6768 )
6769 .expect_err("discovery issuer must bind to the request issuer");
6770
6771 assert!(error.to_string().contains("does not match"), "{error}");
6772 }
6773
6774 #[test]
6775 fn oauth_error_details_collapse_control_whitespace() {
6776 let detail = oauth_failure_detail(
6777 Some("invalid_scope\nforged"),
6778 Some("bad\t scope\r\nnext line"),
6779 reqwest::StatusCode::BAD_REQUEST,
6780 );
6781
6782 assert!(
6783 !detail
6784 .chars()
6785 .any(|character| matches!(character, '\n' | '\r' | '\t')),
6786 "{detail}"
6787 );
6788 assert!(detail.contains("invalid_scope forged"), "{detail}");
6789 assert!(detail.contains("bad scope next line"), "{detail}");
6790 }
6791
6792 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6793 async fn discovery_failure_uses_documented_endpoint_fallback() {
6794 let server = MockServer::start().await;
6795 Mock::given(method("GET"))
6796 .and(path("/.well-known/openid-configuration"))
6797 .respond_with(
6798 ResponseTemplate::new(503)
6799 .set_body_raw("<html>temporarily unavailable</html>", "text/html"),
6800 )
6801 .expect(1)
6802 .mount(&server)
6803 .await;
6804
6805 let endpoints = tokio::task::block_in_place(|| {
6806 resolve_oauth_endpoints(&XAI_OAUTH_PARAMS, &server.uri())
6807 });
6808
6809 assert_eq!(
6810 endpoints,
6811 OAuthEndpoints {
6812 device_authorization_endpoint: Some(format!("{}/oauth2/device/code", server.uri())),
6813 token_endpoint: format!("{}/oauth2/token", server.uri()),
6814 }
6815 );
6816 }
6817
6818 /// End-to-end regression for the v0.9.4 dogfood failure (#5032): starting
6819 /// from the exact state the dogfood machine was bricked in — a
6820 /// `providers.xai.oauth_credential_generation` pointer whose credential
6821 /// file no longer exists — the full device flow (discovery, device-code
6822 /// request, token poll, activation) must succeed and replace the stale
6823 /// pointer instead of dying with "xAI login was not activated; provider
6824 /// configuration is unchanged".
6825 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6826 async fn device_login_end_to_end_recovers_from_dangling_generation_pointer() {
6827 let _guard = crate::test_support::lock_test_env();
6828 let server = MockServer::start().await;
6829 Mock::given(method("GET"))
6830 .and(path("/.well-known/openid-configuration"))
6831 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6832 "issuer": server.uri(),
6833 "device_authorization_endpoint": format!("{}/oauth2/device-advertised", server.uri()),
6834 "token_endpoint": format!("{}/oauth2/token-advertised", server.uri())
6835 })))
6836 .expect(1)
6837 .mount(&server)
6838 .await;
6839 Mock::given(method("POST"))
6840 .and(path("/oauth2/device-advertised"))
6841 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6842 "device_code": "device-token",
6843 "user_code": "CW-TEST",
6844 "verification_uri": format!("{}/verify", server.uri()),
6845 "expires_in": 60,
6846 "interval": 1
6847 })))
6848 .expect(1)
6849 .mount(&server)
6850 .await;
6851 Mock::given(method("POST"))
6852 .and(path("/oauth2/token-advertised"))
6853 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
6854 "access_token": "e2e-xai-access",
6855 "refresh_token": "e2e-xai-refresh",
6856 "expires_in": 3600,
6857 "token_type": "Bearer"
6858 })))
6859 .expect(1)
6860 .mount(&server)
6861 .await;
6862
6863 let dir = TempDir::new().unwrap();
6864 let home = dir
6865 .path()
6866 .canonicalize()
6867 .expect("canonical temp root")
6868 .join("owned-home");
6869 let config_path = dir.path().join("config.toml");
6870 // The exact dogfood-machine state: valid-looking generation pointer,
6871 // missing credential file.
6872 let stale = "xai-auth-39a2f3e766ab47f89490002cd04fe187.json";
6873 fs::write(
6874 &config_path,
6875 format!(
6876 "[providers.xai]\nauth_mode = \"oauth\"\noauth_credential_generation = \"{stale}\"\n"
6877 ),
6878 )
6879 .unwrap();
6880 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
6881
6882 // The login half runs through the unified flow; only activation is
6883 // still legacy here (3b-ii unifies it).
6884 let inputs = crate::oauth::ResolvedOAuthInputs {
6885 issuer: server.uri(),
6886 client_id: GROK_OIDC_CLIENT_ID.to_string(),
6887 scopes: DEFAULT_SCOPES.to_string(),
6888 open_browser: false,
6889 };
6890 let unified = tokio::task::block_in_place(|| {
6891 crate::oauth::device_code_login_with(
6892 crate::oauth::OAuthProvider::Xai,
6893 &inputs,
6894 &mut std::io::sink(),
6895 )
6896 })
6897 .expect("device login against mock xAI");
6898 let pending = unified;
6899 let mut live = Config::default();
6900 let activation = activate_login(pending, Some(&config_path), Some(&mut live))
6901 .expect("dangling pointer must not brick activation");
6902
6903 assert!(activation.auth_path.exists());
6904 let owned = fs::read_to_string(&activation.auth_path).unwrap();
6905 assert!(owned.contains("e2e-xai-access"));
6906 assert!(owned.contains("e2e-xai-refresh"));
6907 let persisted = fs::read_to_string(&config_path).unwrap();
6908 assert!(
6909 !persisted.contains(stale),
6910 "stale pointer must be replaced: {persisted}"
6911 );
6912 assert!(persisted.contains(activation.auth_path.file_name().unwrap().to_str().unwrap()));
6913 assert!(persisted.contains("auth_mode = \"oauth\""));
6914 assert!(
6915 credentials_valid(OAuthProvider::Xai, &live),
6916 "activated login must be usable"
6917 );
6918 }
6919
6920 #[test]
6921 fn apply_token_response_sets_expiry_from_expires_in() {
6922 let mut entry = OwnedAuthEntry {
6923 access_token: None,
6924 refresh_token: None,
6925 expires_at: None,
6926 id_token: None,
6927 account_id: None,
6928 oidc_issuer: None,
6929 oidc_client_id: None,
6930 originator: None,
6931 auth_mode: None,
6932 extra: BTreeMap::new(),
6933 };
6934 let token = OAuthTokenMaterial {
6935 earliest_refresh_at: None,
6936 scope: None,
6937 token_type: None,
6938 verified_chatgpt: None,
6939 id_token: None,
6940 access_token: Some("fresh-access".to_string()),
6941 refresh_token: Some("fresh-refresh".to_string()),
6942 expires_in: Some(3600),
6943 error: None,
6944 error_description: None,
6945 interval: None,
6946 };
6947 let before = now_unix_secs().expect("clock");
6948
6949 apply_token_response(
6950 OAuthProvider::Xai,
6951 &mut entry,
6952 XAI_OIDC_ISSUER,
6953 GROK_OIDC_CLIENT_ID,
6954 &token,
6955 )
6956 .expect("apply token");
6957
6958 assert_eq!(entry.access_token.as_deref(), Some("fresh-access"));
6959 assert_eq!(entry.refresh_token.as_deref(), Some("fresh-refresh"));
6960 let expires_at = entry
6961 .expires_at
6962 .as_deref()
6963 .and_then(parse_rfc3339_secs)
6964 .expect("expires_at set from expires_in");
6965 let after = now_unix_secs().expect("clock");
6966 assert!(
6967 expires_at >= before + 3600,
6968 "{expires_at} < {before} + 3600"
6969 );
6970 assert!(expires_at <= after + 3600, "{expires_at} > {after} + 3600");
6971 }
6972
6973 #[test]
6974 fn apply_token_response_rejects_missing_access_token() {
6975 let mut entry = OwnedAuthEntry {
6976 access_token: None,
6977 refresh_token: None,
6978 expires_at: None,
6979 id_token: None,
6980 account_id: None,
6981 oidc_issuer: None,
6982 oidc_client_id: None,
6983 originator: None,
6984 auth_mode: None,
6985 extra: BTreeMap::new(),
6986 };
6987 let token = OAuthTokenMaterial {
6988 earliest_refresh_at: None,
6989 scope: None,
6990 token_type: None,
6991 verified_chatgpt: None,
6992 id_token: None,
6993 access_token: None,
6994 refresh_token: None,
6995 expires_in: None,
6996 error: None,
6997 error_description: None,
6998 interval: None,
6999 };
7000
7001 let error = apply_token_response(
7002 OAuthProvider::Xai,
7003 &mut entry,
7004 XAI_OIDC_ISSUER,
7005 GROK_OIDC_CLIENT_ID,
7006 &token,
7007 )
7008 .expect_err("missing access_token must fail");
7009
7010 assert!(
7011 error.to_string().contains("missing access_token"),
7012 "{error}"
7013 );
7014 }
7015
7016 struct MockTokenClient {
7017 responses: Mutex<Vec<(u16, String)>>,
7018 posts: Mutex<Vec<MockPost>>,
7019 }
7020
7021 impl MockTokenClient {
7022 fn new(responses: Vec<(u16, String)>) -> Self {
7023 Self {
7024 responses: Mutex::new(responses),
7025 posts: Mutex::new(Vec::new()),
7026 }
7027 }
7028 }
7029
7030 impl crate::oauth::OAuthFormClient for MockTokenClient {
7031 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
7032 self.posts.lock().expect("posts").push((
7033 url.to_string(),
7034 form.iter()
7035 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
7036 .collect(),
7037 ));
7038 let mut responses = self.responses.lock().expect("responses");
7039 anyhow::ensure!(
7040 !responses.is_empty(),
7041 "mock issuer has no remaining responses"
7042 );
7043 Ok(responses.remove(0))
7044 }
7045 }
7046
7047 fn jwt_with_exp(exp: u64) -> String {
7048 let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
7049 format!("header.{payload}.sig")
7050 }
7051
7052 #[test]
7053 fn siwc_legacy_credentials_require_reauthorization_and_revoke_retains_host() {
7054 let _lock = crate::test_support::lock_test_env();
7055 let home = tempfile::tempdir().expect("temp home");
7056 let root = home.path().canonicalize().expect("canonical home");
7057 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
7058 let config_path = root.join("config.toml");
7059 std::fs::write(&config_path, "").expect("empty config");
7060
7061 let pending = pending_login_with_id_token_for_test(
7062 OAuthProvider::Chatgpt,
7063 "access-1",
7064 "refresh-1",
7065 Some(&jwt_with_account("acct-7")),
7066 );
7067 let activation = activate_login(pending, Some(&config_path), None).expect("activate");
7068 assert!(activation.auth_path.exists());
7069 let persisted = std::fs::read_to_string(root.join("config.toml")).expect("config");
7070 assert!(persisted.contains("chatgpt-auth-"));
7071 assert!(persisted.contains("auth_mode = \"oauth\""));
7072 assert!(!persisted.contains("access-1"), "{persisted}");
7073
7074 let generation = toml::from_str::<toml::Value>(&persisted)
7075 .unwrap()["providers"]["openai_codex"]["oauth_credential_generation"]
7076 .as_str()
7077 .unwrap()
7078 .to_string();
7079 let mut config = Config {
7080 provider: Some(ProviderKind::OpenaiCodex.as_str().to_string()),
7081 ..Config::default()
7082 };
7083 config
7084 .mark_codewhale_owned_chatgpt_oauth(generation.clone())
7085 .expect("admitted OAuth fixture");
7086 assert!(credentials_valid(OAuthProvider::Chatgpt, &config));
7087
7088 let stale = jwt_with_exp(1_000_000_000);
7089 let scope = format!("{CHATGPT_OAUTH_ISSUER}::{CHATGPT_OAUTH_CLIENT_ID}");
7090 let raw = serde_json::json!({
7091 &scope: {
7092 "access_token": stale,
7093 "refresh_token": "refresh-old",
7094 // Conflicting metadata must not suppress the existing refresh.
7095 "expires_at": rfc3339_from_now(3600),
7096 "oidc_issuer": CHATGPT_OAUTH_ISSUER,
7097 "oidc_client_id": CHATGPT_OAUTH_CLIENT_ID,
7098 "originator": CHATGPT_OAUTH_ORIGINATOR
7099 }
7100 });
7101 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
7102 store.write(
7103 &generation,
7104 serde_json::to_vec_pretty(&raw).unwrap().as_slice(),
7105 true,
7106 )
7107 })
7108 .unwrap();
7109
7110 let mock = MockTokenClient::new(vec![(
7111 200,
7112 serde_json::json!({
7113 "access_token": "access-2",
7114 "refresh_token": "refresh-2",
7115 "expires_in": 3600
7116 })
7117 .to_string(),
7118 )]);
7119 let error = match get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &mock) {
7120 Ok(_) => panic!("legacy credentials must not be sent to the official API"),
7121 Err(error) => error.to_string(),
7122 };
7123 assert!(error.contains("reauthorization"), "{error}");
7124 assert!(
7125 mock.posts.lock().unwrap().is_empty(),
7126 "legacy tokens must not be refreshed"
7127 );
7128
7129 let revoke_mock = MockTokenClient::new(vec![(200, String::new())]);
7130 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
7131 revoke_owned_login_locked_with(
7132 OAuthProvider::Chatgpt,
7133 Some(&config_path),
7134 None,
7135 store,
7136 &revoke_mock,
7137 )
7138 })
7139 .expect("revoke");
7140 let after = std::fs::read_to_string(&config_path).expect("config after revoke");
7141 assert!(!after.contains("chatgpt-auth-"), "{after}");
7142 assert!(
7143 root.join("credentials")
7144 .join(codewhale_config::CHATGPT_HOST_FILE_NAME)
7145 .exists(),
7146 "signout retains the host and registration"
7147 );
7148 let posts = revoke_mock.posts.lock().unwrap();
7149 assert!(
7150 posts.iter().any(|(url, _)| url.contains("/oauth/revoke")),
7151 "{posts:?}"
7152 );
7153 }
7154
7155 #[test]
7156 fn chatgpt_revoke_removes_durable_tokens_when_live_mirror_refuses() {
7157 let _lock = crate::test_support::lock_test_env();
7158 let home = tempfile::tempdir().expect("temp home");
7159 let root = home.path().canonicalize().expect("canonical home");
7160 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
7161 let config_path = root.join("config.toml");
7162 std::fs::write(&config_path, "").expect("empty config");
7163 let pending = pending_login_with_id_token_for_test(
7164 OAuthProvider::Chatgpt,
7165 "access-1",
7166 "refresh-1",
7167 Some(&jwt_with_account("acct-7")),
7168 );
7169 let activation = activate_login(pending, Some(&config_path), None).expect("activate");
7170 assert!(activation.auth_path.exists());
7171 let mut live = Config::default();
7172 live.providers
7173 .get_or_insert_with(Default::default)
7174 .custom
7175 .insert(
7176 ProviderKind::OpenaiCodex.as_str().to_string(),
7177 crate::config::ProviderConfig {
7178 kind: Some("openai-compatible".to_string()),
7179 base_url: Some("http://localhost:1234/v1".to_string()),
7180 ..Default::default()
7181 },
7182 );
7183 assert!(live.clear_codewhale_owned_chatgpt_oauth().is_err());
7184 let mock = MockTokenClient::new(vec![(200, String::new())]);
7185 let error = codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
7186 revoke_owned_login_locked_with(
7187 OAuthProvider::Chatgpt,
7188 Some(&config_path),
7189 Some(&mut live),
7190 store,
7191 &mock,
7192 )
7193 })
7194 .expect_err("live mirror refusal remains visible");
7195 assert!(error.to_string().contains("Signed out locally"));
7196 assert!(
7197 !activation.auth_path.exists(),
7198 "local tokens cannot survive a refused mirror"
7199 );
7200 let after = std::fs::read_to_string(&config_path).expect("config after revoke");
7201 assert!(
7202 !after.contains("chatgpt-auth-"),
7203 "durable generation pointer removed"
7204 );
7205 assert_eq!(
7206 mock.posts.lock().unwrap().len(),
7207 1,
7208 "one captured remote revoke"
7209 );
7210 }
7211
7212 #[test]
7213 fn debug_impls_redact_secrets() {
7214 let entry = OwnedAuthEntry {
7215 access_token: Some("secret-access".into()),
7216 refresh_token: Some("secret-refresh".into()),
7217 expires_at: None,
7218 id_token: Some("secret-id".into()),
7219 account_id: None,
7220 oidc_issuer: None,
7221 oidc_client_id: None,
7222 originator: None,
7223 auth_mode: None,
7224 extra: BTreeMap::new(),
7225 };
7226 let rendered = format!("{entry:?}");
7227 assert!(rendered.contains("<redacted>"));
7228 assert!(!rendered.contains("secret-access"));
7229 assert!(!rendered.contains("secret-refresh"));
7230 assert!(!rendered.contains("secret-id"));
7231
7232 let activation = OAuthActivation {
7233 credentials: OwnedOAuthCredentials {
7234 access_token: "secret-access".into(),
7235 account_id: None,
7236 account_label: None,
7237 refresh_token: Some("secret-refresh".into()),
7238 expires_at: None,
7239 issuer: "issuer".into(),
7240 client_id: "client".into(),
7241 },
7242 config_path: PathBuf::from("/tmp/config.toml"),
7243 auth_path: PathBuf::from("/tmp/auth.json"),
7244 provider: OAuthProvider::Chatgpt,
7245 account_label: Some("a@example.com (plus)".into()),
7246 replaced: None,
7247 };
7248 let rendered = format!("{activation:?}");
7249 assert!(rendered.contains("<redacted>"));
7250 assert!(!rendered.contains("secret-access"));
7251 }
7252 struct SiwcSigningFixture {
7253 pair: ring::signature::Ed25519KeyPair,
7254 keys: JwkSet,
7255 }
7256
7257 impl SiwcSigningFixture {
7258 fn new() -> Self {
7259 use ring::signature::KeyPair as _;
7260 let pkcs8 =
7261 ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
7262 .unwrap();
7263 let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
7264 let keys = serde_json::from_value(serde_json::json!({"keys": [{
7265 "kty": "OKP", "crv": "Ed25519", "kid": "siwc-fixture", "alg": "EdDSA", "use": "sig",
7266 "x": URL_SAFE_NO_PAD.encode(pair.public_key().as_ref())
7267 }]}))
7268 .unwrap();
7269 Self { pair, keys }
7270 }
7271 fn token(&self, claims: Value) -> String {
7272 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","kid":"siwc-fixture"}"#);
7273 let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
7274 let message = format!("{header}.{payload}");
7275 format!(
7276 "{message}.{}",
7277 URL_SAFE_NO_PAD.encode(self.pair.sign(message.as_bytes()).as_ref())
7278 )
7279 }
7280 fn id_claims(&self) -> Value {
7281 serde_json::json!({"iss": CHATGPT_OAUTH_ISSUER, "aud": "oaiapp_codewhale_test", "sub": "test-sub", "nonce": "pending-nonce", "exp": now_unix_secs().unwrap() + 3600})
7282 }
7283 fn access(&self, subject: &str) -> String {
7284 self.token(serde_json::json!({"iss": CHATGPT_OAUTH_ISSUER, "aud": CHATGPT_OAUTH_RESOURCE, "sub": subject, "client_id": "oaiapp_codewhale_test", "scope": CHATGPT_OAUTH_SCOPE, "exp": now_unix_secs().unwrap() + 3600}))
7285 }
7286 }
7287
7288 #[test]
7289 fn siwc_signed_identity_requires_signature_issuer_audience_expiry_nonce_and_account() {
7290 let signer = SiwcSigningFixture::new();
7291 let verify = |token: &str| {
7292 verify_chatgpt_id_token(
7293 token,
7294 &signer.keys,
7295 CHATGPT_OAUTH_ISSUER,
7296 "oaiapp_codewhale_test",
7297 Some("pending-nonce"),
7298 Some("test-sub"),
7299 "urn:uuid:00000000-0000-4000-8000-000000000001",
7300 )
7301 };
7302 let claims = signer.id_claims();
7303 let signed = signer.token(claims.clone());
7304 assert_eq!(verify(&signed).unwrap().subject, "test-sub");
7305 for (field, wrong) in [
7306 ("iss", Value::from("https://other.invalid")),
7307 ("aud", Value::from("oaiapp_other")),
7308 (
7309 "aud",
7310 serde_json::json!(["oaiapp_codewhale_test", "another-audience"]),
7311 ),
7312 ("exp", Value::from(1)),
7313 ("nonce", Value::from("another-nonce")),
7314 ("sub", Value::from("another-subject")),
7315 ("sub", Value::from("")),
7316 ] {
7317 let mut changed = claims.clone();
7318 changed[field] = wrong;
7319 assert!(
7320 verify(&signer.token(changed)).is_err(),
7321 "accepted invalid {field}"
7322 );
7323 }
7324 for required in ["iss", "aud", "exp", "sub", "nonce"] {
7325 let mut changed = claims.clone();
7326 changed.as_object_mut().unwrap().remove(required);
7327 assert!(
7328 verify(&signer.token(changed)).is_err(),
7329 "accepted missing {required}"
7330 );
7331 }
7332 let other_signer = SiwcSigningFixture::new();
7333 assert!(verify(&other_signer.token(claims.clone())).is_err());
7334 let parts: Vec<_> = signed.split('.').collect();
7335 let bad_payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"attacker"}"#);
7336 assert!(verify(&format!("{}.{}.{}", parts[0], bad_payload, parts[2])).is_err());
7337 let symmetric_header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","kid":"siwc-fixture"}"#);
7338 assert!(verify(&format!("{}.{}.{}", symmetric_header, parts[1], parts[2])).is_err());
7339 }
7340
7341 #[test]
7342 fn siwc_callback_and_grant_guards_reject_registration_substitution() {
7343 assert!(issued_callback_client_id(CHATGPT_OAUTH_CLIENT_ID, None).is_err());
7344 assert!(
7345 issued_callback_client_id(CHATGPT_OAUTH_CLIENT_ID, Some(CHATGPT_OAUTH_CLIENT_ID))
7346 .is_err()
7347 );
7348 assert_eq!(
7349 issued_callback_client_id(CHATGPT_OAUTH_CLIENT_ID, Some("oaiapp_first")).unwrap(),
7350 "oaiapp_first"
7351 );
7352 assert_eq!(
7353 issued_callback_client_id("oaiapp_first", None).unwrap(),
7354 "oaiapp_first"
7355 );
7356 assert!(issued_callback_client_id("oaiapp_first", Some("oaiapp_other")).is_err());
7357 assert!(require_chatgpt_scopes(Some("openid resource.invoke")).is_err());
7358 assert!(require_chatgpt_scopes(Some("chatgpt.tokens.use.direct")).is_err());
7359 assert!(require_chatgpt_scopes(Some(CHATGPT_OAUTH_SCOPE)).is_ok());
7360 assert!(parse_callback_query(&CHATGPT_OAUTH_PARAMS, "code=x&state=a&state=b").is_err());
7361 assert!(
7362 parse_callback_query(&CHATGPT_OAUTH_PARAMS, "code=x&state=a&error=denied").is_err()
7363 );
7364 let error = parse_callback_query(&CHATGPT_OAUTH_PARAMS, "error=access_denied").unwrap();
7365 assert!(accept_callback("pending", error).is_err());
7366 let success = parse_callback_query(
7367 &CHATGPT_OAUTH_PARAMS,
7368 "code=secret-code&state=a&client_id=oaiapp_first",
7369 )
7370 .unwrap();
7371 assert!(!format!("{success:?}").contains("secret-code"));
7372 }
7373
7374 #[test]
7375 fn siwc_signed_access_requires_resource_account_registration_and_plan_scope() {
7376 let signer = SiwcSigningFixture::new();
7377 let registration = verify_chatgpt_id_token(
7378 &signer.token(signer.id_claims()),
7379 &signer.keys,
7380 CHATGPT_OAUTH_ISSUER,
7381 "oaiapp_codewhale_test",
7382 Some("pending-nonce"),
7383 None,
7384 "host",
7385 )
7386 .unwrap();
7387 assert!(
7388 verify_chatgpt_access_token(&signer.access("test-sub"), &signer.keys, &registration)
7389 .is_ok()
7390 );
7391 assert!(
7392 verify_chatgpt_access_token(
7393 &signer.access("another-subject"),
7394 &signer.keys,
7395 &registration
7396 )
7397 .is_err()
7398 );
7399 for (field, wrong) in [
7400 ("aud", CHATGPT_OAUTH_ISSUER),
7401 ("client_id", "oaiapp_other"),
7402 ("scope", "openid resource.invoke"),
7403 ] {
7404 let mut claims = serde_json::json!({"iss": CHATGPT_OAUTH_ISSUER, "aud": CHATGPT_OAUTH_RESOURCE, "sub": "test-sub", "client_id": "oaiapp_codewhale_test", "scope": CHATGPT_OAUTH_SCOPE, "exp": now_unix_secs().unwrap() + 3600});
7405 claims[field] = Value::from(wrong);
7406 let token = signer.token(claims);
7407 assert!(
7408 verify_chatgpt_access_token(&token, &signer.keys, &registration).is_err(),
7409 "accepted invalid {field}"
7410 );
7411 }
7412 }
7413
7414 struct SiwcRefreshClient {
7415 keys: JwkSet,
7416 access: String,
7417 posts: Mutex<Vec<MockPost>>,
7418 }
7419 impl OAuthFormClient for SiwcRefreshClient {
7420 fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
7421 self.posts.lock().unwrap().push((
7422 url.to_string(),
7423 form.iter()
7424 .map(|(k, v)| (k.to_string(), v.to_string()))
7425 .collect(),
7426 ));
7427 Ok((200, serde_json::json!({"access_token": self.access, "refresh_token": "rotated-refresh", "expires_in": 3600, "scope": CHATGPT_OAUTH_SCOPE, "token_type": "Bearer"}).to_string()))
7428 }
7429 fn chatgpt_jwks(&self, _: &str) -> Result<JwkSet> {
7430 Ok(self.keys.clone())
7431 }
7432 }
7433
7434 #[test]
7435 fn siwc_refresh_rotates_atomically_and_rejects_a_different_account() {
7436 let _lock = crate::test_support::lock_test_env();
7437 let home = tempfile::tempdir().unwrap();
7438 let root = home.path().canonicalize().unwrap();
7439 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
7440 let signer = SiwcSigningFixture::new();
7441 let mut config = Config {
7442 provider: Some("openai-codex".into()),
7443 ..Default::default()
7444 };
7445 install_test_chatgpt_registration(&mut config).unwrap();
7446 let original = get_owned_credentials_read_only(OAuthProvider::Chatgpt, &config).unwrap();
7447 let original_scope = crate::client::CodewhaleClient::new(&config)
7448 .unwrap()
7449 .chatgpt_reasoning_api;
7450 let path = configured_owned_auth_file_path(OAuthProvider::Chatgpt, &config)
7451 .unwrap()
7452 .unwrap();
7453 let name = path.file_name().unwrap().to_str().unwrap();
7454 codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
7455 let mut file = load_owned_auth_file_from_store(store, name)?.unwrap();
7456 for entry in file.values_mut() {
7457 entry.expires_at = Some("2000-01-01T00:00:00Z".to_string());
7458 }
7459 write_auth_file_to_store(store, name, &file, true)
7460 })
7461 .unwrap();
7462 let before = std::fs::read(&path).unwrap();
7463 let wrong = SiwcRefreshClient {
7464 keys: signer.keys.clone(),
7465 access: signer.access("another-account"),
7466 posts: Mutex::new(Vec::new()),
7467 };
7468 assert!(get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &wrong).is_err());
7469 assert_eq!(std::fs::read(&path).unwrap(), before);
7470 let right = SiwcRefreshClient {
7471 keys: signer.keys.clone(),
7472 access: signer.access("test-sub"),
7473 posts: Mutex::new(Vec::new()),
7474 };
7475 let refreshed =
7476 get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &right).unwrap();
7477 assert_ne!(original.access_token, refreshed.access_token);
7478 assert_eq!(
7479 original_scope,
7480 crate::client::CodewhaleClient::new(&config)
7481 .unwrap()
7482 .chatgpt_reasoning_api
7483 );
7484 assert_eq!(refreshed.refresh_token.as_deref(), Some("rotated-refresh"));
7485 assert_eq!(
7486 official_chatgpt_registration(&config).unwrap().subject,
7487 "test-sub"
7488 );
7489 assert!(get_owned_credentials_read_only(OAuthProvider::Chatgpt, &config).is_ok());
7490 let posts = right.posts.lock().unwrap();
7491 assert_eq!(
7492 posts[0].0,
7493 "https://auth.openai.com/api/accounts/oauth/token"
7494 );
7495 assert!(
7496 posts[0]
7497 .1
7498 .contains(&("client_id".to_string(), "oaiapp_codewhale_test".to_string()))
7499 );
7500 assert!(
7501 posts[0]
7502 .1
7503 .contains(&("resource".to_string(), CHATGPT_OAUTH_RESOURCE.to_string()))
7504 );
7505 assert!(
7506 posts[0]
7507 .1
7508 .contains(&("refresh_token".to_string(), "siwc-test-refresh".to_string()))
7509 );
7510 }
7511
7512 #[test]
7513 fn siwc_replacing_account_drops_previous_registration_and_token() {
7514 let _lock = crate::test_support::lock_test_env();
7515 let home = tempfile::tempdir().unwrap();
7516 let root = home.path().canonicalize().unwrap();
7517 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
7518 let config_path = root.join("config.toml");
7519 std::fs::write(&config_path, "").unwrap();
7520 let mut config = Config::default();
7521 let first = pending_login_for_test(OAuthProvider::Chatgpt, "first-access", "first-refresh");
7522 activate_login(first, Some(&config_path), Some(&mut config)).unwrap();
7523 let mut next =
7524 pending_login_for_test(OAuthProvider::Chatgpt, "next-access", "next-refresh");
7525 next.client_id = "oaiapp_next".to_string();
7526 let registration = next.token.verified_chatgpt.as_mut().unwrap();
7527 registration.client_id = "oaiapp_next".to_string();
7528 registration.subject = "next-sub".to_string();
7529 let activated = activate_login(next, Some(&config_path), Some(&mut config)).unwrap();
7530 let registration = official_chatgpt_registration(&config).unwrap();
7531 assert_eq!(registration.subject, "next-sub");
7532 assert_eq!(registration.client_id, "oaiapp_next");
7533 let raw = std::fs::read_to_string(activated.auth_path).unwrap();
7534 assert!(!raw.contains("first-access"));
7535 assert!(!raw.contains("first-refresh"));
7536 let mut file: AuthFile = serde_json::from_str(&raw).unwrap();
7537 assert_eq!(file.len(), 1);
7538 let (_, mut entry) = select_entry(OAuthProvider::Chatgpt, &mut file).unwrap();
7539 entry.account_id = Some("unverified-account".to_string());
7540 assert!(registration_from_entry(&entry).is_err());
7541 entry.account_id = Some("next-sub".to_string());
7542 entry.access_token = Some("unverified-replacement".to_string());
7543 assert!(registration_from_entry(&entry).is_err());
7544 }
7545 }
7546
7546 lines RUST