Skip to main content

scitadel_core/
credentials.rs

1//! Credential resolution: keychain → environment variable → config file → None.
2//!
3//! Credentials are stored in the macOS Keychain via the `security` CLI tool,
4//! which avoids per-binary authorization prompts that the `keyring` crate triggers.
5//! Each source has one or more named secrets stored as generic passwords
6//! with service "scitadel" and the key as the account name.
7
8const SERVICE: &str = "scitadel";
9
10/// A credential that was not found, with instructions on how to set it.
11#[derive(Debug)]
12pub struct MissingCredential {
13    pub source: String,
14    pub keys: Vec<String>,
15    pub env_vars: Vec<String>,
16    pub remedy: String,
17}
18
19impl std::fmt::Display for MissingCredential {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        write!(
22            f,
23            "{source} credentials not configured.\n\n\
24             To authenticate, run:\n  scitadel auth login {source}\n\n\
25             Or set environment variable(s):\n{env_hint}",
26            source = self.source,
27            env_hint = self
28                .env_vars
29                .iter()
30                .map(|v| format!("  {v}=<value>"))
31                .collect::<Vec<_>>()
32                .join("\n"),
33        )
34    }
35}
36
37/// Get a credential value by trying keychain, then env var, then config fallback.
38///
39/// Returns the first non-empty value found, or `None`.
40pub fn resolve(keychain_key: &str, env_var: &str, config_fallback: &str) -> Option<String> {
41    // 1. Keychain (via security CLI — no per-binary auth prompts)
42    if let Some(val) = get_keychain(keychain_key) {
43        return Some(val);
44    }
45
46    // 2. Environment variable
47    if let Ok(val) = std::env::var(env_var)
48        && !val.is_empty()
49    {
50        return Some(val);
51    }
52
53    // 3. Config fallback
54    if !config_fallback.is_empty() {
55        return Some(config_fallback.to_string());
56    }
57
58    None
59}
60
61/// Store a credential in the macOS Keychain via `security` CLI.
62pub fn store(key: &str, value: &str) -> Result<(), String> {
63    // Delete existing entry first (security add-generic-password fails if it exists)
64    let _ = std::process::Command::new("security")
65        .args(["delete-generic-password", "-s", SERVICE, "-a", key])
66        .output();
67
68    let output = std::process::Command::new("security")
69        .args([
70            "add-generic-password",
71            "-s",
72            SERVICE,
73            "-a",
74            key,
75            "-w",
76            value,
77            "-U", // update if exists
78        ])
79        .output()
80        .map_err(|e| format!("failed to run security CLI: {e}"))?;
81
82    if output.status.success() {
83        Ok(())
84    } else {
85        let stderr = String::from_utf8_lossy(&output.stderr);
86        Err(format!("failed to store credential '{key}': {stderr}"))
87    }
88}
89
90/// Delete a credential from the macOS Keychain via `security` CLI.
91pub fn delete(key: &str) -> Result<(), String> {
92    let output = std::process::Command::new("security")
93        .args(["delete-generic-password", "-s", SERVICE, "-a", key])
94        .output()
95        .map_err(|e| format!("failed to run security CLI: {e}"))?;
96
97    if output.status.success() {
98        Ok(())
99    } else {
100        let stderr = String::from_utf8_lossy(&output.stderr);
101        Err(format!("failed to delete credential '{key}': {stderr}"))
102    }
103}
104
105/// Get a credential from the macOS Keychain via `security` CLI.
106///
107/// Uses `security find-generic-password -s scitadel -a <key> -w` which
108/// reads from the login keychain without triggering per-binary auth prompts.
109pub fn get_keychain(key: &str) -> Option<String> {
110    let output = std::process::Command::new("security")
111        .args(["find-generic-password", "-s", SERVICE, "-a", key, "-w"])
112        .output()
113        .ok()?;
114
115    if output.status.success() {
116        let val = String::from_utf8_lossy(&output.stdout).trim().to_string();
117        if val.is_empty() { None } else { Some(val) }
118    } else {
119        None
120    }
121}
122
123/// Definitions of credentials required by each source.
124pub struct SourceCredentials {
125    pub source: &'static str,
126    pub keys: &'static [CredentialKey],
127}
128
129pub struct CredentialKey {
130    pub keychain_key: &'static str,
131    pub env_var: &'static str,
132    pub label: &'static str,
133    pub secret: bool,
134}
135
136pub static PATENTSVIEW_CREDENTIALS: SourceCredentials = SourceCredentials {
137    source: "patentsview",
138    keys: &[CredentialKey {
139        keychain_key: "patentsview.api_key",
140        env_var: "SCITADEL_PATENTSVIEW_KEY",
141        label: "API key",
142        secret: true,
143    }],
144};
145
146pub static PUBMED_CREDENTIALS: SourceCredentials = SourceCredentials {
147    source: "pubmed",
148    keys: &[CredentialKey {
149        keychain_key: "pubmed.api_key",
150        env_var: "SCITADEL_PUBMED_API_KEY",
151        label: "API key",
152        secret: true,
153    }],
154};
155
156pub static OPENALEX_CREDENTIALS: SourceCredentials = SourceCredentials {
157    source: "openalex",
158    keys: &[CredentialKey {
159        keychain_key: "openalex.email",
160        env_var: "SCITADEL_OPENALEX_EMAIL",
161        label: "Email (for polite pool)",
162        secret: false,
163    }],
164};
165
166pub static LENS_CREDENTIALS: SourceCredentials = SourceCredentials {
167    source: "lens",
168    keys: &[CredentialKey {
169        keychain_key: "lens.api_token",
170        env_var: "SCITADEL_LENS_TOKEN",
171        label: "API token",
172        secret: true,
173    }],
174};
175
176pub static EPO_CREDENTIALS: SourceCredentials = SourceCredentials {
177    source: "epo",
178    keys: &[
179        CredentialKey {
180            keychain_key: "epo.consumer_key",
181            env_var: "SCITADEL_EPO_KEY",
182            label: "Consumer key",
183            secret: false,
184        },
185        CredentialKey {
186            keychain_key: "epo.consumer_secret",
187            env_var: "SCITADEL_EPO_SECRET",
188            label: "Consumer secret",
189            secret: true,
190        },
191    ],
192};
193
194/// All sources that support authentication.
195pub static ALL_SOURCES: &[&SourceCredentials] = &[
196    &PUBMED_CREDENTIALS,
197    &OPENALEX_CREDENTIALS,
198    &PATENTSVIEW_CREDENTIALS,
199    &LENS_CREDENTIALS,
200    &EPO_CREDENTIALS,
201];
202
203/// Check whether a source has all required credentials configured.
204pub fn check_source(creds: &SourceCredentials) -> Result<(), MissingCredential> {
205    let missing: Vec<&CredentialKey> = creds
206        .keys
207        .iter()
208        .filter(|k| resolve(k.keychain_key, k.env_var, "").is_none())
209        .collect();
210
211    if missing.is_empty() {
212        Ok(())
213    } else {
214        Err(MissingCredential {
215            source: creds.source.to_string(),
216            keys: missing.iter().map(|k| k.keychain_key.to_string()).collect(),
217            env_vars: missing.iter().map(|k| k.env_var.to_string()).collect(),
218            remedy: format!("scitadel auth login {}", creds.source),
219        })
220    }
221}