Skip to main content

magi_code/config/
auth.rs

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}