返回 CodeWhale
oauth.rs
根目录 / crates / tui / src / oauth.rs
1 //! OpenAI Codex / ChatGPT OAuth credential loading.
2 //!
3 //! External Codex CLI credentials are read only after an exact, provider-scoped
4 //! consent grant. Codewhale never refreshes or rewrites that external file.
5 //!
6 //! # Security
7 //!
8 //! Token values are never logged or printed. All debug representations
9 //! redact sensitive fields.
10
11 use std::path::PathBuf;
12 use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14 use anyhow::{Context, Result, bail};
15 use base64::Engine as _;
16 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
17 use codewhale_config::ExternalCredentialReadGrant;
18 use serde::Deserialize;
19
20 /// OAuth token payload stored in `auth.json`.
21 #[derive(Debug, Clone, Deserialize)]
22 #[serde(rename_all = "snake_case")]
23 struct AuthTokens {
24 access_token: Option<String>,
25 account_id: Option<String>,
26 }
27
28 /// Top-level structure of Codex CLI's `auth.json`.
29 #[derive(Debug, Clone, Deserialize)]
30 #[serde(rename_all = "snake_case")]
31 struct CodexAuthFile {
32 tokens: Option<AuthTokens>,
33 }
34
35 /// Resolved OAuth credentials ready for API use.
36 #[derive(Debug, Clone)]
37 pub struct CodexCredentials {
38 pub access_token: String,
39 pub account_id: Option<String>,
40 }
41
42 /// JWT claims subset for expiry extraction.
43 #[derive(Debug, Deserialize)]
44 struct JwtClaims {
45 exp: Option<u64>,
46 }
47
48 /// Resolve the path to the Codex auth file.
49 ///
50 /// Priority:
51 /// 1. `OPENAI_CODEX_AUTH_FILE` env var
52 /// 2. `$CODEX_HOME/auth.json`
53 /// 3. `~/.codex/auth.json`
54 pub fn auth_file_path() -> PathBuf {
55 if let Ok(path) = std::env::var("OPENAI_CODEX_AUTH_FILE") {
56 let p = PathBuf::from(&path);
57 if !p.as_os_str().is_empty() {
58 return codewhale_config::resolve_external_credential_path(&p).unwrap_or(p);
59 }
60 }
61 let codex_home = std::env::var("CODEX_HOME")
62 .map(PathBuf::from)
63 .unwrap_or_else(|_| {
64 crate::config::effective_home_dir()
65 .unwrap_or_else(|| PathBuf::from("."))
66 .join(".codex")
67 });
68 let path = codex_home.join("auth.json");
69 codewhale_config::resolve_external_credential_path(&path).unwrap_or(path)
70 }
71
72 /// Try to extract `exp` (epoch seconds) from a JWT without verifying
73 /// the signature. Returns `None` on any parse failure.
74 fn jwt_expiry_seconds(token: &str) -> Option<u64> {
75 let parts: Vec<&str> = token.split('.').collect();
76 if parts.len() < 2 {
77 return None;
78 }
79 let payload = parts[1];
80 let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
81 let claims: JwtClaims = serde_json::from_slice(&decoded).ok()?;
82 claims.exp
83 }
84
85 /// Check whether an access token is expired, with a 60-second safety margin.
86 fn token_is_expired(access_token: &str) -> bool {
87 match jwt_expiry_seconds(access_token) {
88 Some(exp) => {
89 let now = SystemTime::now()
90 .duration_since(UNIX_EPOCH)
91 .unwrap_or(Duration::ZERO)
92 .as_secs();
93 // 60-second safety margin
94 now + 60 >= exp
95 }
96 // If we can't prove freshness, fail closed. External credentials are
97 // never refreshed by Codewhale.
98 None => true,
99 }
100 }
101
102 /// Load Codex credentials from the auth file.
103 ///
104 /// Returns `Ok(None)` if the file doesn't exist or has no usable tokens.
105 /// Returns `Err` only on parse/IO errors that aren't "file not found".
106 fn load_credentials(grant: &ExternalCredentialReadGrant) -> Result<Option<CodexCredentials>> {
107 let Some(contents) = crate::external_credentials::read_to_string(grant)? else {
108 return Ok(None);
109 };
110 let auth: CodexAuthFile = serde_json::from_str(&contents).map_err(|_| {
111 anyhow::anyhow!(
112 "Codex credential file {} is not valid credential JSON",
113 codewhale_config::quote_os_path(grant.path())
114 )
115 })?;
116 let tokens = match auth.tokens {
117 Some(t) => t,
118 None => return Ok(None),
119 };
120 let access_token = match tokens.access_token {
121 Some(t) if !t.trim().is_empty() => t,
122 _ => return Ok(None),
123 };
124 Ok(Some(CodexCredentials {
125 access_token,
126 account_id: tokens.account_id,
127 }))
128 }
129
130 /// Prompt-free, non-refreshing readiness check for picker/onboarding surfaces.
131 /// It reads process-level token variables only; no file or network access occurs.
132 #[must_use]
133 pub fn credentials_from_env() -> Option<CodexCredentials> {
134 ["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
135 .iter()
136 .find_map(|name| {
137 std::env::var(name)
138 .ok()
139 .filter(|token| !token.trim().is_empty())
140 })
141 .map(|access_token| CodexCredentials {
142 access_token,
143 account_id: codex_account_id_env(),
144 })
145 }
146
147 /// Validate only the stored OAuth file, excluding token environment
148 /// overrides so config-vs-env provenance remains truthful.
149 #[must_use]
150 pub fn stored_credentials_present(grant: &ExternalCredentialReadGrant) -> bool {
151 load_credentials(grant)
152 .ok()
153 .flatten()
154 .is_some_and(|credentials| !token_is_expired(&credentials.access_token))
155 }
156
157 /// Load read-only credentials from the exact external path authorized by
158 /// `grant`. Expired tokens fail with guidance; they are never refreshed.
159 pub fn get_credentials(grant: &ExternalCredentialReadGrant) -> Result<CodexCredentials> {
160 let creds = load_credentials(grant)?.with_context(missing_auth_message)?;
161
162 // Check if the access token is still valid.
163 if !token_is_expired(&creds.access_token) {
164 return Ok(creds);
165 }
166
167 bail!(
168 "Codex access token in {} is expired. Read-only consent never refreshes or rewrites another CLI's credentials. Run `codex login`, or provide OPENAI_CODEX_ACCESS_TOKEN for this process.",
169 codewhale_config::quote_os_path(grant.path())
170 )
171 }
172
173 #[must_use]
174 pub fn missing_auth_message() -> String {
175 format!(
176 "OpenAI Codex OAuth credentials are unavailable.\n\
177 \n\
178 Codewhale checks OPENAI_CODEX_ACCESS_TOKEN and CODEX_ACCESS_TOKEN automatically.\n\
179 Access to the Codex CLI file is disabled by default. After `codex login`, grant read-only access explicitly with:\n\
180 `codewhale auth external-consent --provider openai-codex --mode read-only --path {}`\n\
181 Read-only access never refreshes or rewrites the Codex CLI file.",
182 codewhale_config::quote_os_path(&auth_file_path())
183 )
184 }
185
186 /// Read a ChatGPT account id from env overrides only.
187 fn codex_account_id_env() -> Option<String> {
188 for var in ["OPENAI_CODEX_ACCOUNT_ID", "CODEX_ACCOUNT_ID"] {
189 if let Ok(value) = std::env::var(var) {
190 let trimmed = value.trim();
191 if !trimmed.is_empty() {
192 return Some(trimmed.to_string());
193 }
194 }
195 }
196 None
197 }
198
199 #[cfg(test)]
200 mod tests {
201 use super::*;
202
203 fn grant(path: &std::path::Path) -> ExternalCredentialReadGrant {
204 codewhale_config::ExternalCredentialConsentToml::read_only(
205 codewhale_config::ProviderKind::OpenaiCodex,
206 codewhale_config::ExternalCredentialSource::CodexCli,
207 path.to_path_buf(),
208 )
209 .read_grant(
210 codewhale_config::ProviderKind::OpenaiCodex,
211 codewhale_config::ExternalCredentialSource::CodexCli,
212 path,
213 )
214 .expect("test read grant")
215 }
216
217 #[test]
218 fn jwt_expiry_parses_valid_token() {
219 // A minimal JWT with {"exp": 9999999999} as payload.
220 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
221 let token = format!("header.{payload}.signature");
222 assert_eq!(jwt_expiry_seconds(&token), Some(9999999999));
223 }
224
225 #[test]
226 fn jwt_expiry_returns_none_for_malformed() {
227 assert_eq!(jwt_expiry_seconds("not.a.jwt"), None);
228 assert_eq!(jwt_expiry_seconds(""), None);
229 assert_eq!(jwt_expiry_seconds("x"), None);
230 }
231
232 #[test]
233 fn token_is_expired_detects_future() {
234 // Far future — should not be expired.
235 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
236 let token = format!("header.{payload}.sig");
237 assert!(!token_is_expired(&token));
238 }
239
240 #[test]
241 fn token_is_expired_detects_past() {
242 // Way in the past.
243 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
244 let token = format!("header.{payload}.sig");
245 assert!(token_is_expired(&token));
246 }
247
248 #[test]
249 fn credential_presence_rejects_empty_and_malformed_files_without_refresh() {
250 let _lock = crate::test_support::lock_test_env();
251 let home = tempfile::tempdir().expect("temp Codex home");
252 let auth_path = home
253 .path()
254 .canonicalize()
255 .expect("canonical temp root")
256 .join("auth.json");
257 let _auth = crate::test_support::EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &auth_path);
258 let _access = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
259 let _legacy_access = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
260 let grant = grant(&auth_path);
261
262 std::fs::write(&auth_path, "{}").expect("empty auth");
263 crate::external_credentials::reset_side_effect_trap();
264 assert!(!stored_credentials_present(&grant));
265 assert_eq!(
266 crate::external_credentials::side_effect_trap_counts(),
267 (1, 1)
268 );
269 assert_eq!(
270 crate::external_credentials::complete_side_effect_trap_counts(),
271 (1, 1, 0, 0, 0)
272 );
273 std::fs::write(&auth_path, "{not-json").expect("malformed auth");
274 crate::external_credentials::reset_side_effect_trap();
275 assert!(!stored_credentials_present(&grant));
276 assert_eq!(
277 crate::external_credentials::side_effect_trap_counts(),
278 (1, 1)
279 );
280
281 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":9999999999}");
282 let access_token = format!("header.{payload}.signature");
283 std::fs::write(
284 &auth_path,
285 serde_json::to_vec(&serde_json::json!({
286 "tokens": {"access_token": access_token}
287 }))
288 .expect("valid auth json"),
289 )
290 .expect("valid auth");
291 crate::external_credentials::reset_side_effect_trap();
292 assert!(stored_credentials_present(&grant));
293 assert_eq!(
294 crate::external_credentials::side_effect_trap_counts(),
295 (1, 1)
296 );
297 }
298
299 #[test]
300 fn expired_external_token_fails_without_refresh_or_rewrite() {
301 let _lock = crate::test_support::lock_test_env();
302 let home = tempfile::tempdir().expect("temp Codex home");
303 let auth_path = home
304 .path()
305 .canonicalize()
306 .expect("canonical temp root")
307 .join("auth.json");
308 let payload = URL_SAFE_NO_PAD.encode(b"{\"exp\":1000000000}");
309 let access_token = format!("header.{payload}.signature");
310 let raw = serde_json::to_string_pretty(&serde_json::json!({
311 "tokens": {
312 "access_token": access_token,
313 "refresh_token": "must-never-be-used",
314 "account_id": "acct-test",
315 "future_field": {"preserve": true}
316 },
317 "future_top_level": [1, 2, 3]
318 }))
319 .expect("auth fixture");
320 std::fs::write(&auth_path, &raw).expect("expired auth fixture");
321
322 crate::external_credentials::reset_side_effect_trap();
323 let error = get_credentials(&grant(&auth_path))
324 .expect_err("read-only external tokens must not refresh");
325 assert!(error.to_string().contains("never refreshes or rewrites"));
326 assert_eq!(
327 crate::external_credentials::side_effect_trap_counts(),
328 (1, 1)
329 );
330 assert_eq!(
331 std::fs::read_to_string(&auth_path).expect("unchanged auth file"),
332 raw
333 );
334 }
335
336 #[test]
337 fn auth_file_path_respects_env() {
338 // Just verify it returns a path without panicking.
339 let path = auth_file_path();
340 assert!(path.to_string_lossy().contains("auth.json"));
341 }
342
343 #[test]
344 fn missing_auth_message_explains_disabled_default_and_explicit_consent() {
345 let _lock = crate::test_support::lock_test_env();
346 let message = missing_auth_message();
347
348 assert!(message.contains("OpenAI Codex OAuth credentials are unavailable"));
349 assert!(message.contains("OPENAI_CODEX_ACCESS_TOKEN"));
350 assert!(message.contains("CODEX_ACCESS_TOKEN"));
351 assert!(message.contains(&codewhale_config::quote_os_path(&auth_file_path())));
352 assert!(message.contains("codex login"));
353 assert!(message.contains("external-consent"));
354 assert!(message.contains("disabled by default"));
355 }
356
357 #[test]
358 fn malformed_codex_credential_errors_never_echo_file_contents() {
359 let _lock = crate::test_support::lock_test_env();
360 let home = tempfile::tempdir().expect("temp Codex home");
361 let path = home.path().canonicalize().unwrap().join("auth.json");
362 let sentinel = "must-not-appear-in-diagnostics";
363 std::fs::write(
364 &path,
365 format!(r#"{{"tokens":{{"access_token":{{"secret":"{sentinel}"}}}}}}"#),
366 )
367 .unwrap();
368
369 let error = load_credentials(&grant(&path)).expect_err("malformed schema");
370 let message = format!("{error:#}");
371 assert!(message.contains("not valid credential JSON"), "{message}");
372 assert!(!message.contains(sentinel), "{message}");
373 }
374 }
375
375 lines RUST