1use crate::persistence::{
2 CrossProcessFileLock, atomic_write_with_permissions, in_process_file_lock,
3};
4use base64::Engine;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::{collections::BTreeMap, env, fmt, fs, io::Read, path::Path};
8
9use super::{CustomProviderConfig, McPaths};
10use anyhow::Context;
11
12const OAUTH_REFRESH_SKEW_SECS: i64 = 300;
13
14#[derive(Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
15pub struct Auth {
16 #[serde(default)]
17 pub api_key: Option<String>,
18 #[serde(flatten)]
19 pub providers: BTreeMap<String, AuthProviderRecord>,
20}
21
22impl fmt::Debug for Auth {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 f.debug_struct("Auth")
25 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
26 .field("providers", &self.providers)
27 .finish()
28 }
29}
30
31#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(tag = "type")]
33pub enum AuthProviderRecord {
34 #[serde(rename = "api_key")]
35 ApiKey { key: String },
36 #[serde(rename = "oauth")]
37 OAuth {
38 access: String,
39 #[serde(default)]
40 refresh: Option<String>,
41 #[serde(default)]
42 expires: Option<i64>,
43 #[serde(default, rename = "accountId")]
44 account_id: Option<String>,
45 },
46}
47
48impl fmt::Debug for AuthProviderRecord {
49 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50 match self {
51 Self::ApiKey { .. } => f
52 .debug_struct("ApiKey")
53 .field("key", &"<redacted>")
54 .finish(),
55 Self::OAuth {
56 refresh, expires, ..
57 } => f
58 .debug_struct("OAuth")
59 .field("access", &"<redacted>")
60 .field("refresh", &refresh.as_ref().map(|_| "<redacted>"))
61 .field("expires", expires)
62 .field("account_id", &"<redacted>")
63 .finish(),
64 }
65 }
66}
67
68#[derive(Clone, PartialEq, Eq)]
69pub enum ProviderCredential {
70 ApiKey {
71 key: String,
72 },
73 OAuth {
74 access: String,
75 account_id: Option<String>,
76 },
77 NoAuth,
78}
79
80impl fmt::Debug for ProviderCredential {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::ApiKey { .. } => f
84 .debug_struct("ApiKey")
85 .field("key", &"<redacted>")
86 .finish(),
87 Self::OAuth { account_id, .. } => f
88 .debug_struct("OAuth")
89 .field("access", &"<redacted>")
90 .field("account_id", &account_id.as_ref().map(|_| "<redacted>"))
91 .finish(),
92 Self::NoAuth => f.debug_struct("NoAuth").finish(),
93 }
94 }
95}
96
97impl ProviderCredential {
98 pub fn is_configured(&self) -> bool {
99 match self {
100 Self::ApiKey { key } => !key.is_empty(),
101 Self::OAuth { access, .. } => !access.is_empty(),
102 Self::NoAuth => true,
103 }
104 }
105
106 pub fn is_supported_for_provider(&self, provider: &str) -> bool {
107 matches!(
108 (provider, self),
109 (crate::providers::OPENAI_CODEX_PROVIDER, Self::OAuth { .. })
110 | (crate::providers::ANTHROPIC_PROVIDER, Self::ApiKey { .. })
111 | (crate::providers::CLAUDE_CODE_PROVIDER, Self::ApiKey { .. })
112 | (crate::providers::CLAUDE_CODE_PROVIDER, Self::OAuth { .. })
113 )
114 }
115}
116
117fn is_auth_ready_for_provider(provider: &str, credential: &ProviderCredential) -> bool {
118 credential.is_configured() && credential.is_supported_for_provider(provider)
119}
120
121#[derive(Clone, PartialEq, Eq)]
122pub enum AuthState {
123 Ready {
124 provider: String,
125 credential: ProviderCredential,
126 },
127 Missing {
128 provider: String,
129 },
130}
131
132impl fmt::Debug for AuthState {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 match self {
135 Self::Ready {
136 provider,
137 credential,
138 } => f
139 .debug_struct("Ready")
140 .field("provider", provider)
141 .field("credential", credential)
142 .finish(),
143 Self::Missing { provider } => f
144 .debug_struct("Missing")
145 .field("provider", provider)
146 .finish(),
147 }
148 }
149}
150
151impl AuthState {
152 pub fn for_provider(provider: impl Into<String>, auth: Option<&ProviderCredential>) -> Self {
153 Self::for_provider_with_custom(provider, auth, &BTreeMap::new())
154 }
155
156 pub fn for_provider_with_custom(
157 provider: impl Into<String>,
158 auth: Option<&ProviderCredential>,
159 custom_providers: &BTreeMap<String, CustomProviderConfig>,
160 ) -> Self {
161 let provider = provider.into();
162 match auth {
163 Some(credential)
164 if (is_auth_ready_for_provider(&provider, credential)
165 || (custom_providers.contains_key(&provider)
166 && matches!(
167 credential,
168 ProviderCredential::ApiKey { .. } | ProviderCredential::NoAuth
169 )
170 && credential.is_configured())) =>
171 {
172 Self::Ready {
173 provider,
174 credential: credential.clone(),
175 }
176 }
177 _ => Self::Missing { provider },
178 }
179 }
180
181 pub fn is_ready(&self) -> bool {
182 matches!(self, Self::Ready { .. })
183 }
184
185 pub fn provider(&self) -> &str {
186 match self {
187 Self::Ready { provider, .. } | Self::Missing { provider } => provider,
188 }
189 }
190
191 pub fn credential(&self) -> Option<&ProviderCredential> {
192 match self {
193 Self::Ready { credential, .. } => Some(credential),
194 Self::Missing { .. } => None,
195 }
196 }
197}
198
199pub fn resolve_provider_credential(
200 provider: &str,
201 auth: &Auth,
202 cli_api_key: Option<String>,
203 custom_providers: &BTreeMap<String, CustomProviderConfig>,
204) -> anyhow::Result<Option<ProviderCredential>> {
205 if provider == crate::providers::CLAUDE_CODE_PROVIDER {
206 return crate::providers::claude_code::auth::ClaudeCodeAuth::readiness_credential(auth);
207 }
208 if let Some(custom) = custom_providers.get(provider) {
209 return Ok(match &custom.api_key_env_var {
210 Some(env_var) => env::var(env_var)
211 .ok()
212 .filter(|key| !key.is_empty())
213 .map(|key| ProviderCredential::ApiKey { key }),
214 None => Some(ProviderCredential::NoAuth),
215 });
216 }
217 if provider == crate::providers::ANTHROPIC_PROVIDER {
218 if let Ok(key) = env::var("ANTHROPIC_API_KEY")
219 && !key.is_empty()
220 {
221 return Ok(Some(ProviderCredential::ApiKey { key }));
222 }
223 if let Some(record) = auth.providers.get(provider) {
224 return Ok(match record {
225 AuthProviderRecord::ApiKey { key } => {
226 Some(ProviderCredential::ApiKey { key: key.clone() })
227 }
228 AuthProviderRecord::OAuth { .. } => None,
229 });
230 }
231 return Ok(None);
232 }
233 if provider != crate::providers::OPENAI_CODEX_PROVIDER {
234 if let Some(key) = cli_api_key.filter(|key| !key.is_empty()) {
235 return Ok(Some(ProviderCredential::ApiKey { key }));
236 }
237 if let Ok(key) = env::var("MC_API_KEY")
238 && !key.is_empty()
239 {
240 return Ok(Some(ProviderCredential::ApiKey { key }));
241 }
242 }
243 if provider == "openai" {
244 return Ok(None);
245 }
246 if let Some(record) = auth.providers.get(provider) {
247 return Ok(match record {
248 AuthProviderRecord::ApiKey { key } => {
249 Some(ProviderCredential::ApiKey { key: key.clone() })
250 }
251 AuthProviderRecord::OAuth {
252 access,
253 refresh,
254 expires,
255 account_id,
256 } => {
257 if provider == crate::providers::OPENAI_CODEX_PROVIDER
258 && oauth_requires_refresh(*expires)
259 && refresh.as_ref().is_none_or(|value| value.is_empty())
260 {
261 None
262 } else {
263 Some(ProviderCredential::OAuth {
264 access: access.clone(),
265 account_id: account_id.clone(),
266 })
267 }
268 }
269 });
270 }
271 Ok(None)
272}
273
274fn oauth_requires_refresh(expires: Option<i64>) -> bool {
275 expires
276 .is_none_or(|expires| expires <= chrono::Utc::now().timestamp() + OAUTH_REFRESH_SKEW_SECS)
277}
278
279pub(crate) fn extract_chatgpt_account_id_from_jwt(access_token: &str) -> anyhow::Result<String> {
280 let value = jwt_payload_json(access_token)?;
281 standard_chatgpt_account_id_claim(&value)
282 .filter(|id| !id.is_empty())
283 .map(ToString::to_string)
284 .ok_or_else(|| anyhow::anyhow!("Codex access token is missing ChatGPT account id claim"))
285}
286
287pub(crate) fn extract_oauth_account_id_from_jwt(access_token: &str) -> Option<String> {
288 let value = jwt_payload_json(access_token).ok()?;
289 standard_chatgpt_account_id_claim(&value)
290 .or_else(|| value.get("chatgpt_account_id").and_then(Value::as_str))
291 .or_else(|| value.get("accountId").and_then(Value::as_str))
292 .filter(|id| !id.is_empty())
293 .map(ToString::to_string)
294}
295
296fn jwt_payload_json(access_token: &str) -> anyhow::Result<Value> {
297 let payload = access_token
298 .split('.')
299 .nth(1)
300 .ok_or_else(|| anyhow::anyhow!("Codex access token is not a JWT"))?;
301 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload)?;
302 Ok(serde_json::from_slice(&decoded)?)
303}
304
305fn standard_chatgpt_account_id_claim(value: &Value) -> Option<&str> {
306 value
307 .get("https://api.openai.com/auth.chatgpt_account_id")
308 .and_then(Value::as_str)
309 .or_else(|| {
310 value
311 .get("https://api.openai.com/auth")
312 .and_then(|auth| auth.get("chatgpt_account_id"))
313 .and_then(Value::as_str)
314 })
315}
316
317pub fn read_auth(paths: &McPaths) -> anyhow::Result<Auth> {
318 let auth_lock = auth_file_lock(&paths.auth_file)?;
319 let _auth_guard = auth_lock
320 .lock()
321 .map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
322 let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
323 read_auth_unlocked(paths)
324}
325
326fn read_auth_unlocked(paths: &McPaths) -> anyhow::Result<Auth> {
327 read_auth_file(&paths.auth_file).map(|auth| auth.unwrap_or_default())
328}
329
330fn read_auth_file(path: &Path) -> anyhow::Result<Option<Auth>> {
331 validate_auth_file_path_before_open(path)?;
332
333 let mut options = fs::OpenOptions::new();
334 options.read(true);
335 #[cfg(unix)]
336 {
337 use std::os::unix::fs::OpenOptionsExt;
338 options.custom_flags(o_no_follow());
339 }
340 let mut file = match options.open(path) {
341 Ok(file) => file,
342 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
343 #[cfg(unix)]
344 Err(error) if path_is_symlink(path) => {
345 let _ = error;
346 anyhow::bail!(
347 "auth.json must be a regular private file; symlinked auth files are not allowed"
348 );
349 }
350 Err(error) => {
351 return Err(error).with_context(|| format!("failed to read {}", path.display()));
352 }
353 };
354 validate_open_auth_file(&file)?;
355 let mut text = String::new();
356 file.read_to_string(&mut text)
357 .with_context(|| format!("failed to read {}", path.display()))?;
358 Ok(Some(serde_json::from_str(&text)?))
359}
360
361fn validate_open_auth_file(file: &fs::File) -> anyhow::Result<()> {
362 let metadata = file.metadata()?;
363 if !metadata.is_file() {
364 anyhow::bail!("auth.json must be a regular private file");
365 }
366 #[cfg(unix)]
367 {
368 use std::os::unix::fs::PermissionsExt;
369 if metadata.permissions().mode() & 0o077 != 0 {
370 anyhow::bail!("auth.json permissions must be private/owner-only (0600 or stricter)");
371 }
372 }
373 Ok(())
374}
375
376#[cfg(windows)]
377fn validate_auth_file_path_before_open(path: &Path) -> anyhow::Result<()> {
378 use std::os::windows::fs::MetadataExt;
379 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
380
381 match fs::symlink_metadata(path) {
382 Ok(metadata) if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 => {
383 anyhow::bail!(
384 "auth.json must be a regular private file; reparse-point auth files are not allowed"
385 );
386 }
387 Ok(_) => Ok(()),
388 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
389 Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
390 }
391}
392
393#[cfg(not(windows))]
394fn validate_auth_file_path_before_open(_path: &Path) -> anyhow::Result<()> {
395 Ok(())
396}
397
398#[cfg(unix)]
399fn path_is_symlink(path: &Path) -> bool {
400 fs::symlink_metadata(path)
401 .map(|metadata| metadata.file_type().is_symlink())
402 .unwrap_or(false)
403}
404
405#[cfg(all(unix, target_os = "linux"))]
406fn o_no_follow() -> i32 {
407 0x20000
408}
409
410#[cfg(all(unix, not(target_os = "linux")))]
411fn o_no_follow() -> i32 {
412 0x100
413}
414
415#[cfg_attr(not(test), allow(dead_code))]
416pub(crate) fn write_auth(paths: &McPaths, auth: &Auth) -> anyhow::Result<()> {
417 fs::create_dir_all(&paths.root)?;
418 let auth_lock = auth_file_lock(&paths.auth_file)?;
419 let _auth_guard = auth_lock
420 .lock()
421 .map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
422 let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
423 write_auth_unlocked(paths, auth)
424}
425
426fn write_auth_unlocked(paths: &McPaths, auth: &Auth) -> anyhow::Result<()> {
427 fs::create_dir_all(&paths.root)?;
428 atomic_write_with_permissions(
429 &paths.auth_file,
430 serde_json::to_string_pretty(auth)?.as_bytes(),
431 Some(0o600),
432 )?;
433 Ok(())
434}
435
436pub(crate) fn update_auth(paths: &McPaths, mutate: impl FnOnce(&mut Auth)) -> anyhow::Result<Auth> {
437 fs::create_dir_all(&paths.root)?;
438 let auth_lock = auth_file_lock(&paths.auth_file)?;
439 let _auth_guard = auth_lock
440 .lock()
441 .map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
442 let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
443 let mut auth = read_auth_unlocked(paths)?;
444 mutate(&mut auth);
445 write_auth_unlocked(paths, &auth)?;
446 Ok(auth)
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct LogoutAuthRemoval {
451 pub provider_id: String,
452 pub removed: bool,
453 pub auth: Auth,
454}
455
456pub fn remove_provider_auth(
457 paths: &McPaths,
458 provider_id: &str,
459) -> anyhow::Result<LogoutAuthRemoval> {
460 let auth_lock = auth_file_lock(&paths.auth_file)?;
461 let _auth_guard = auth_lock
462 .lock()
463 .map_err(|_| anyhow::anyhow!("auth lock was poisoned"))?;
464 let _file_guard = CrossProcessFileLock::acquire(&paths.auth_file)?;
465 let mut auth = read_auth_unlocked(paths)?;
466 let removed = auth.providers.remove(provider_id).is_some();
467 if removed {
468 write_auth_unlocked(paths, &auth)?;
469 }
470 Ok(LogoutAuthRemoval {
471 provider_id: provider_id.to_string(),
472 removed,
473 auth,
474 })
475}
476
477fn auth_file_lock(path: &Path) -> anyhow::Result<std::sync::Arc<std::sync::Mutex<()>>> {
478 in_process_file_lock(path, "auth")
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[cfg(unix)]
486 fn set_mode(path: &std::path::Path, mode: u32) {
487 use std::os::unix::fs::PermissionsExt;
488 let mut permissions = fs::metadata(path).unwrap().permissions();
489 permissions.set_mode(mode);
490 fs::set_permissions(path, permissions).unwrap();
491 }
492
493 #[test]
494 fn concurrent_auth_updates_preserve_independent_providers() {
495 let temp = tempfile::TempDir::new().unwrap();
496 let paths = McPaths::from_root(temp.path().join("mc"));
497 let left = paths.clone();
498 let right = paths.clone();
499
500 let left = std::thread::spawn(move || {
501 update_auth(&left, |auth| {
502 auth.providers.insert(
503 "provider-a".to_string(),
504 AuthProviderRecord::ApiKey {
505 key: "key-a".to_string(),
506 },
507 );
508 })
509 .unwrap();
510 });
511 let right = std::thread::spawn(move || {
512 update_auth(&right, |auth| {
513 auth.providers.insert(
514 "provider-b".to_string(),
515 AuthProviderRecord::ApiKey {
516 key: "key-b".to_string(),
517 },
518 );
519 })
520 .unwrap();
521 });
522 left.join().unwrap();
523 right.join().unwrap();
524
525 let auth = read_auth(&paths).unwrap();
526 assert!(auth.providers.contains_key("provider-a"));
527 assert!(auth.providers.contains_key("provider-b"));
528 }
529
530 #[cfg(unix)]
531 #[test]
532 fn read_auth_rejects_symlink_with_no_follow_open_on_unix() {
533 use std::os::unix::fs::symlink;
534
535 let temp = tempfile::TempDir::new().unwrap();
536 let paths = McPaths::from_root(temp.path().join("mc"));
537 fs::create_dir_all(&paths.root).unwrap();
538 let target = temp.path().join("target-auth.json");
539 fs::write(&target, r#"{"api_key":"target-secret"}"#).unwrap();
540 set_mode(&target, 0o600);
541 symlink(&target, &paths.auth_file).unwrap();
542
543 let error = read_auth(&paths).unwrap_err().to_string();
544
545 assert!(error.contains("auth.json"), "{error}");
546 assert!(
547 error.contains("symlink") || error.contains("regular private file"),
548 "{error}"
549 );
550 assert!(!error.contains("target-secret"), "{error}");
551 }
552
553 #[cfg(windows)]
554 #[test]
555 fn read_auth_rejects_reparse_point_on_windows() {
556 use std::os::windows::fs::symlink_file;
557
558 let temp = tempfile::TempDir::new().unwrap();
559 let paths = McPaths::from_root(temp.path().join("mc"));
560 fs::create_dir_all(&paths.root).unwrap();
561 let target = temp.path().join("target-auth.json");
562 fs::write(&target, r#"{"api_key":"target-secret"}"#).unwrap();
563 if let Err(error) = symlink_file(&target, &paths.auth_file) {
564 if error.kind() == std::io::ErrorKind::PermissionDenied {
565 return;
566 }
567 panic!("symlink_file failed: {error}");
568 }
569
570 let error = read_auth(&paths).unwrap_err().to_string();
571
572 assert!(
573 error.contains("reparse") || error.contains("regular private file"),
574 "{error}"
575 );
576 assert!(!error.contains("target-secret"), "{error}");
577 }
578
579 #[test]
580 fn claude_code_auth_state_accepts_oauth_and_api_key_but_not_no_auth() {
581 assert!(
582 AuthState::for_provider(
583 crate::providers::CLAUDE_CODE_PROVIDER,
584 Some(&ProviderCredential::OAuth {
585 access: "cc-access".to_string(),
586 account_id: None,
587 }),
588 )
589 .is_ready()
590 );
591 assert!(
592 AuthState::for_provider(
593 crate::providers::CLAUDE_CODE_PROVIDER,
594 Some(&ProviderCredential::ApiKey {
595 key: "sk-ant-api-test".to_string(),
596 }),
597 )
598 .is_ready()
599 );
600 assert!(
601 !AuthState::for_provider(
602 crate::providers::CLAUDE_CODE_PROVIDER,
603 Some(&ProviderCredential::NoAuth),
604 )
605 .is_ready()
606 );
607 }
608
609 #[test]
610 fn claude_code_resolve_uses_provider_keyed_api_key_fallback_only() {
611 let env = crate::test_support::env::env_lock();
612 let _saved = [
613 env.save("MC_API_KEY"),
614 env.save("OPENAI_API_KEY"),
615 env.save("ANTHROPIC_API_KEY"),
616 env.save("MC_CLAUDE_CODE_CREDENTIALS_PATH"),
617 ];
618 let temp = tempfile::TempDir::new().unwrap();
619 env.set_var(
620 "MC_CLAUDE_CODE_CREDENTIALS_PATH",
621 temp.path().join("missing-claude-credentials.json"),
622 );
623 env.set_var("MC_API_KEY", "mc-key");
624 env.set_var("OPENAI_API_KEY", "openai-key");
625 env.set_var("ANTHROPIC_API_KEY", "anthropic-key");
626 let auth = Auth {
627 providers: BTreeMap::from([(
628 crate::providers::CLAUDE_CODE_PROVIDER.to_string(),
629 AuthProviderRecord::ApiKey {
630 key: "provider-key".to_string(),
631 },
632 )]),
633 ..Auth::default()
634 };
635
636 let credential = resolve_provider_credential(
637 crate::providers::CLAUDE_CODE_PROVIDER,
638 &auth,
639 Some("cli-key".to_string()),
640 &BTreeMap::new(),
641 )
642 .unwrap()
643 .unwrap();
644
645 assert_eq!(
646 credential,
647 ProviderCredential::ApiKey {
648 key: "provider-key".to_string()
649 }
650 );
651 }
652}