Skip to main content

nono_proxy/
credential.rs

1//! Credential loading and management for reverse proxy mode.
2//!
3//! Loads API credentials from the system keystore or 1Password at proxy startup.
4//! Credentials are stored in `Zeroizing<String>` and injected into
5//! requests via headers, URL paths, query parameters, or Basic Auth.
6//! The sandboxed agent never sees the real credentials.
7//!
8//! Route-level configuration (upstream URL, L7 endpoint rules, custom TLS CA)
9//! is handled by [`crate::route::RouteStore`], which loads independently of
10//! credentials. This module handles only credential-specific concerns.
11
12use crate::config::{InjectMode, RouteConfig};
13use crate::error::{ProxyError, Result};
14use base64::Engine;
15use std::collections::HashMap;
16use tracing::{debug, warn};
17use zeroize::Zeroizing;
18
19/// A loaded credential ready for injection.
20///
21/// Contains only credential-specific fields (injection mode, header name/value,
22/// raw secret). Route-level configuration (upstream URL, L7 endpoint rules,
23/// custom TLS CA) is stored in [`crate::route::LoadedRoute`].
24pub struct LoadedCredential {
25    /// Injection mode
26    pub inject_mode: InjectMode,
27    /// Raw credential value from keystore (for modes that need it directly)
28    pub raw_credential: Zeroizing<String>,
29
30    // --- Header mode ---
31    /// Header name to inject (e.g., "Authorization")
32    pub header_name: String,
33    /// Formatted header value (e.g., "Bearer sk-...")
34    pub header_value: Zeroizing<String>,
35
36    // --- URL path mode ---
37    /// Pattern to match in incoming path (with {} placeholder)
38    pub path_pattern: Option<String>,
39    /// Pattern for outgoing path (with {} placeholder)
40    pub path_replacement: Option<String>,
41
42    // --- Query param mode ---
43    /// Query parameter name
44    pub query_param_name: Option<String>,
45}
46
47/// Custom Debug impl that redacts secret values to prevent accidental leakage
48/// in logs, panic messages, or debug output.
49impl std::fmt::Debug for LoadedCredential {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("LoadedCredential")
52            .field("inject_mode", &self.inject_mode)
53            .field("raw_credential", &"[REDACTED]")
54            .field("header_name", &self.header_name)
55            .field("header_value", &"[REDACTED]")
56            .field("path_pattern", &self.path_pattern)
57            .field("path_replacement", &self.path_replacement)
58            .field("query_param_name", &self.query_param_name)
59            .finish()
60    }
61}
62
63/// Credential store for all configured routes.
64#[derive(Debug)]
65pub struct CredentialStore {
66    /// Map from route prefix to loaded credential
67    credentials: HashMap<String, LoadedCredential>,
68}
69
70impl CredentialStore {
71    /// Load credentials for all configured routes from the system keystore.
72    ///
73    /// Routes without a `credential_key` are skipped (no credential injection).
74    /// Routes whose credential is not found (e.g. unset env var) are skipped
75    /// with a warning — this allows profiles to declare optional credentials
76    /// without failing when they are unavailable.
77    ///
78    /// Returns an error only for hard failures (keystore access errors,
79    /// config parse errors, non-UTF-8 values).
80    pub fn load(routes: &[RouteConfig]) -> Result<Self> {
81        let mut credentials = HashMap::new();
82
83        for route in routes {
84            // Normalize prefix: strip leading/trailing slashes so it matches
85            // the bare service name returned by parse_service_prefix() in
86            // the reverse proxy path (e.g., "/anthropic" -> "anthropic").
87            let normalized_prefix = route.prefix.trim_matches('/').to_string();
88
89            if let Some(ref key) = route.credential_key {
90                debug!(
91                    "Loading credential for route prefix: {} (mode: {:?})",
92                    normalized_prefix, route.inject_mode
93                );
94
95                let secret = match nono::keystore::load_secret_by_ref(KEYRING_SERVICE, key) {
96                    Ok(s) => s,
97                    Err(nono::NonoError::SecretNotFound(_)) => {
98                        let hint = if !key.contains("://") && cfg!(target_os = "macos") {
99                            format!(
100                                " To add it to the macOS keychain: security add-generic-password -s \"nono\" -a \"{}\" -w",
101                                key
102                            )
103                        } else {
104                            String::new()
105                        };
106                        warn!(
107                            "Credential '{}' not found for route '{}' — requests will proceed without credential injection.{}",
108                            key, normalized_prefix, hint
109                        );
110                        continue;
111                    }
112                    Err(e) => return Err(ProxyError::Credential(e.to_string())),
113                };
114
115                // Format header value based on mode.
116                // When inject_header is not "Authorization" (e.g., "PRIVATE-TOKEN",
117                // "X-API-Key"), the credential is injected as-is unless the user
118                // explicitly set a custom format. The default "Bearer {}" only
119                // makes sense for the Authorization header.
120                let effective_format = if route.inject_header != "Authorization"
121                    && route.credential_format == "Bearer {}"
122                {
123                    "{}".to_string()
124                } else {
125                    route.credential_format.clone()
126                };
127
128                let header_value = match route.inject_mode {
129                    InjectMode::Header => Zeroizing::new(effective_format.replace("{}", &secret)),
130                    InjectMode::BasicAuth => {
131                        // Base64 encode the credential for Basic auth
132                        let encoded =
133                            base64::engine::general_purpose::STANDARD.encode(secret.as_bytes());
134                        Zeroizing::new(format!("Basic {}", encoded))
135                    }
136                    // For url_path and query_param, header_value is not used
137                    InjectMode::UrlPath | InjectMode::QueryParam => Zeroizing::new(String::new()),
138                };
139
140                credentials.insert(
141                    normalized_prefix.clone(),
142                    LoadedCredential {
143                        inject_mode: route.inject_mode.clone(),
144                        raw_credential: secret,
145                        header_name: route.inject_header.clone(),
146                        header_value,
147                        path_pattern: route.path_pattern.clone(),
148                        path_replacement: route.path_replacement.clone(),
149                        query_param_name: route.query_param_name.clone(),
150                    },
151                );
152            }
153        }
154
155        Ok(Self { credentials })
156    }
157
158    /// Create an empty credential store (no credential injection).
159    #[must_use]
160    pub fn empty() -> Self {
161        Self {
162            credentials: HashMap::new(),
163        }
164    }
165
166    /// Get a credential for a route prefix, if configured.
167    #[must_use]
168    pub fn get(&self, prefix: &str) -> Option<&LoadedCredential> {
169        self.credentials.get(prefix)
170    }
171
172    /// Check if any credentials are loaded.
173    #[must_use]
174    pub fn is_empty(&self) -> bool {
175        self.credentials.is_empty()
176    }
177
178    /// Number of loaded credentials.
179    #[must_use]
180    pub fn len(&self) -> usize {
181        self.credentials.len()
182    }
183
184    /// Returns the set of route prefixes that have loaded credentials.
185    #[must_use]
186    pub fn loaded_prefixes(&self) -> std::collections::HashSet<String> {
187        self.credentials.keys().cloned().collect()
188    }
189}
190
191/// The keyring service name used by nono for all credentials.
192/// Uses the same constant as `nono::keystore::DEFAULT_SERVICE` to ensure consistency.
193const KEYRING_SERVICE: &str = nono::keystore::DEFAULT_SERVICE;
194
195#[cfg(test)]
196#[allow(clippy::unwrap_used)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_empty_credential_store() {
202        let store = CredentialStore::empty();
203        assert!(store.is_empty());
204        assert_eq!(store.len(), 0);
205        assert!(store.get("openai").is_none());
206    }
207
208    #[test]
209    fn test_loaded_credential_debug_redacts_secrets() {
210        // Security: Debug output must NEVER contain real secret values.
211        // This prevents accidental leakage in logs, panic messages, or
212        // tracing output at debug level.
213        let cred = LoadedCredential {
214            inject_mode: InjectMode::Header,
215            raw_credential: Zeroizing::new("sk-secret-12345".to_string()),
216            header_name: "Authorization".to_string(),
217            header_value: Zeroizing::new("Bearer sk-secret-12345".to_string()),
218            path_pattern: None,
219            path_replacement: None,
220            query_param_name: None,
221        };
222
223        let debug_output = format!("{:?}", cred);
224
225        // Must contain REDACTED markers
226        assert!(
227            debug_output.contains("[REDACTED]"),
228            "Debug output should contain [REDACTED], got: {}",
229            debug_output
230        );
231        // Must NOT contain the actual secret
232        assert!(
233            !debug_output.contains("sk-secret-12345"),
234            "Debug output must not contain the real secret"
235        );
236        assert!(
237            !debug_output.contains("Bearer sk-secret"),
238            "Debug output must not contain the formatted secret"
239        );
240        // Non-secret fields should still be visible
241        assert!(debug_output.contains("Authorization"));
242    }
243
244    #[test]
245    fn test_load_no_credential_routes() {
246        let routes = vec![RouteConfig {
247            prefix: "/test".to_string(),
248            upstream: "https://example.com".to_string(),
249            credential_key: None,
250            inject_mode: InjectMode::Header,
251            inject_header: "Authorization".to_string(),
252            credential_format: "Bearer {}".to_string(),
253            path_pattern: None,
254            path_replacement: None,
255            query_param_name: None,
256            env_var: None,
257            endpoint_rules: vec![],
258            tls_ca: None,
259            tls_client_cert: None,
260            tls_client_key: None,
261        }];
262        let store = CredentialStore::load(&routes);
263        assert!(store.is_ok());
264        let store = store.unwrap_or_else(|_| CredentialStore::empty());
265        assert!(store.is_empty());
266    }
267}