From 31d0e13e6fc0f6a678a42fc61180869e6c0f663e Mon Sep 17 00:00:00 2001 From: mattjacksoncello <156972821+mattjacksoncello@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:10:09 +1300 Subject: [PATCH 1/7] Add configclass --- USAGE.md | 66 ++++++++++++++++++++++- src/onepasswordconnectsdk/async_client.py | 23 ++++++-- src/onepasswordconnectsdk/client.py | 43 ++++++++++----- src/onepasswordconnectsdk/config.py | 41 +++++++++++++- src/tests/test_client_config.py | 44 +++++++++++++++ 5 files changed, 198 insertions(+), 19 deletions(-) create mode 100644 src/tests/test_client_config.py diff --git a/USAGE.md b/USAGE.md index 902b71b..f0afc34 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(cafile="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( + cafile="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( + cafile="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/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..8a8d8da 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -1,6 +1,7 @@ import os import shlex -from typing import List, Dict +from typing import List, Dict, Optional +import httpx from onepasswordconnectsdk.client import Client from onepasswordconnectsdk.models import ( Item, @@ -16,6 +17,44 @@ ) +class ClientConfig: + """Configuration class for 1Password Connect client. + Inherits from httpx.BaseClient to support all httpx client options. + """ + def __init__(self, cafile: Optional[str] = None, **kwargs): + """Initialize client configuration + + Args: + cafile (Optional[str]): Path to CA certificate file for SSL verification + **kwargs: Additional httpx client options + """ + self.cafile = cafile + 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, + **self.httpx_options + } + + if self.cafile: + args['verify'] = self.cafile + + 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 diff --git a/src/tests/test_client_config.py b/src/tests/test_client_config.py new file mode 100644 index 0000000..21e6034 --- /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_cafile(): + config = ClientConfig(cafile="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( + cafile="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 cafile + config = ClientConfig( + cafile="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_cafile(): + 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 From 426b901c8af33133b16bf9d4c8345c7b97e1f540 Mon Sep 17 00:00:00 2001 From: mattjacksoncello <156972821+mattjacksoncello@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:43:01 +1300 Subject: [PATCH 2/7] fix cyclical imports --- src/onepasswordconnectsdk/config.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index 8a8d8da..e63179b 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -1,8 +1,10 @@ import os import shlex -from typing import List, Dict, Optional +from typing import List, Dict, Optional, TYPE_CHECKING import httpx -from onepasswordconnectsdk.client import Client + +if TYPE_CHECKING: + from onepasswordconnectsdk.client import Client from onepasswordconnectsdk.models import ( Item, ParsedField, @@ -55,7 +57,7 @@ def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float return args -def load_dict(client: Client, config: dict): +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 @@ -122,7 +124,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. @@ -201,7 +203,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, From 3fa8e903fc5d26c1cf96d4061172e5ba33c42e7b Mon Sep 17 00:00:00 2001 From: mattjacksoncello <156972821+mattjacksoncello@users.noreply.github.com> Date: Wed, 29 Jan 2025 16:14:25 +1300 Subject: [PATCH 3/7] add example --- example/ca_file_example/list_secrets.py | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 example/ca_file_example/list_secrets.py diff --git a/example/ca_file_example/list_secrets.py b/example/ca_file_example/list_secrets.py new file mode 100644 index 0000000..b830011 --- /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( + cafile=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( + cafile=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()) From c2e521c3273ce6645257b396eb138fe1d9284af0 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 29 Jan 2025 21:54:42 +0000 Subject: [PATCH 4/7] Re order logic for getting httpx options --- src/onepasswordconnectsdk/config.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index e63179b..c127910 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -48,12 +48,15 @@ def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float 'base_url': base_url, 'headers': headers, 'timeout': timeout, - **self.httpx_options } + # Set verify from cafile first if self.cafile: args['verify'] = self.cafile + # Allow httpx_options (including verify) to override + args.update(self.httpx_options) + return args From d447dc326ea22089db431bec9003aca9af2cfd6a Mon Sep 17 00:00:00 2001 From: mattjacksoncello <156972821+mattjacksoncello@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:37:52 +1300 Subject: [PATCH 5/7] Update src/onepasswordconnectsdk/config.py LGTM Co-authored-by: Andi Titu <45081667+AndyTitu@users.noreply.github.com> --- src/onepasswordconnectsdk/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index c127910..2aee3c9 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -23,7 +23,7 @@ class ClientConfig: """Configuration class for 1Password Connect client. Inherits from httpx.BaseClient to support all httpx client options. """ - def __init__(self, cafile: Optional[str] = None, **kwargs): + def __init__(self, ca_file: Optional[str] = None, **kwargs): """Initialize client configuration Args: From f4980bb5d52fe309614bb80b7be85fe7240dc27f Mon Sep 17 00:00:00 2001 From: mattjacksoncello <156972821+mattjacksoncello@users.noreply.github.com> Date: Wed, 5 Mar 2025 08:38:04 +1300 Subject: [PATCH 6/7] Update src/onepasswordconnectsdk/config.py LGTM Co-authored-by: Andi Titu <45081667+AndyTitu@users.noreply.github.com> --- src/onepasswordconnectsdk/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index 2aee3c9..7063b58 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -30,7 +30,7 @@ def __init__(self, ca_file: Optional[str] = None, **kwargs): cafile (Optional[str]): Path to CA certificate file for SSL verification **kwargs: Additional httpx client options """ - self.cafile = cafile + self.ca_file = ca_file self.httpx_options = kwargs def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float) -> Dict: From f4a9352b8e7f9b48a02d0a660261cbb35359ea29 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Mar 2025 20:22:46 +0000 Subject: [PATCH 7/7] Rename 'cafile' to 'ca_file' in ClientConfig for consistency --- USAGE.md | 6 +++--- example/ca_file_example/list_secrets.py | 4 ++-- src/onepasswordconnectsdk/config.py | 8 ++++---- src/tests/test_client_config.py | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/USAGE.md b/USAGE.md index f0afc34..d1554b9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -41,7 +41,7 @@ When connecting to a 1Password Connect server using HTTPS, you may need to confi from onepasswordconnectsdk.config import ClientConfig # Verify SSL using a custom CA certificate -config = ClientConfig(cafile="path/to/ca.pem") +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) @@ -56,7 +56,7 @@ The ClientConfig class accepts all httpx client options as keyword arguments. Th ```python # Configure timeouts and redirects config = ClientConfig( - cafile="path/to/ca.pem", + 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 @@ -85,7 +85,7 @@ The same configuration options work for both synchronous and asynchronous client ```python config = ClientConfig( - cafile="path/to/ca.pem", + ca_file="path/to/ca.pem", timeout=30.0 ) async_client = new_client("https://connect.example.com", "your-token", is_async=True, config=config) diff --git a/example/ca_file_example/list_secrets.py b/example/ca_file_example/list_secrets.py index b830011..a10243c 100644 --- a/example/ca_file_example/list_secrets.py +++ b/example/ca_file_example/list_secrets.py @@ -25,7 +25,7 @@ def list_vault_secrets(): try: # Configure client with CA certificate verification config = ClientConfig( - cafile=CA_FILE, + ca_file=CA_FILE, timeout=30.0 # 30 second timeout ) @@ -53,7 +53,7 @@ async def list_vault_secrets_async(): try: # Configure client with CA certificate verification config = ClientConfig( - cafile=CA_FILE, + ca_file=CA_FILE, timeout=30.0 # 30 second timeout ) diff --git a/src/onepasswordconnectsdk/config.py b/src/onepasswordconnectsdk/config.py index 7063b58..bcde7a0 100644 --- a/src/onepasswordconnectsdk/config.py +++ b/src/onepasswordconnectsdk/config.py @@ -27,7 +27,7 @@ def __init__(self, ca_file: Optional[str] = None, **kwargs): """Initialize client configuration Args: - cafile (Optional[str]): Path to CA certificate file for SSL verification + ca_file (Optional[str]): Path to CA certificate file for SSL verification **kwargs: Additional httpx client options """ self.ca_file = ca_file @@ -50,9 +50,9 @@ def get_client_args(self, base_url: str, headers: Dict[str, str], timeout: float 'timeout': timeout, } - # Set verify from cafile first - if self.cafile: - args['verify'] = self.cafile + # 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) diff --git a/src/tests/test_client_config.py b/src/tests/test_client_config.py index 21e6034..524f1f4 100644 --- a/src/tests/test_client_config.py +++ b/src/tests/test_client_config.py @@ -2,8 +2,8 @@ from onepasswordconnectsdk.config import ClientConfig import httpx -def test_client_config_with_cafile(): - config = ClientConfig(cafile="path/to/ca.pem") +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" @@ -13,7 +13,7 @@ def test_client_config_with_cafile(): def test_client_config_with_kwargs(): config = ClientConfig( - cafile="path/to/ca.pem", + ca_file="path/to/ca.pem", follow_redirects=True, timeout=60.0 ) @@ -25,16 +25,16 @@ def test_client_config_with_kwargs(): assert args["timeout"] == 60.0 def test_client_config_verify_override(): - # When verify is explicitly set in kwargs, it should override cafile + # When verify is explicitly set in kwargs, it should override ca_file config = ClientConfig( - cafile="path/to/ca.pem", + 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_cafile(): +def test_client_config_no_ca_file(): config = ClientConfig() args = config.get_client_args("https://test.com", {"Authorization": "Bearer token"}, 30.0)