Skip to main content

figment_keyring/
lib.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Figment Provider for Keyring Integration
4//!
5//! This crate provides a Figment2 provider that fetches secrets from system
6//! keyrings (macOS Keychain, Windows Credential Manager, Linux Secret Service).
7//!
8//! # Quick Start
9//!
10//! ```rust,no_run
11//! use figment2::Figment;
12//! use figment_keyring::KeyringProvider;
13//!
14//! // Create a Figment with your configuration sources
15//! let config_figment = Figment::new();
16//!
17//! // Provider is configured by that Figment (late binding)
18//! let api_key_provider = KeyringProvider::configured_by(config_figment, "api_key");
19//!
20//! // Final Figment merges everything
21//! // let config: YourConfig = Figment::new()
22//! //     .merge(config_figment)
23//! //     .merge(api_key_provider)
24//! //     .extract().unwrap();
25//! ```
26//!
27//! # Configuration
28//!
29//! The provider is configured via a [`KeyringConfig`] which can come from
30//! any Figment source (file, environment, etc.):
31//!
32//! ```toml
33//! # config.toml
34//! service = "myapp"
35//! keyrings = ["user", "team-secrets"]
36//! optional = false
37//! ```
38//! ## Custom Configuration Keys
39//!
40//! If you have existing configuration with custom field names, implement
41//! the [`KeyringKeyMapping`] trait to specify which keys to extract:
42//!
43//! ```rust,no_run
44//! use figment2::{Figment, providers::Serialized};
45//! use figment_keyring::{KeyringProvider, KeyringKeyMapping, KeyringConfig, Keyring};
46//!
47//! struct MyKeyMapping;
48//!
49//! impl KeyringKeyMapping for MyKeyMapping {
50//!     fn service_key(&self) -> &str { "app_name" }
51//!     fn keyrings_key(&self) -> &str { "stores" }
52//!     fn optional_key(&self) -> &str { "allow_missing" }
53//! }
54//!
55//! let config_figment = Figment::from(Serialized::defaults(KeyringConfig {
56//!     service: "myapp".to_string(),
57//!     keyrings: vec![Keyring::User],
58//!     optional: true,
59//! }));
60//! let mapping = MyKeyMapping;
61//! let provider = KeyringProvider::configured_by_with_mapping(config_figment, &mapping, "api_key");
62//! ```
63//!
64//! This allows your configuration to use custom field names:
65//!
66//! ```toml
67//! # config.toml
68//! app_name = "myapp"
69//! stores = ["user", "system"]
70//! allow_missing = false
71//! ```
72//!
73//! The trait provides full flexibility:
74//! - Use any field names
75//! - Support nested key paths (e.g., `"secrets.service"`)
76//! - Customize each field independently
77//! ```
78
79pub mod error;
80pub mod keyring_config;
81
82pub use error::KeyringError;
83pub use keyring_config::{Keyring, KeyringConfig};
84
85use figment2::{
86    providers::Serialized,
87    value::{Dict, Map, Value},
88    Error, Figment, Metadata, Profile, Provider,
89};
90use std::sync::Arc;
91
92/// Trait for specifying which keys to extract from Figment.
93///
94/// Implement this trait to use custom field names in your configuration
95/// instead of of fixed `service`, `keyrings`, `optional`:
96///
97/// ```rust,no_run
98/// use figment2::{Figment, providers::Serialized};
99/// use figment_keyring::{KeyringProvider, KeyringKeyMapping, KeyringConfig, Keyring};
100///
101/// struct MyKeyMapping;
102///
103/// impl KeyringKeyMapping for MyKeyMapping {
104///     fn service_key(&self) -> &str { "app_name" }
105///     fn keyrings_key(&self) -> &str { "stores" }
106///     fn optional_key(&self) -> &str { "allow_missing" }
107/// }
108///
109/// let config_figment = Figment::from(Serialized::defaults(KeyringConfig {
110///     service: "myapp".to_string(),
111///     keyrings: vec![Keyring::User],
112///     optional: true,
113/// }));
114/// let mapping = MyKeyMapping;
115/// let provider = KeyringProvider::configured_by_with_mapping(config_figment, &mapping, "api_key");
116/// ```
117pub trait KeyringKeyMapping {
118    fn service_key(&self) -> &str;
119
120    fn keyrings_key(&self) -> &str;
121
122    fn optional_key(&self) -> &str {
123        "optional"
124    }
125}
126
127/// Provider that fetches secrets from system keyrings.
128///
129/// This provider uses **late binding**: it holds a reference to a Figment
130/// containing the configuration, but doesn't extract it until `.data()` is
131/// called. This allows the configuration to be loaded from any Figment source
132/// (files, environment, custom providers) that the application chooses.
133///
134/// # Example
135///
136/// ```rust,no_run
137/// use figment2::Figment;
138/// use figment_keyring::KeyringProvider;
139///
140/// // Create a Figment with your configuration sources
141/// let config_figment = Figment::new();
142///
143/// let provider = KeyringProvider::configured_by(config_figment, "api_key");
144/// ```
145pub struct KeyringProvider {
146    config_figment: Arc<Figment>,
147    credential_name: String,
148    config_key: Option<String>,
149    profile: Option<Profile>,
150}
151
152impl KeyringProvider {
153    pub fn configured_by(config_figment: Figment, credential_name: &str) -> Self {
154        Self {
155            config_figment: Arc::new(config_figment),
156            credential_name: credential_name.into(),
157            config_key: None,
158            profile: None,
159        }
160    }
161
162    pub fn configured_by_with_mapping<M: KeyringKeyMapping>(
163        config_figment: Figment,
164        mapping: &M,
165        credential_name: &str,
166    ) -> std::result::Result<Self, Error> {
167        let service: String = config_figment.extract_inner(mapping.service_key())?;
168        let keyrings: Vec<Keyring> = config_figment.extract_inner(mapping.keyrings_key())?;
169        let optional: bool = config_figment
170            .extract_inner(mapping.optional_key())
171            .unwrap_or(false);
172
173        let config = KeyringConfig {
174            service,
175            keyrings,
176            optional,
177        };
178
179        let figment = Figment::from(Serialized::defaults(config));
180        Ok(Self::configured_by(figment, credential_name))
181    }
182
183    /// Create a new provider with a Figment focused on a nested path.
184    ///
185    /// This is useful when your keyring configuration is nested within a larger
186    /// configuration structure. For example, if your TOML configuration has a
187    /// `[keyring]` section:
188    ///
189    /// ```toml
190    /// # config.toml
191    /// [keyring]
192    /// service = "myapp"
193    /// keyrings = ["user", "team-secrets"]
194    /// optional = false
195    ///
196    /// [database]
197    /// # ...
198    /// ```
199    ///
200    /// You can focus on the `[keyring]` section:
201    ///
202    /// ```rust,no_run
203    /// # use figment2::Figment;
204    /// # use figment_keyring::KeyringProvider;
205    /// let config_figment = Figment::new();
206    ///
207    /// // Focus on the [keyring] section
208    /// let provider = KeyringProvider::configured_by(config_figment, "api_key")
209    ///     .focused("keyring");
210    /// ```
211    ///
212    /// You can also focus on deeply nested paths:
213    ///
214    /// ```toml
215    /// # config.toml
216    /// [services.database]
217    /// service = "myapp-db"
218    /// keyrings = ["system"]
219    /// optional = false
220    /// ```
221    ///
222    /// ```rust,no_run
223    /// # use figment2::Figment;
224    /// # use figment_keyring::KeyringProvider;
225    /// # let config_figment = Figment::new();
226    /// let provider = KeyringProvider::configured_by(config_figment, "db_password")
227    ///     .focused("services.database");
228    /// ```
229    pub fn focused(&self, path: &str) -> Self {
230        Self {
231            config_figment: Arc::new(self.config_figment.focus(path)),
232            credential_name: self.credential_name.clone(),
233            config_key: self.config_key.clone(),
234            profile: self.profile.clone(),
235        }
236    }
237
238    pub fn new(service: &str, credential_name: &str) -> Self {
239        let config = KeyringConfig {
240            service: service.into(),
241            keyrings: vec![Keyring::User],
242            optional: false,
243        };
244        let figment = Figment::from(Serialized::defaults(config));
245        Self::configured_by(figment, credential_name)
246    }
247
248    pub fn system(service: &str, credential_name: &str) -> Self {
249        let config = KeyringConfig {
250            service: service.into(),
251            keyrings: vec![Keyring::System],
252            optional: false,
253        };
254        let figment = Figment::from(Serialized::defaults(config));
255        Self::configured_by(figment, credential_name)
256    }
257
258    pub fn as_key(mut self, key: &str) -> Self {
259        self.config_key = Some(key.into());
260        self
261    }
262
263    pub fn with_profile(mut self, profile: Profile) -> Self {
264        self.profile = Some(profile);
265        self
266    }
267}
268
269impl Provider for KeyringProvider {
270    fn metadata(&self) -> Metadata {
271        Metadata::named("keyring")
272    }
273
274    fn data(&self) -> std::result::Result<Map<Profile, Dict>, Error> {
275        let config: KeyringConfig = self
276            .config_figment
277            .extract()
278            .map_err(|e| Error::from(format!("keyring config: {}", e)))?;
279
280        let secret = self.search_keyrings(&config)?;
281
282        let key = self.config_key.as_ref().unwrap_or(&self.credential_name);
283
284        let profile = self.profile.clone().unwrap_or_default();
285        let mut dict = Dict::new();
286
287        match secret {
288            Some(value) => {
289                dict.insert(key.clone(), Value::from(value));
290            }
291            None if config.optional => {}
292            None => {
293                return Err(Error::from(format!(
294                    "secret '{}' not found in any keyring",
295                    self.credential_name
296                )));
297            }
298        }
299
300        let mut map = Map::new();
301        map.insert(profile, dict);
302        Ok(map)
303    }
304}
305
306impl KeyringProvider {
307    fn search_keyrings(
308        &self,
309        config: &KeyringConfig,
310    ) -> std::result::Result<Option<String>, Error> {
311        for keyring in &config.keyrings {
312            match self.get_from_keyring(keyring, &config.service, &self.credential_name) {
313                Ok(secret) => return Ok(Some(secret)),
314                Err(KeyringError::NotFound(_)) => continue,
315                Err(e) => {
316                    if config.optional {
317                        continue;
318                    } else {
319                        return Err(Error::from(e.to_string()));
320                    }
321                }
322            }
323        }
324        Ok(None)
325    }
326
327    fn get_from_keyring(
328        &self,
329        keyring: &Keyring,
330        service: &str,
331        username: &str,
332    ) -> std::result::Result<String, KeyringError> {
333        keyring_config::backend::get_secret(keyring, service, username)
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_keyring_from_str() {
343        assert_eq!(Keyring::from("user"), Keyring::User);
344        assert_eq!(Keyring::from("system"), Keyring::System);
345        assert_eq!(
346            Keyring::from("custom-keyring"),
347            Keyring::Named("custom-keyring".into())
348        );
349    }
350
351    #[test]
352    fn test_keyring_default() {
353        assert_eq!(Keyring::default(), Keyring::User);
354    }
355
356    #[test]
357    fn test_keyring_provider_new() {
358        let provider = KeyringProvider::new("test-app", "test-key");
359        assert_eq!(provider.credential_name, "test-key");
360    }
361
362    #[test]
363    fn test_keyring_provider_system() {
364        let provider = KeyringProvider::system("test-app", "test-key");
365        assert_eq!(provider.credential_name, "test-key");
366    }
367
368    #[test]
369    fn test_keyring_provider_as_key() {
370        let provider = KeyringProvider::new("test-app", "test-key").as_key("custom.config.key");
371        assert_eq!(provider.config_key, Some("custom.config.key".into()));
372    }
373
374    #[test]
375    fn test_keyring_provider_with_profile() {
376        let profile = Profile::from("production");
377        let provider = KeyringProvider::new("test-app", "test-key").with_profile(profile.clone());
378        assert_eq!(provider.profile, Some(profile));
379    }
380
381    #[test]
382    fn test_keyring_provider_focused() {
383        let config_figment = Figment::new();
384        let provider = KeyringProvider::configured_by(config_figment, "api_key");
385        let focused_provider = provider.focused("keyring");
386        assert_eq!(focused_provider.credential_name, "api_key");
387        assert_eq!(focused_provider.config_key, None);
388        assert_eq!(focused_provider.profile, None);
389    }
390
391    #[test]
392    fn test_keyring_provider_focused_preserves_config_key() {
393        let config_figment = Figment::new();
394        let provider =
395            KeyringProvider::configured_by(config_figment, "api_key").as_key("custom_key");
396        let focused_provider = provider.focused("keyring");
397        assert_eq!(focused_provider.config_key, Some("custom_key".into()));
398    }
399
400    #[test]
401    fn test_keyring_provider_focused_preserves_profile() {
402        let config_figment = Figment::new();
403        let profile = Profile::from("production");
404        let provider =
405            KeyringProvider::configured_by(config_figment, "api_key").with_profile(profile.clone());
406        let focused_provider = provider.focused("keyring");
407        assert_eq!(focused_provider.profile, Some(profile));
408    }
409
410    #[test]
411    fn test_key_mapping_default_keys() {
412        struct DefaultMapping;
413
414        impl KeyringKeyMapping for DefaultMapping {
415            fn service_key(&self) -> &str {
416                "service"
417            }
418            fn keyrings_key(&self) -> &str {
419                "keyrings"
420            }
421        }
422
423        let mapping = DefaultMapping;
424        let config_figment = Figment::from(Serialized::defaults(KeyringConfig {
425            service: "myapp".to_string(),
426            keyrings: vec![Keyring::User],
427            optional: true,
428        }));
429
430        let provider =
431            KeyringProvider::configured_by_with_mapping(config_figment, &mapping, "test-key")
432                .unwrap();
433
434        assert_eq!(provider.credential_name, "test-key");
435    }
436
437    #[test]
438    fn test_key_mapping_custom_keys() {
439        struct CustomMapping;
440
441        impl KeyringKeyMapping for CustomMapping {
442            fn service_key(&self) -> &str {
443                "app_name"
444            }
445            fn keyrings_key(&self) -> &str {
446                "stores"
447            }
448            fn optional_key(&self) -> &str {
449                "allow_missing"
450            }
451        }
452
453        let mapping = CustomMapping;
454        let config_figment = Figment::from(Serialized::defaults(serde_json::json!({
455            "app_name": "myapp",
456            "stores": ["user", "system"],
457            "allow_missing": false,
458        })));
459
460        let provider =
461            KeyringProvider::configured_by_with_mapping(config_figment, &mapping, "api_key")
462                .unwrap();
463
464        assert_eq!(provider.credential_name, "api_key");
465    }
466
467    #[test]
468    fn test_key_mapping_default_optional() {
469        struct MappingWithoutOptional;
470
471        impl KeyringKeyMapping for MappingWithoutOptional {
472            fn service_key(&self) -> &str {
473                "service"
474            }
475            fn keyrings_key(&self) -> &str {
476                "keyrings"
477            }
478        }
479
480        let mapping = MappingWithoutOptional;
481        let config_figment = Figment::from(Serialized::defaults(serde_json::json!({
482            "service": "myapp",
483            "keyrings": ["user"],
484        })));
485
486        let provider = KeyringProvider::configured_by_with_mapping(
487            config_figment,
488            &mapping,
489            "test-credential",
490        )
491        .unwrap();
492
493        assert_eq!(provider.credential_name, "test-credential");
494    }
495
496    #[test]
497    fn test_key_mapping_nested_keys() {
498        struct NestedMapping;
499
500        impl KeyringKeyMapping for NestedMapping {
501            fn service_key(&self) -> &str {
502                "secrets.service"
503            }
504            fn keyrings_key(&self) -> &str {
505                "secrets.backends"
506            }
507            fn optional_key(&self) -> &str {
508                "secrets.optional"
509            }
510        }
511
512        let mapping = NestedMapping;
513        let config_figment = Figment::from(Serialized::defaults(serde_json::json!({
514            "secrets": {
515                "service": "myapp",
516                "backends": ["system"],
517                "optional": true,
518            }
519        })));
520
521        let provider =
522            KeyringProvider::configured_by_with_mapping(config_figment, &mapping, "db_password")
523                .unwrap();
524
525        assert_eq!(provider.credential_name, "db_password");
526    }
527}