corteq_onepassword/
client.rs

1//! 1Password client implementation.
2//!
3//! This module provides the main `OnePassword` client and its builder.
4
5use crate::error::{Error, Result};
6use crate::ffi::{load_library, SdkClient};
7use crate::secret::{SecretMap, SecretReference};
8use base64::Engine;
9use secrecy::SecretString;
10use std::sync::Arc;
11use tokio::sync::Mutex;
12
13/// Prefix for 1Password service account tokens.
14const TOKEN_PREFIX: &str = "ops_";
15
16/// Validate a 1Password service account token format.
17///
18/// Tokens must:
19/// - Start with "ops_" prefix
20/// - Contain valid base64 after the prefix
21/// - Decode to JSON with required fields (signInAddress, email, deviceUuid)
22///
23/// This validation ensures early error detection with clear error messages,
24/// rather than cryptic SDK errors at connect time.
25fn validate_token(token: &str) -> Result<()> {
26    // Check prefix
27    if !token.starts_with(TOKEN_PREFIX) {
28        return Err(Error::InvalidToken);
29    }
30
31    let payload = &token[TOKEN_PREFIX.len()..];
32
33    // Decode base64 (1Password uses URL-safe base64 without padding)
34    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
35        .decode(payload)
36        .map_err(|_| Error::InvalidToken)?;
37
38    // Parse JSON and check required fields
39    let json: serde_json::Value =
40        serde_json::from_slice(&decoded).map_err(|_| Error::InvalidToken)?;
41
42    // Validate required fields exist
43    const REQUIRED_FIELDS: &[&str] = &["signInAddress", "email", "deviceUuid"];
44    for field in REQUIRED_FIELDS {
45        if json.get(*field).is_none() {
46            return Err(Error::InvalidToken);
47        }
48    }
49
50    Ok(())
51}
52
53/// Builder for configuring and creating a `OnePassword` client.
54///
55/// Use [`OnePassword::from_env()`] or [`OnePassword::from_token()`] to create a builder,
56/// then configure it and call [`connect()`](OnePasswordBuilder::connect) to establish
57/// the connection.
58///
59/// # Examples
60///
61/// ```no_run
62/// use corteq_onepassword::OnePassword;
63///
64/// #[tokio::main]
65/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
66///     // From environment variable
67///     let client = OnePassword::from_env()?
68///         .integration("my-app", "1.0.0")
69///         .connect()
70///         .await?;
71///
72///     // From explicit token (use env var in production)
73///     let token = std::env::var("MY_TOKEN")?;
74///     let client = OnePassword::from_token(&token)?
75///         .integration("my-app", "1.0.0")
76///         .connect()
77///         .await?;
78///     Ok(())
79/// }
80/// ```
81pub struct OnePasswordBuilder {
82    /// The service account token (wrapped for security).
83    token: SecretString,
84
85    /// Integration name for 1Password audit logs.
86    integration_name: Option<String>,
87
88    /// Integration version for 1Password audit logs.
89    integration_version: Option<String>,
90}
91
92impl OnePasswordBuilder {
93    /// Create a new builder with the given token.
94    fn new(token: impl Into<String>) -> Self {
95        Self {
96            token: SecretString::from(token.into()),
97            integration_name: None,
98            integration_version: None,
99        }
100    }
101
102    /// Set the integration name and version for 1Password audit logs.
103    ///
104    /// This metadata appears in 1Password's audit logs, helping identify
105    /// which application is accessing secrets.
106    ///
107    /// If not set, defaults to `corteq-onepassword` and the crate version.
108    ///
109    /// # Examples
110    ///
111    /// ```no_run
112    /// use corteq_onepassword::OnePassword;
113    ///
114    /// #[tokio::main]
115    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
116    ///     let client = OnePassword::from_env()?
117    ///         .integration("contact-guard", "2.1.0")
118    ///         .connect()
119    ///         .await?;
120    ///     Ok(())
121    /// }
122    /// ```
123    pub fn integration(mut self, name: &str, version: &str) -> Self {
124        self.integration_name = Some(name.to_string());
125        self.integration_version = Some(version.to_string());
126        self
127    }
128
129    /// Connect to 1Password and create the client.
130    ///
131    /// This establishes a session with 1Password using the configured
132    /// service account token.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if:
137    /// - The native library cannot be loaded
138    /// - Authentication fails (invalid or expired token)
139    /// - Network issues prevent connection
140    ///
141    /// # Examples
142    ///
143    /// ```no_run
144    /// use corteq_onepassword::OnePassword;
145    ///
146    /// #[tokio::main]
147    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
148    ///     let client = OnePassword::from_env()?
149    ///         .connect()
150    ///         .await?;
151    ///     Ok(())
152    /// }
153    /// ```
154    pub async fn connect(self) -> Result<OnePassword> {
155        let integration_name = self
156            .integration_name
157            .unwrap_or_else(|| "corteq-onepassword".to_string());
158        let integration_version = self
159            .integration_version
160            .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
161
162        #[cfg(feature = "tracing")]
163        tracing::debug!(
164            integration_name = %integration_name,
165            integration_version = %integration_version,
166            "connecting to 1Password"
167        );
168
169        // Load the native library
170        let library = load_library()?;
171
172        // Initialize the SDK client
173        let client = SdkClient::init(
174            library,
175            &self.token,
176            &integration_name,
177            &integration_version,
178        )?;
179
180        #[cfg(feature = "tracing")]
181        tracing::info!("connected to 1Password");
182
183        Ok(OnePassword {
184            client: Arc::new(Mutex::new(client)),
185        })
186    }
187
188    /// Connect to 1Password synchronously (blocking).
189    ///
190    /// This is equivalent to `connect()` but blocks the current thread
191    /// instead of returning a future.
192    ///
193    /// # Feature Flag
194    ///
195    /// This method requires the `blocking` feature to be enabled.
196    ///
197    /// # Examples
198    ///
199    /// ```no_run
200    /// use corteq_onepassword::OnePassword;
201    ///
202    /// fn main() -> Result<(), Box<dyn std::error::Error>> {
203    ///     let client = OnePassword::from_env()?
204    ///         .connect_blocking()?;
205    ///     Ok(())
206    /// }
207    /// ```
208    #[cfg(feature = "blocking")]
209    pub fn connect_blocking(self) -> Result<OnePassword> {
210        tokio::runtime::Builder::new_current_thread()
211            .enable_all()
212            .build()
213            .map_err(|e| Error::SdkError {
214                message: format!("failed to create runtime: {e}"),
215            })?
216            .block_on(self.connect())
217    }
218}
219
220/// 1Password client for retrieving secrets.
221///
222/// The client is thread-safe and can be shared across tasks via `Arc<OnePassword>`.
223/// It does NOT implement `Clone` to prevent accidental session duplication.
224///
225/// # Thread Safety
226///
227/// `OnePassword` is `Send + Sync`, allowing it to be used in async contexts
228/// and shared between threads.
229///
230/// # Examples
231///
232/// ```no_run
233/// use std::sync::Arc;
234/// use corteq_onepassword::OnePassword;
235///
236/// #[tokio::main]
237/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
238///     // Create and share the client
239///     let client = Arc::new(OnePassword::from_env()?.connect().await?);
240///
241///     // Use in multiple tasks
242///     let client1 = Arc::clone(&client);
243///     let client2 = Arc::clone(&client);
244///
245///     let (secret1, secret2) = tokio::join!(
246///         client1.secret("op://vault/item/field1"),
247///         client2.secret("op://vault/item/field2"),
248///     );
249///
250///     Ok(())
251/// }
252/// ```
253pub struct OnePassword {
254    /// The underlying SDK client, protected by a mutex for thread safety.
255    client: Arc<Mutex<SdkClient>>,
256}
257
258// Explicitly implement Send + Sync
259// SAFETY: Access to SdkClient is synchronized via Mutex
260unsafe impl Send for OnePassword {}
261unsafe impl Sync for OnePassword {}
262
263impl OnePassword {
264    /// Create a builder using the `OP_SERVICE_ACCOUNT_TOKEN` environment variable.
265    ///
266    /// This is the recommended way to create a client in production, as it
267    /// avoids hardcoding tokens in source code.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`Error::MissingAuthToken`] if the environment variable is not set
272    /// or is empty.
273    ///
274    /// # Examples
275    ///
276    /// ```no_run
277    /// use corteq_onepassword::OnePassword;
278    ///
279    /// // Set OP_SERVICE_ACCOUNT_TOKEN in your environment
280    /// #[tokio::main]
281    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
282    ///     let client = OnePassword::from_env()?
283    ///         .connect()
284    ///         .await?;
285    ///     Ok(())
286    /// }
287    /// ```
288    #[must_use = "builder must be used to connect"]
289    pub fn from_env() -> Result<OnePasswordBuilder> {
290        let token =
291            std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| Error::MissingAuthToken)?;
292
293        if token.is_empty() {
294            return Err(Error::MissingAuthToken);
295        }
296
297        // Validate token format before accepting
298        validate_token(&token)?;
299
300        Ok(OnePasswordBuilder::new(token))
301    }
302
303    /// Create a builder with an explicit service account token.
304    ///
305    /// Use this method for testing or when the token is provided through
306    /// a mechanism other than environment variables.
307    ///
308    /// # Security Note
309    ///
310    /// Avoid hardcoding tokens in source code. Prefer [`from_env()`](Self::from_env)
311    /// for production use.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`Error::InvalidToken`] if the token format is invalid.
316    /// Valid tokens start with "ops_" and contain a base64-encoded JSON payload.
317    ///
318    /// # Examples
319    ///
320    /// ```no_run
321    /// use corteq_onepassword::OnePassword;
322    ///
323    /// #[tokio::main]
324    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
325    ///     let token = std::env::var("CUSTOM_TOKEN_VAR")?;
326    ///     let client = OnePassword::from_token(&token)?
327    ///         .connect()
328    ///         .await?;
329    ///     Ok(())
330    /// }
331    /// ```
332    #[must_use = "builder must be used to connect"]
333    pub fn from_token(token: impl Into<String>) -> Result<OnePasswordBuilder> {
334        let token = token.into();
335
336        // Validate token format before accepting
337        validate_token(&token)?;
338
339        Ok(OnePasswordBuilder::new(token))
340    }
341
342    /// Resolve a single secret by reference.
343    ///
344    /// # Arguments
345    ///
346    /// * `reference` - Secret reference in format `op://vault/item/field`
347    ///   or `op://vault/item/section/field`
348    ///
349    /// # Returns
350    ///
351    /// The secret value wrapped in [`SecretString`] for secure memory handling.
352    /// Use [`.expose_secret()`](ExposeSecret::expose_secret) to access the value.
353    ///
354    /// # Errors
355    ///
356    /// - [`Error::InvalidReference`] - The reference format is invalid
357    /// - [`Error::SecretNotFound`] - The secret doesn't exist
358    /// - [`Error::AccessDenied`] - No permission to access the vault
359    /// - [`Error::NetworkError`] - Connection issues
360    ///
361    /// # Examples
362    ///
363    /// ```no_run
364    /// use corteq_onepassword::{OnePassword, ExposeSecret};
365    ///
366    /// #[tokio::main]
367    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
368    ///     let client = OnePassword::from_env()?.connect().await?;
369    ///
370    ///     let api_key = client.secret("op://prod/stripe/api-key").await?;
371    ///     println!("API key length: {}", api_key.expose_secret().len());
372    ///     Ok(())
373    /// }
374    /// ```
375    pub async fn secret(&self, reference: &str) -> Result<SecretString> {
376        // Validate reference format first
377        SecretReference::parse(reference)?;
378
379        #[cfg(feature = "tracing")]
380        tracing::debug!(reference = %reference, "resolving secret");
381
382        let client = self.client.lock().await;
383        let result = client.resolve_secret(reference);
384
385        #[cfg(feature = "tracing")]
386        if result.is_ok() {
387            tracing::debug!(reference = %reference, "secret resolved successfully");
388        }
389
390        result
391    }
392
393    /// Resolve multiple secrets in a batch.
394    ///
395    /// Secrets are returned in the same order as the input references.
396    /// The operation fails atomically - if any reference fails, none are returned.
397    ///
398    /// # Arguments
399    ///
400    /// * `references` - Slice of secret references to resolve
401    ///
402    /// # Returns
403    ///
404    /// A vector of [`SecretString`] values in the same order as input.
405    ///
406    /// # Errors
407    ///
408    /// Returns an error if any reference is invalid or cannot be resolved.
409    /// The error will indicate which reference failed.
410    ///
411    /// # Examples
412    ///
413    /// ```no_run
414    /// use corteq_onepassword::{OnePassword, ExposeSecret};
415    ///
416    /// #[tokio::main]
417    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
418    ///     let client = OnePassword::from_env()?.connect().await?;
419    ///
420    ///     let secrets = client.secrets(&[
421    ///         "op://prod/database/host",
422    ///         "op://prod/database/username",
423    ///         "op://prod/database/password",
424    ///     ]).await?;
425    ///
426    ///     let host = secrets[0].expose_secret();
427    ///     let user = secrets[1].expose_secret();
428    ///     let pass = secrets[2].expose_secret();
429    ///     Ok(())
430    /// }
431    /// ```
432    pub async fn secrets(&self, references: &[&str]) -> Result<Vec<SecretString>> {
433        // Validate all references first
434        for reference in references {
435            SecretReference::parse(reference)?;
436        }
437
438        if references.is_empty() {
439            return Ok(Vec::new());
440        }
441
442        #[cfg(feature = "tracing")]
443        tracing::debug!(count = references.len(), "resolving batch of secrets");
444
445        let client = self.client.lock().await;
446        client.resolve_secrets_batch(references)
447    }
448
449    /// Resolve secrets with user-defined names.
450    ///
451    /// This method allows you to assign memorable names to secrets,
452    /// making your code more readable and decoupling it from specific
453    /// vault/item/field paths.
454    ///
455    /// # Arguments
456    ///
457    /// * `mappings` - Slice of (name, reference) tuples
458    ///
459    /// # Returns
460    ///
461    /// A [`SecretMap`] that can be accessed by name.
462    ///
463    /// # Errors
464    ///
465    /// Returns an error if any reference is invalid or cannot be resolved.
466    ///
467    /// # Examples
468    ///
469    /// ```no_run
470    /// use corteq_onepassword::{OnePassword, ExposeSecret};
471    ///
472    /// #[tokio::main]
473    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
474    ///     let client = OnePassword::from_env()?.connect().await?;
475    ///
476    ///     let secrets = client.secrets_named(&[
477    ///         ("db_host", "op://prod/database/host"),
478    ///         ("db_user", "op://prod/database/username"),
479    ///         ("db_pass", "op://prod/database/password"),
480    ///     ]).await?;
481    ///
482    ///     let host = secrets.get("db_host").expect("db_host not found").expose_secret();
483    ///     let user = secrets.get("db_user").expect("db_user not found").expose_secret();
484    ///     let pass = secrets.get("db_pass").expect("db_pass not found").expose_secret();
485    ///     Ok(())
486    /// }
487    /// ```
488    pub async fn secrets_named(&self, mappings: &[(&str, &str)]) -> Result<SecretMap> {
489        let references: Vec<&str> = mappings.iter().map(|(_, r)| *r).collect();
490        let names: Vec<&str> = mappings.iter().map(|(n, _)| *n).collect();
491
492        let secrets = self.secrets(&references).await?;
493
494        Ok(SecretMap::from_pairs(names.into_iter().zip(secrets)))
495    }
496}
497
498impl std::fmt::Debug for OnePassword {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        f.debug_struct("OnePassword")
501            .field("connected", &true)
502            .finish_non_exhaustive()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use serial_test::serial;
510
511    #[test]
512    fn test_onepassword_is_send_sync() {
513        fn assert_send_sync<T: Send + Sync>() {}
514        assert_send_sync::<OnePassword>();
515    }
516
517    #[test]
518    fn test_builder_is_send() {
519        fn assert_send<T: Send>() {}
520        assert_send::<OnePasswordBuilder>();
521    }
522
523    #[test]
524    #[serial]
525    fn test_from_env_missing_var() {
526        // Temporarily unset the variable
527        let original = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok();
528        // SAFETY: Test is serialized to prevent concurrent env var access
529        unsafe {
530            std::env::remove_var("OP_SERVICE_ACCOUNT_TOKEN");
531        }
532
533        let result = OnePassword::from_env();
534        assert!(matches!(result, Err(Error::MissingAuthToken)));
535
536        // Restore if it was set
537        // SAFETY: Test is serialized to prevent concurrent env var access
538        if let Some(val) = original {
539            unsafe {
540                std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", val);
541            }
542        }
543    }
544
545    #[test]
546    #[serial]
547    fn test_from_env_empty_var() {
548        let original = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").ok();
549        // SAFETY: Test is serialized to prevent concurrent env var access
550        unsafe {
551            std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", "");
552        }
553
554        let result = OnePassword::from_env();
555        assert!(matches!(result, Err(Error::MissingAuthToken)));
556
557        // Restore
558        // SAFETY: Test is serialized to prevent concurrent env var access
559        unsafe {
560            match original {
561                Some(val) => std::env::set_var("OP_SERVICE_ACCOUNT_TOKEN", val),
562                None => std::env::remove_var("OP_SERVICE_ACCOUNT_TOKEN"),
563            }
564        }
565    }
566
567    #[test]
568    fn test_from_token_rejects_invalid_token() {
569        // Missing ops_ prefix
570        let result = OnePassword::from_token("test-token");
571        assert!(matches!(result, Err(Error::InvalidToken)));
572    }
573
574    #[test]
575    fn test_from_token_rejects_invalid_prefix() {
576        // Wrong prefix
577        let result = OnePassword::from_token("opp_test");
578        assert!(matches!(result, Err(Error::InvalidToken)));
579    }
580
581    #[test]
582    fn test_from_token_rejects_invalid_base64() {
583        // Valid prefix but invalid base64
584        let result = OnePassword::from_token("ops_not-valid-base64!!!");
585        assert!(matches!(result, Err(Error::InvalidToken)));
586    }
587
588    #[test]
589    fn test_from_token_rejects_invalid_json() {
590        // Valid prefix and base64, but not JSON
591        // "hello world" in URL-safe base64 without padding
592        let result = OnePassword::from_token("ops_aGVsbG8gd29ybGQ");
593        assert!(matches!(result, Err(Error::InvalidToken)));
594    }
595
596    #[test]
597    fn test_from_token_rejects_missing_fields() {
598        // Valid JSON but missing required fields
599        // {"foo": "bar"} in URL-safe base64 without padding
600        let result = OnePassword::from_token("ops_eyJmb28iOiAiYmFyIn0");
601        assert!(matches!(result, Err(Error::InvalidToken)));
602    }
603
604    #[test]
605    fn test_from_token_accepts_valid_format() {
606        // Valid format with all required fields
607        // {"signInAddress":"example.com","email":"test@test.com","deviceUuid":"123"}
608        let valid_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
609            r#"{"signInAddress":"example.com","email":"test@test.com","deviceUuid":"123"}"#,
610        );
611        let token = format!("ops_{valid_payload}");
612        let result = OnePassword::from_token(&token);
613        assert!(result.is_ok());
614    }
615
616    #[test]
617    fn test_builder_integration_chaining() {
618        // Use a valid token format for testing builder functionality
619        let valid_payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
620            r#"{"signInAddress":"example.com","email":"test@test.com","deviceUuid":"123"}"#,
621        );
622        let token = format!("ops_{valid_payload}");
623
624        let builder = OnePassword::from_token(&token)
625            .expect("valid token should be accepted")
626            .integration("my-app", "2.0.0");
627
628        // Verify chaining works
629        assert!(builder.integration_name.is_some());
630        assert!(builder.integration_version.is_some());
631    }
632}