diff --git a/USAGE.md b/USAGE.md index 902b71b..d1554b9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -29,6 +29,70 @@ connect_async_client: Client = new_client( True) ``` +## Client Configuration + +The SDK provides a `ClientConfig` class that allows you to configure the underlying httpx client. This includes SSL certificate verification and all other httpx client options. + +### SSL Certificate Verification + +When connecting to a 1Password Connect server using HTTPS, you may need to configure SSL certificate verification: + +```python +from onepasswordconnectsdk.config import ClientConfig + +# Verify SSL using a custom CA certificate +config = ClientConfig(ca_file="path/to/ca.pem") +client = new_client("https://connect.example.com", "your-token", config=config) + +# Disable SSL verification (not recommended for production) +config = ClientConfig(verify=False) +client = new_client("https://connect.example.com", "your-token", config=config) +``` + +### Additional Configuration Options + +The ClientConfig class accepts all httpx client options as keyword arguments. These options are passed directly to the underlying httpx client: + +```python +# Configure timeouts and redirects +config = ClientConfig( + ca_file="path/to/ca.pem", + timeout=30.0, # 30 second timeout + follow_redirects=True, # Follow HTTP redirects + max_redirects=5 # Maximum number of redirects to follow +) + +# Configure proxy settings +config = ClientConfig( + proxies={ + "http://": "http://proxy.example.com", + "https://": "https://proxy.example.com" + } +) + +# Configure custom headers +config = ClientConfig( + headers={ + "User-Agent": "CustomApp/1.0", + "X-Custom-Header": "value" + } +) +``` + +### Async Client Configuration + +The same configuration options work for both synchronous and asynchronous clients: + +```python +config = ClientConfig( + ca_file="path/to/ca.pem", + timeout=30.0 +) +async_client = new_client("https://connect.example.com", "your-token", is_async=True, config=config) +``` + +For a complete list of available configuration options, see the [httpx client documentation](https://www.python-httpx.org/api/#client). + ## Environment Variables - **OP_CONNECT_TOKEN** – The token to be used to authenticate with the 1Password Connect API. @@ -166,4 +230,4 @@ async def main(): await async_client.session.aclose() # close the client gracefully when you are done asyncio.run(main()) -``` \ No newline at end of file +``` diff --git a/example/ca_file_example/list_secrets.py b/example/ca_file_example/list_secrets.py new file mode 100644 index 0000000..a10243c --- /dev/null +++ b/example/ca_file_example/list_secrets.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Example script demonstrating how to connect to a 1Password Connect server +using CA certificate verification and list all secrets in a vault. + +Shows both synchronous and asynchronous usage. +Update the configuration variables below with your values. +""" + +import asyncio +from onepasswordconnectsdk.client import new_client +from onepasswordconnectsdk.config import ClientConfig + +# Configuration +CONNECT_URL = "https://connect.example.com" # Your 1Password Connect server URL +TOKEN = "eyJhbGc..." # Your 1Password Connect token +VAULT_ID = "vaults_abc123" # ID of the vault to list secrets from +CA_FILE = "path/to/ca.pem" # Path to your CA certificate file + +def list_vault_secrets(): + """ + Connect to 1Password Connect server and list all secrets in the specified vault. + Uses CA certificate verification for secure connection. + """ + try: + # Configure client with CA certificate verification + config = ClientConfig( + ca_file=CA_FILE, + timeout=30.0 # 30 second timeout + ) + + # Initialize client with configuration + client = new_client(CONNECT_URL, TOKEN, config=config) + + # Get all items in the vault + items = client.get_items(VAULT_ID) + + # Print items + print(f"\nSecrets in vault {VAULT_ID}:") + print("-" * 40) + for item in items: + print(f"- {item.title} ({item.category})") + + except Exception as e: + print(f"Error: {str(e)}") + + +async def list_vault_secrets_async(): + """ + Async version: Connect to 1Password Connect server and list all secrets in the specified vault. + Uses CA certificate verification for secure connection. + """ + try: + # Configure client with CA certificate verification + config = ClientConfig( + ca_file=CA_FILE, + timeout=30.0 # 30 second timeout + ) + + # Initialize async client with configuration + client = new_client(CONNECT_URL, TOKEN, is_async=True, config=config) + + # Get all items in the vault + items = await client.get_items(VAULT_ID) + + # Print items + print(f"\nSecrets in vault {VAULT_ID} (async):") + print("-" * 40) + for item in items: + print(f"- {item.title} ({item.category})") + + # Close the client gracefully + await client.session.aclose() + + except Exception as e: + print(f"Error: {str(e)}") + +if __name__ == "__main__": + # Run sync version + print("Running synchronous example...") + list_vault_secrets() + + # Run async version + print("\nRunning asynchronous example...") + asyncio.run(list_vault_secrets_async()) diff --git a/src/onepasswordconnectsdk/async_client.py b/src/onepasswordconnectsdk/async_client.py index 2adf379..802adda 100644 --- a/src/onepasswordconnectsdk/async_client.py +++ b/src/onepasswordconnectsdk/async_client.py @@ -1,10 +1,11 @@ """Python AsyncClient for connecting to 1Password Connect""" import httpx from httpx import HTTPError -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional import os from onepasswordconnectsdk.serializer import Serializer +from onepasswordconnectsdk.config import ClientConfig from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder, get_timeout from onepasswordconnectsdk.errors import ( FailedToRetrieveItemException, @@ -16,15 +17,29 @@ class AsyncClient: """Python Async Client Class""" - def __init__(self, url: str, token: str) -> None: - """Initialize async client""" + def __init__(self, url: str, token: str, config: Optional[ClientConfig] = None) -> None: + """Initialize async client + + Args: + url (str): The url of the 1Password Connect API + token (str): The 1Password Service Account token + config (Optional[ClientConfig]): Optional configuration for httpx client + """ self.url = url self.token = token + self.config = config self.session = self.create_session(url, token) self.serializer = Serializer() def create_session(self, url: str, token: str) -> httpx.AsyncClient: - return httpx.AsyncClient(base_url=url, headers=self.build_headers(token), timeout=get_timeout()) + headers = self.build_headers(token) + timeout = get_timeout() + + if self.config: + client_args = self.config.get_client_args(url, headers, timeout) + return httpx.AsyncClient(**client_args) + + return httpx.AsyncClient(base_url=url, headers=headers, timeout=timeout) def build_headers(self, token: str) -> Dict[str, str]: return build_headers(token) diff --git a/src/onepasswordconnectsdk/client.py b/src/onepasswordconnectsdk/client.py index a6d5060..0d264b6 100644 --- a/src/onepasswordconnectsdk/client.py +++ b/src/onepasswordconnectsdk/client.py @@ -2,10 +2,11 @@ import httpx from httpx import HTTPError, USE_CLIENT_DEFAULT import json -from typing import Dict, List, Union +from typing import Dict, List, Union, Optional import os from onepasswordconnectsdk.async_client import AsyncClient +from onepasswordconnectsdk.config import ClientConfig from onepasswordconnectsdk.serializer import Serializer from onepasswordconnectsdk.utils import build_headers, is_valid_uuid, PathBuilder, get_timeout from onepasswordconnectsdk.errors import ( @@ -24,15 +25,29 @@ class Client: """Python Client Class""" - def __init__(self, url: str, token: str) -> None: - """Initialize client""" + def __init__(self, url: str, token: str, config: Optional[ClientConfig] = None) -> None: + """Initialize client + + Args: + url (str): The url of the 1Password Connect API + token (str): The 1Password Service Account token + config (Optional[ClientConfig]): Optional configuration for httpx client + """ self.url = url self.token = token + self.config = config self.session = self.create_session(url, token) self.serializer = Serializer() def create_session(self, url: str, token: str) -> httpx.Client: - return httpx.Client(base_url=url, headers=self.build_headers(token), timeout=get_timeout()) + headers = self.build_headers(token) + timeout = get_timeout() + + if self.config: + client_args = self.config.get_client_args(url, headers, timeout) + return httpx.Client(**client_args) + + return httpx.Client(base_url=url, headers=headers, timeout=timeout) def build_headers(self, token: str) -> Dict[str, str]: return build_headers(token) @@ -381,19 +396,21 @@ def sanitize_for_serialization(self, obj): return self.serializer.sanitize_for_serialization(obj) -def new_client(url: str, token: str, is_async: bool = False) -> Union[AsyncClient, Client]: +def new_client(url: str, token: str, is_async: bool = False, config: Optional[ClientConfig] = None) -> Union[AsyncClient, Client]: """Builds a new client for interacting with 1Password Connect - Parameters: - url: The url of the 1Password Connect API - token: The 1Password Service Account token - is_async: Initialize async or sync client - + + Args: + url (str): The url of the 1Password Connect API + token (str): The 1Password Service Account token + is_async (bool): Initialize async or sync client + config (Optional[ClientConfig]): Optional configuration for httpx client + Returns: - Client: The 1Password Connect client + Union[AsyncClient, Client]: The 1Password Connect client """ if is_async: - return AsyncClient(url, token) - return Client(url, token) + return AsyncClient(url, token, config) + return Client(url, token, config) def new_client_from_environment(url: str = None) -> Union[AsyncClient, Client]: diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index 4670b97..bcde7a0 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -1,7 +1,10 @@ import os import shlex -from typing import List, Dict -from onepasswordconnectsdk.client import Client +from typing import List, Dict, Optional, TYPE_CHECKING +import httpx + +if TYPE_CHECKING: + from onepasswordconnectsdk.client import Client from onepasswordconnectsdk.models import ( Item, ParsedField, @@ -16,7 +19,48 @@ ) -def load_dict(client: Client, config: dict): +class ClientConfig: + """Configuration class for 1Password Connect client. + Inherits from httpx.BaseClient to support all httpx client options. + """ + def __init__(self, ca_file: Optional[str] = None, **kwargs): + """Initialize client configuration + + Args: + ca_file (Optional[str]): Path to CA certificate file for SSL verification + **kwargs: Additional httpx client options + """ + self.ca_file = ca_file + self.httpx_options = kwargs + + def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float) -> Dict: + """Get arguments for httpx client initialization + + Args: + base_url (str): Base URL for the client + headers (Dict[str, str]): Headers to include in requests + timeout (float): Request timeout in seconds + + Returns: + Dict: Arguments for httpx client initialization + """ + args = { + 'base_url': base_url, + 'headers': headers, + 'timeout': timeout, + } + + # Set verify from ca_file first + if self.ca_file: + args['verify'] = self.ca_file + + # Allow httpx_options (including verify) to override + args.update(self.httpx_options) + + return args + + +def load_dict(client: "Client", config: dict): """Load: Takes a dictionary with keys specifiying the user desired naming scheme of the values to return. Each key's value is a dictionary that includes information on where @@ -83,7 +127,7 @@ def load_dict(client: Client, config: dict): return config_values -def load(client: Client, config: object): +def load(client: "Client", config: object): """Load: Takes a an object with class attributes annotated with tags describing where to find desired fields in 1Password. Manipulates given object and fills attributes in with 1Password item field values. @@ -162,7 +206,7 @@ def _vault_uuid_for_field(field: str, vault_tag: dict): def _set_values_for_item( - client: Client, + client: "Client", parsed_item: ParsedItem, config_dict={}, config_object: object = None, diff --git a/src/tests/test_client_config.py b/src/tests/test_client_config.py new file mode 100644 index 0000000..524f1f4 --- /dev/null +++ b/src/tests/test_client_config.py @@ -0,0 +1,44 @@ +import pytest +from onepasswordconnectsdk.config import ClientConfig +import httpx + +def test_client_config_with_ca_file(): + config = ClientConfig(ca_file="path/to/ca.pem") + args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0) + + assert args["verify"] == "path/to/ca.pem" + assert args["base_url"] == "https://test.com" + assert args["headers"] == {"Authorization": "Bearer token"} + assert args["timeout"] == 30.0 + +def test_client_config_with_kwargs(): + config = ClientConfig( + ca_file="path/to/ca.pem", + follow_redirects=True, + timeout=60.0 + ) + args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0) + + assert args["verify"] == "path/to/ca.pem" + assert args["follow_redirects"] == True + # kwargs should override default timeout + assert args["timeout"] == 60.0 + +def test_client_config_verify_override(): + # When verify is explicitly set in kwargs, it should override ca_file + config = ClientConfig( + ca_file="path/to/ca.pem", + verify=False + ) + args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0) + + assert args["verify"] == False + +def test_client_config_no_ca_file(): + config = ClientConfig() + args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0) + + assert "verify" not in args + assert args["base_url"] == "https://test.com" + assert args["headers"] == {"Authorization": "Bearer token"} + assert args["timeout"] == 30.0