Skip to content

Commit 791cd1e

Browse files
authored
Merge pull request #179 from 1Password/andi/sdk-desktop-integrations
Add support for authenticating via desktop app
2 parents c29275d + dd4ea87 commit 791cd1e

File tree

13 files changed

+266
-97
lines changed

13 files changed

+266
-97
lines changed

.github/workflows/validate.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
strategy:
2020
matrix:
2121
os: [ubuntu-latest, windows-latest, macos-latest]
22-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
22+
python-version: ["3.10", "3.11", "3.12", "3.13"]
2323
runs-on: ${{ matrix.os }}
2424
steps:
2525
- uses: actions/checkout@v3

example/desktop_app.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# [developer-docs.sdk.python.sdk-import]-start
2+
from onepassword import *
3+
import asyncio
4+
5+
6+
async def main():
7+
# [developer-docs.sdk.python.client-initialization]-start
8+
# Connects to the 1Password desktop app.
9+
client = await Client.authenticate(
10+
auth=DesktopAuth(
11+
account_name="AndiTituTest" # Set to your 1Password account name.
12+
),
13+
# Set the following to your own integration name and version.
14+
integration_name="My 1Password Integration",
15+
integration_version="v1.0.0",
16+
)
17+
18+
# [developer-docs.sdk.python.list-vaults]-start
19+
vaults = await client.vaults.list()
20+
for vault in vaults:
21+
print(vault)
22+
# [developer-docs.sdk.python.list-vaults]-end
23+
24+
# [developer-docs.sdk.python.list-items]-start
25+
overviews = await client.items.list("xw33qlvug6moegr3wkk5zkenoa")
26+
for overview in overviews:
27+
print(overview.title)
28+
# [developer-docs.sdk.python.list-items]-end
29+
30+
31+
if __name__ == "__main__":
32+
asyncio.run(main())

src/onepassword/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Code generated by op-codegen - DO NO EDIT MANUALLY
22

3-
from .client import Client
3+
from .client import Client, DesktopAuth
44
from .defaults import DEFAULT_INTEGRATION_NAME, DEFAULT_INTEGRATION_VERSION
55
from .types import * # noqa F403
66
from .errors import * # noqa F403
@@ -20,6 +20,7 @@
2020
"Vaults",
2121
"DEFAULT_INTEGRATION_NAME",
2222
"DEFAULT_INTEGRATION_VERSION",
23+
"DesktopAuth",
2324
]
2425

2526
for name, obj in inspect.getmembers(sys.modules["onepassword.types"]):

src/onepassword/client.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
from __future__ import annotations
44
import weakref
5-
from .core import _init_client, _release_client
6-
from .defaults import new_default_config
5+
from .core import Core, UniffiCore
6+
from .desktop_core import DesktopCore
7+
from .defaults import new_default_config, DesktopAuth
78
from .secrets import Secrets
89
from .items import Items
910
from .vaults import Vaults
@@ -16,23 +17,28 @@ class Client:
1617

1718
@classmethod
1819
async def authenticate(
19-
cls, auth: str, integration_name: str, integration_version: str
20+
cls, auth: str | DesktopAuth, integration_name: str, integration_version: str
2021
) -> Client:
2122
config = new_default_config(
22-
auth=auth or "",
23+
auth=auth,
2324
integration_name=integration_name,
2425
integration_version=integration_version,
2526
)
2627

27-
client_id = int(await _init_client(config))
28+
if isinstance(auth, DesktopAuth):
29+
core = DesktopCore(auth.account_name)
30+
else:
31+
core = UniffiCore()
32+
33+
client_id = int(await core.init_client(config))
2834

2935
authenticated_client = cls()
3036

31-
authenticated_client.secrets = Secrets(client_id)
32-
authenticated_client.items = Items(client_id)
33-
authenticated_client.vaults = Vaults(client_id)
37+
authenticated_client.secrets = Secrets(client_id, core)
38+
authenticated_client.items = Items(client_id, core)
39+
authenticated_client.vaults = Vaults(client_id, core)
3440
authenticated_client._finalizer = weakref.finalize(
35-
cls, _release_client, client_id
41+
cls, core.release_client, client_id
3642
)
3743

3844
return authenticated_client

src/onepassword/core.py

Lines changed: 63 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,72 @@
11
import json
22
import platform
3-
3+
from typing import Protocol
44
from onepassword.errors import raise_typed_exception
55

66
# In empirical tests, we determined that maximum message size that can cross the FFI boundary
77
# is ~128MB. Past this limit, FFI will throw an error and the program will crash.
88
# We set the limit to 50MB to be safe and consistent with the other SDKs (where this limit is 64MB), to be reconsidered upon further testing
99
MESSAGE_LIMIT = 50 * 1024 * 1024
1010

11-
machine_arch = platform.machine().lower()
12-
13-
if machine_arch in ["x86_64", "amd64"]:
14-
import onepassword.lib.x86_64.op_uniffi_core as core
15-
elif machine_arch in ["aarch64", "arm64"]:
16-
import onepassword.lib.aarch64.op_uniffi_core as core
17-
else:
18-
raise ImportError(
19-
f"Your machine's architecture is not currently supported: {machine_arch}"
20-
)
21-
22-
23-
# InitClient creates a client instance in the current core module and returns its unique ID.
24-
async def _init_client(client_config):
25-
try:
26-
return await core.init_client(json.dumps(client_config))
27-
except Exception as e:
28-
raise_typed_exception(e)
29-
30-
31-
# Invoke calls specified business logic from the SDK core.
32-
async def _invoke(invoke_config):
33-
serialized_config = json.dumps(invoke_config)
34-
if len(serialized_config.encode()) > MESSAGE_LIMIT:
35-
raise ValueError(
36-
f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, please contact 1Password at [email protected] or https://developer.1password.com/joinslack if you need help."
37-
)
38-
try:
39-
return await core.invoke(serialized_config)
40-
except Exception as e:
41-
raise_typed_exception(e)
42-
43-
44-
# Invoke calls specified business logic from the SDK core.
45-
def _invoke_sync(invoke_config):
46-
serialized_config = json.dumps(invoke_config)
47-
if len(serialized_config.encode()) > MESSAGE_LIMIT:
48-
raise ValueError(
49-
f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, please contact 1Password at [email protected] or https://developer.1password.com/joinslack if you need help."
50-
)
51-
try:
52-
return core.invoke_sync(serialized_config)
53-
except Exception as e:
54-
raise_typed_exception(e)
55-
56-
57-
# ReleaseClient releases memory in the SDK core associated with the given client ID.
58-
def _release_client(client_id):
59-
return core.release_client(json.dumps(client_id))
11+
class Core(Protocol):
12+
async def init_client(self, client_config: dict) -> str: ...
13+
async def invoke(self, invoke_config: dict) -> str: ...
14+
def invoke_sync(self, invoke_config: dict) -> str: ...
15+
def release_client(self, client_id: int) -> None: ...
16+
17+
class UniffiCore:
18+
def __init__(self):
19+
machine_arch = platform.machine().lower()
20+
21+
if machine_arch in ["x86_64", "amd64"]:
22+
import onepassword.lib.x86_64.op_uniffi_core as core
23+
elif machine_arch in ["aarch64", "arm64"]:
24+
import onepassword.lib.aarch64.op_uniffi_core as core
25+
else:
26+
raise ImportError(
27+
f"Your machine's architecture is not currently supported: {machine_arch}"
28+
)
29+
30+
self.core = core
31+
32+
async def init_client(self, client_config: dict):
33+
"""Creates a client instance in the current core module and returns its unique ID."""
34+
try:
35+
return await self.core.init_client(json.dumps(client_config))
36+
except Exception as e:
37+
raise_typed_exception(e)
38+
39+
async def invoke(self, invoke_config: dict):
40+
"""Invoke business logic asynchronously."""
41+
serialized_config = json.dumps(invoke_config)
42+
if len(serialized_config.encode()) > MESSAGE_LIMIT:
43+
raise ValueError(
44+
f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, "
45+
"please contact 1Password at [email protected] or "
46+
"https://developer.1password.com/joinslack if you need help."
47+
)
48+
try:
49+
return await self.core.invoke(serialized_config)
50+
except Exception as e:
51+
raise_typed_exception(e)
52+
53+
def invoke_sync(self, invoke_config: dict):
54+
"""Invoke business logic synchronously."""
55+
serialized_config = json.dumps(invoke_config)
56+
if len(serialized_config.encode()) > MESSAGE_LIMIT:
57+
raise ValueError(
58+
f"message size exceeds the limit of {MESSAGE_LIMIT} bytes, "
59+
"please contact 1Password at [email protected] or "
60+
"https://developer.1password.com/joinslack if you need help."
61+
)
62+
try:
63+
return self.core.invoke_sync(serialized_config)
64+
except Exception as e:
65+
raise_typed_exception(e)
66+
67+
def release_client(self, client_id: int):
68+
"""Releases memory in the SDK core associated with the given client ID."""
69+
try:
70+
return self.core.release_client(json.dumps(client_id))
71+
except Exception as e:
72+
raise_typed_exception(e)

src/onepassword/defaults.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,19 @@
99
DEFAULT_REQUEST_LIBRARY_VERSION = "0.11.24"
1010
DEFAULT_OS_VERSION = "0.0.0"
1111

12+
class DesktopAuth:
13+
def __init__(self, account_name: str):
14+
"""
15+
Initialize a DesktopAuth instance.
16+
17+
Args:
18+
account_name (str): The name of the account.
19+
"""
20+
self.account_name = account_name
1221

1322
# Generates a configuration dictionary with the user's parameters
14-
def new_default_config(auth, integration_name, integration_version):
23+
def new_default_config(auth: DesktopAuth | str, integration_name, integration_version):
1524
client_config_dict = {
16-
"serviceAccountToken": auth,
1725
"programmingLanguage": SDK_LANGUAGE,
1826
"sdkVersion": SDK_VERSION,
1927
"integrationName": integration_name,
@@ -24,4 +32,7 @@ def new_default_config(auth, integration_name, integration_version):
2432
"osVersion": DEFAULT_OS_VERSION,
2533
"architecture": platform.machine(),
2634
}
35+
if isinstance(auth, str):
36+
client_config_dict["serviceAccountToken"] = auth
37+
2738
return client_config_dict

src/onepassword/desktop_core.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import ctypes
2+
import json
3+
import os
4+
import base64
5+
from ctypes import c_uint8, c_size_t, c_int32, POINTER, byref, c_void_p
6+
from .core import UniffiCore
7+
from onepassword.errors import raise_typed_exception
8+
9+
10+
def find_1password_lib_path():
11+
locations = [
12+
"/Users/andititu/core/target/debug/libop_sdk_ipc_client.dylib"
13+
]
14+
15+
for lib_path in locations:
16+
if os.path.exists(lib_path):
17+
return lib_path
18+
19+
raise FileNotFoundError("1Password desktop application not found")
20+
21+
class DesktopCore:
22+
def __init__(self, account_name: str):
23+
# Determine the path to the desktop app.
24+
path = find_1password_lib_path()
25+
26+
self.lib = ctypes.CDLL(path)
27+
self.account_name = account_name
28+
29+
# Bind the Rust-exported functions
30+
self.send_message = self.lib.op_sdk_ipc_send_message
31+
self.send_message.argtypes = [
32+
POINTER(c_uint8), # msg_ptr
33+
c_size_t, # msg_len
34+
POINTER(POINTER(c_uint8)), # out_buf
35+
POINTER(c_size_t), # out_len
36+
POINTER(c_size_t), # out_cap
37+
]
38+
self.send_message.restype = c_int32
39+
40+
self.free_message = self.lib.op_sdk_ipc_free_response
41+
self.free_message.argtypes = [POINTER(c_uint8), c_size_t, c_size_t]
42+
self.free_message.restype = None
43+
44+
def call_shared_library(self, payload: str, operation_kind: str) -> bytes:
45+
# Prepare the input
46+
encoded_payload = base64.b64encode(payload.encode("utf-8")).decode("utf-8")
47+
data = {
48+
"kind": operation_kind,
49+
"account_name": self.account_name,
50+
"payload": encoded_payload,
51+
}
52+
message = json.dumps(data).encode("utf-8")
53+
54+
# Prepare output parameters
55+
out_buf = POINTER(c_uint8)()
56+
out_len = c_size_t()
57+
out_cap = c_size_t()
58+
59+
ret = self.send_message(
60+
(ctypes.cast(message, POINTER(c_uint8))),
61+
len(message),
62+
byref(out_buf),
63+
byref(out_len),
64+
byref(out_cap),
65+
)
66+
67+
if ret != 0:
68+
raise RuntimeError(f"send_message failed with code {ret}. Please make sure the Desktop app intehration setting is enabled, or contact 1Password support.")
69+
70+
# Copy bytes into Python
71+
data = ctypes.string_at(out_buf, out_len.value)
72+
73+
# Free memory via Rust's exported function
74+
self.free_message(out_buf, out_len, out_cap)
75+
76+
parsed = json.loads(data)
77+
payload = bytes(parsed.get("payload", [])).decode("utf-8")
78+
79+
success = parsed.get("success", False)
80+
if not success:
81+
raise_typed_exception(Exception(str(payload)))
82+
83+
return payload
84+
85+
async def init_client(self, config: dict) -> int:
86+
payload = json.dumps(config)
87+
resp = self.call_shared_library(payload, "init_client")
88+
return json.loads(resp)
89+
90+
async def invoke(self, invoke_config: dict) -> str:
91+
payload = json.dumps(invoke_config)
92+
return self.call_shared_library(payload, "invoke")
93+
94+
def release_client(self, client_id: int):
95+
payload = json.dumps(client_id)
96+
try:
97+
self.call_shared_library(payload, "release_client")
98+
except Exception as e:
99+
print(f"failed to release client: {e}")

0 commit comments

Comments
 (0)