Skip to main content

modde_sources/nexus/
auth.rs

1//! Nexus API-key acquisition and storage: resolving a key from the configured
2//! sources (`OAuth`, config file, environment, system keyring) and validating
3//! it against the Nexus API.
4
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8use keyring_core::{Entry, Error as KeyringError};
9use modde_core::paths;
10use modde_core::settings::AppSettings;
11use reqwest::Client;
12use serde::Deserialize;
13use tracing::{debug, info, warn};
14
15use crate::error::{SourceResult, status_error};
16
17const KEYRING_SERVICE: &str = "modde";
18const KEYRING_KEY: &str = "nexus-api-key";
19
20#[derive(Debug, Deserialize)]
21struct ValidateResponse {
22    #[serde(default)]
23    is_premium: bool,
24    name: Option<String>,
25}
26
27/// Where a resolved Nexus API key was loaded from.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum ApiKeySource {
30    OAuth,
31    ModdeConfigFile,
32    Environment,
33    Keyring,
34    EnvironmentFile,
35    LegacySettingsToml,
36}
37
38impl ApiKeySource {
39    /// A short human-readable label naming this key source.
40    #[must_use]
41    pub fn label(&self) -> &'static str {
42        match self {
43            Self::OAuth => "OAuth token",
44            Self::ModdeConfigFile => "modde config file",
45            Self::Environment => "NEXUS_API_KEY",
46            Self::Keyring => "system keyring",
47            Self::EnvironmentFile => "NEXUS_API_KEY_FILE",
48            Self::LegacySettingsToml => "legacy settings.toml",
49        }
50    }
51}
52
53/// A resolved Nexus API key together with the source it came from.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct LoadedApiKey {
56    pub key: String,
57    pub source: ApiKeySource,
58}
59
60/// Store the Nexus API key in the system keyring.
61pub fn store_api_key(api_key: &str) -> Result<()> {
62    let entry = keyring_entry(KEYRING_SERVICE, KEYRING_KEY)?;
63    entry
64        .set_password(api_key)
65        .context("failed to store API key in keyring")?;
66    info!("Nexus API key stored in system keyring");
67    Ok(())
68}
69
70/// Delete the Nexus API key from the system keyring.
71pub fn delete_api_key() -> Result<()> {
72    let entry = keyring_entry(KEYRING_SERVICE, KEYRING_KEY)?;
73    entry
74        .delete_credential()
75        .context("failed to delete API key from keyring")?;
76    info!("Nexus API key removed from system keyring");
77    Ok(())
78}
79
80/// Retrieve the API key from the system keyring, returning `None` if unavailable.
81fn load_from_keyring() -> Option<String> {
82    let entry = match keyring_entry(KEYRING_SERVICE, KEYRING_KEY) {
83        Ok(e) => e,
84        Err(e) => {
85            debug!("keyring unavailable: {e}");
86            return None;
87        }
88    };
89    match entry.get_password() {
90        Ok(key) if !key.is_empty() => {
91            debug!("loaded API key from system keyring");
92            Some(key)
93        }
94        Ok(_) => None,
95        Err(KeyringError::NoEntry) => None,
96        Err(e) => {
97            warn!("failed to read from keyring: {e}");
98            None
99        }
100    }
101}
102
103fn keyring_entry(service: &str, key: &str) -> Result<Entry> {
104    keyring::use_native_store(false).context("failed to initialize system keyring store")?;
105    Entry::new(service, key).context("failed to create keyring entry")
106}
107
108/// Load Nexus API key from the configured auth sources.
109///
110/// Lookup chain:
111/// 1. OAuth token
112/// 2. `modde nexus auth` config file
113/// 3. `NEXUS_API_KEY` environment variable
114/// 4. System keyring (secret-service D-Bus)
115/// 5. `NEXUS_API_KEY_FILE` file path (sops-nix compatible)
116/// 6. Legacy `settings.toml` key
117pub fn load_api_key() -> Result<String> {
118    load_api_key_with_source().map(|loaded| loaded.key)
119}
120
121/// Load Nexus API key and report which source supplied it.
122pub fn load_api_key_with_source() -> Result<LoadedApiKey> {
123    if let Some(token) = super::oauth::load_token() {
124        if !token.is_expired() {
125            debug!("using OAuth token for Nexus authentication");
126            return Ok(LoadedApiKey {
127                key: token.access_token,
128                source: ApiKeySource::OAuth,
129            });
130        }
131        debug!("OAuth token expired, falling back to API key");
132    }
133
134    resolve_api_key_from_sources(
135        &config_api_key_path(),
136        std::env::var("NEXUS_API_KEY").ok(),
137        load_from_keyring(),
138        std::env::var("NEXUS_API_KEY_FILE").ok().map(PathBuf::from),
139        AppSettings::load().nexus_api_key,
140    )
141}
142
143/// Path to the API key file written by `modde nexus auth`.
144#[must_use]
145pub fn config_api_key_path() -> std::path::PathBuf {
146    paths::modde_config_dir().join("nexus_api_key")
147}
148
149/// Does the modde-owned API key file exist?
150#[must_use]
151pub fn config_api_key_exists() -> bool {
152    config_api_key_path().exists()
153}
154
155/// Write the Nexus API key to modde's own config file.
156pub fn write_config_api_key(api_key: &str) -> Result<()> {
157    write_config_api_key_to(&config_api_key_path(), api_key)
158}
159
160fn write_config_api_key_to(path: &Path, api_key: &str) -> Result<()> {
161    let api_key = api_key.trim();
162    if api_key.is_empty() {
163        bail!("API key cannot be empty");
164    }
165
166    if let Some(parent) = path.parent() {
167        std::fs::create_dir_all(parent)
168            .with_context(|| format!("failed to create {}", parent.display()))?;
169    }
170    std::fs::write(path, api_key).with_context(|| {
171        format!(
172            "failed to write Nexus API key to modde config file {}",
173            path.display()
174        )
175    })?;
176
177    #[cfg(unix)]
178    {
179        use std::os::unix::fs::PermissionsExt;
180        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
181            .with_context(|| format!("failed to restrict permissions on {}", path.display()))?;
182    }
183
184    Ok(())
185}
186
187/// Delete only modde's own Nexus API key file.
188pub fn delete_config_api_key() -> Result<()> {
189    let path = config_api_key_path();
190    if let Err(e) = std::fs::remove_file(&path)
191        && e.kind() != std::io::ErrorKind::NotFound
192    {
193        return Err(e).with_context(|| format!("failed to delete {}", path.display()));
194    }
195    Ok(())
196}
197
198fn load_from_config_file(path: &std::path::Path) -> Result<Option<String>> {
199    if !path.exists() {
200        return Ok(None);
201    }
202
203    let key = std::fs::read_to_string(path)
204        .with_context(|| format!("failed to read API key from {}", path.display()))?
205        .trim()
206        .to_string();
207    if key.is_empty() {
208        Ok(None)
209    } else {
210        Ok(Some(key))
211    }
212}
213
214fn resolve_api_key_from_sources(
215    config_path: &Path,
216    env_key: Option<String>,
217    keyring_key: Option<String>,
218    env_file_path: Option<PathBuf>,
219    legacy_settings_key: String,
220) -> Result<LoadedApiKey> {
221    if let Some(key) = load_from_config_file(config_path)? {
222        return Ok(LoadedApiKey {
223            key,
224            source: ApiKeySource::ModdeConfigFile,
225        });
226    }
227
228    if let Some(key) = env_key.map(|key| key.trim().to_string())
229        && !key.is_empty()
230    {
231        return Ok(LoadedApiKey {
232            key,
233            source: ApiKeySource::Environment,
234        });
235    }
236
237    if let Some(key) = keyring_key.map(|key| key.trim().to_string())
238        && !key.is_empty()
239    {
240        return Ok(LoadedApiKey {
241            key,
242            source: ApiKeySource::Keyring,
243        });
244    }
245
246    if let Some(path) = env_file_path {
247        let key = std::fs::read_to_string(&path)
248            .with_context(|| format!("failed to read API key from {}", path.display()))?
249            .trim()
250            .to_string();
251        if !key.is_empty() {
252            return Ok(LoadedApiKey {
253                key,
254                source: ApiKeySource::EnvironmentFile,
255            });
256        }
257    }
258
259    let legacy_settings_key = legacy_settings_key.trim().to_string();
260    if !legacy_settings_key.is_empty() {
261        return Ok(LoadedApiKey {
262            key: legacy_settings_key,
263            source: ApiKeySource::LegacySettingsToml,
264        });
265    }
266
267    bail!("No Nexus API key found. Set NEXUS_API_KEY env var or run `modde nexus auth`.")
268}
269
270/// Check if the given API key belongs to a premium account.
271pub async fn check_premium(client: &Client, api_key: &str) -> Result<bool> {
272    Ok(check_premium_source(client, api_key).await?)
273}
274
275/// Check premium status, preserving typed HTTP failures for download sources.
276pub async fn check_premium_source(client: &Client, api_key: &str) -> SourceResult<bool> {
277    let validate_url = format!("{}/users/validate.json", super::base_url());
278    let resp: ValidateResponse = status_error(
279        client
280            .get(&validate_url)
281            .header("apikey", api_key)
282            .send()
283            .await?,
284    )?
285    .json()
286    .await?;
287
288    info!(
289        user = resp.name.as_deref().unwrap_or("unknown"),
290        premium = resp.is_premium,
291        "Nexus account validated"
292    );
293
294    Ok(resp.is_premium)
295}
296
297#[cfg(test)]
298mod tests {
299    use super::{
300        ApiKeySource, load_from_config_file, resolve_api_key_from_sources, write_config_api_key_to,
301    };
302    use std::path::PathBuf;
303
304    #[test]
305    fn load_from_config_file_trims_key() {
306        let dir = tempfile::tempdir().unwrap();
307        let path = dir.path().join("nexus_api_key");
308        std::fs::write(&path, "  test-key\n").unwrap();
309
310        let key = load_from_config_file(&path).unwrap();
311        assert_eq!(key.as_deref(), Some("test-key"));
312    }
313
314    #[test]
315    fn load_from_config_file_ignores_missing_or_empty_file() {
316        let dir = tempfile::tempdir().unwrap();
317        let missing = dir.path().join("missing");
318        assert!(load_from_config_file(&missing).unwrap().is_none());
319
320        let empty = dir.path().join("nexus_api_key");
321        std::fs::write(&empty, "\n").unwrap();
322        assert!(load_from_config_file(&empty).unwrap().is_none());
323    }
324
325    #[test]
326    fn config_file_overrides_environment() {
327        let dir = tempfile::tempdir().unwrap();
328        let path = dir.path().join("nexus_api_key");
329        std::fs::write(&path, " config-key \n").unwrap();
330
331        let loaded = resolve_api_key_from_sources(
332            &path,
333            Some("env-key".to_string()),
334            None,
335            None,
336            String::new(),
337        )
338        .unwrap();
339
340        assert_eq!(loaded.key, "config-key");
341        assert_eq!(loaded.source, ApiKeySource::ModdeConfigFile);
342    }
343
344    #[test]
345    fn missing_config_file_falls_back_to_environment() {
346        let dir = tempfile::tempdir().unwrap();
347        let path = dir.path().join("nexus_api_key");
348
349        let loaded = resolve_api_key_from_sources(
350            &path,
351            Some("env-key".to_string()),
352            None,
353            None,
354            String::new(),
355        )
356        .unwrap();
357
358        assert_eq!(loaded.key, "env-key");
359        assert_eq!(loaded.source, ApiKeySource::Environment);
360    }
361
362    #[test]
363    fn replacement_writes_only_config_file() {
364        let dir = tempfile::tempdir().unwrap();
365        let path = dir.path().join("nexus_api_key");
366
367        write_config_api_key_to(&path, " replacement-key \n").unwrap();
368
369        assert_eq!(std::fs::read_to_string(&path).unwrap(), "replacement-key");
370        let loaded = resolve_api_key_from_sources(
371            &path,
372            Some("env-key".to_string()),
373            None,
374            None,
375            String::new(),
376        )
377        .unwrap();
378
379        assert_eq!(loaded.key, "replacement-key");
380        assert_eq!(loaded.source, ApiKeySource::ModdeConfigFile);
381    }
382
383    #[test]
384    fn deleting_config_file_falls_back_to_environment() {
385        let dir = tempfile::tempdir().unwrap();
386        let path = dir.path().join("nexus_api_key");
387        write_config_api_key_to(&path, "config-key").unwrap();
388        std::fs::remove_file(&path).unwrap();
389
390        let loaded = resolve_api_key_from_sources(
391            &path,
392            Some("env-key".to_string()),
393            None,
394            None,
395            String::new(),
396        )
397        .unwrap();
398
399        assert_eq!(loaded.key, "env-key");
400        assert_eq!(loaded.source, ApiKeySource::Environment);
401    }
402
403    #[test]
404    fn environment_file_precedes_legacy_settings() {
405        let dir = tempfile::tempdir().unwrap();
406        let config_path = dir.path().join("nexus_api_key");
407        let env_file = dir.path().join("env-key");
408        std::fs::write(&env_file, "file-key").unwrap();
409
410        let loaded = resolve_api_key_from_sources(
411            &config_path,
412            None,
413            None,
414            Some(PathBuf::from(&env_file)),
415            "legacy-key".to_string(),
416        )
417        .unwrap();
418
419        assert_eq!(loaded.key, "file-key");
420        assert_eq!(loaded.source, ApiKeySource::EnvironmentFile);
421    }
422}