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}