optimizely-opal.opal-tools-sdk 0.1.42.dev0__tar.gz → 0.1.43.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/PKG-INFO +1 -1
  2. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/__init__.py +6 -0
  3. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/epi_hmac.py +156 -0
  4. optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/requires_auth.py +41 -0
  5. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
  6. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +3 -0
  7. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/pyproject.toml +3 -3
  8. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.py +1 -1
  9. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/README.md +0 -0
  10. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/__init__.py +0 -0
  11. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/_registry.py +0 -0
  12. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/config.py +0 -0
  13. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/context.py +0 -0
  14. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/decorators.py +0 -0
  15. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/logging.py +0 -0
  16. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/models.py +0 -0
  17. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/proteus.py +0 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/response.py +0 -0
  19. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/service.py +0 -0
  20. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/opal_tools_sdk/ui.py +0 -0
  21. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
  22. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
  23. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
  24. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.cfg +0 -0
  25. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_async_tool.py +0 -0
  26. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_auth_middleware.py +0 -0
  27. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_dynamic_ui.py +0 -0
  28. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_hmac.py +0 -0
  29. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_integration.py +0 -0
  30. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_nested_schema.py +0 -0
  31. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_proteus.py +0 -0
  32. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_response.py +0 -0
  33. {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_rollback.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.42.dev0
3
+ Version: 0.1.43.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -0,0 +1,6 @@
1
+ """Authentication utilities for Opal Tools SDK."""
2
+
3
+ from .epi_hmac import EpiHmac
4
+ from .requires_auth import requires_auth
5
+
6
+ __all__ = ["EpiHmac", "requires_auth"]
@@ -0,0 +1,156 @@
1
+ """epi-hmac signing and verification utility.
2
+
3
+ Implements the canonical epi-hmac scheme used across Opal services
4
+ (API Gateway, system-tools, file-server, instructions-service).
5
+
6
+ Signing algorithm::
7
+
8
+ timestamp = milliseconds since epoch
9
+ nonce = UUID v4
10
+ body_md5 = base64(md5(body)) # empty body hashes to base64(md5(""))
11
+ message = app_key + method + endpoint + timestamp + nonce + body_md5
12
+ secret_bytes = base64_decode(hmac_secret)
13
+ signature = base64(HMAC-SHA256(secret_bytes, message))
14
+ header = "epi-hmac {app_key}:{timestamp}:{nonce}:{signature}"
15
+
16
+ Replay protection (v1 posture): verification enforces the 5-minute timestamp
17
+ window but does NOT track nonces, so a captured signed request can be replayed
18
+ within that window. Nonce dedup (e.g. a shared store) is the caller's
19
+ responsibility if stronger replay protection is required.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import binascii
26
+ import hashlib
27
+ import hmac as hmac_mod
28
+ import time
29
+ import uuid
30
+
31
+ _TIMESTAMP_WINDOW_MS = 300_000 # 5 minutes (past timestamps)
32
+ _MAX_FUTURE_SKEW_MS = 60_000 # 1 minute (clock skew tolerance)
33
+
34
+
35
+ class EpiHmac:
36
+ """epi-hmac signing and verification utility.
37
+
38
+ Args:
39
+ app_key: Credential identifier.
40
+ secret: Base64-encoded HMAC secret.
41
+ """
42
+
43
+ def __init__(self, app_key: str, secret: str) -> None:
44
+ self.app_key = app_key
45
+ # Fail closed on a malformed secret rather than silently decoding to the
46
+ # wrong bytes (b64decode without validate ignores non-alphabet chars).
47
+ try:
48
+ self._secret_bytes = base64.b64decode(secret, validate=True)
49
+ except (binascii.Error, ValueError) as exc:
50
+ raise ValueError("EpiHmac secret must be valid base64") from exc
51
+
52
+ def sign(
53
+ self,
54
+ method: str,
55
+ endpoint: str,
56
+ body: bytes = b"",
57
+ ) -> str:
58
+ """Generate an ``Authorization: epi-hmac ...`` header value.
59
+
60
+ Args:
61
+ method: HTTP method (GET, POST, etc.).
62
+ endpoint: Request path including query string.
63
+ body: Request body bytes (empty for GET/DELETE).
64
+
65
+ Returns:
66
+ The full Authorization header value.
67
+ """
68
+ timestamp = str(int(time.time() * 1000))
69
+ nonce = str(uuid.uuid4())
70
+ return self._build_header(method, endpoint, body, timestamp, nonce)
71
+
72
+ def verify(
73
+ self,
74
+ auth_header: str,
75
+ method: str,
76
+ endpoint: str,
77
+ body: bytes = b"",
78
+ ) -> bool:
79
+ """Verify an ``Authorization: epi-hmac ...`` header.
80
+
81
+ Checks app_key match, timestamp freshness (5-minute window),
82
+ and signature correctness using timing-safe comparison.
83
+
84
+ Args:
85
+ auth_header: The full Authorization header value.
86
+ method: HTTP method.
87
+ endpoint: Request path including query string.
88
+ body: Request body bytes.
89
+
90
+ Returns:
91
+ True if the header is valid.
92
+ """
93
+ if not auth_header.startswith("epi-hmac "):
94
+ return False
95
+
96
+ parts = auth_header[len("epi-hmac ") :].split(":")
97
+ if len(parts) != 4:
98
+ return False
99
+
100
+ recv_app_key, timestamp, nonce, recv_signature = parts
101
+
102
+ # Validate app_key
103
+ if not hmac_mod.compare_digest(recv_app_key, self.app_key):
104
+ return False
105
+
106
+ # Validate timestamp freshness: reject far-future timestamps (beyond clock
107
+ # skew) and old timestamps (beyond replay window). The original symmetric
108
+ # check `abs(now - ts) <= window` accepted timestamps up to 5 min in the
109
+ # FUTURE, doubling the replay surface. A future timestamp is never
110
+ # legitimate beyond small clock skew.
111
+ try:
112
+ ts_ms = int(timestamp)
113
+ except ValueError:
114
+ return False
115
+ now_ms = int(time.time() * 1000)
116
+ # Reject timestamps too far in the future (beyond clock skew tolerance)
117
+ if ts_ms - now_ms > _MAX_FUTURE_SKEW_MS:
118
+ return False
119
+ # Reject timestamps too far in the past (beyond replay window)
120
+ if now_ms - ts_ms > _TIMESTAMP_WINDOW_MS:
121
+ return False
122
+
123
+ # Recompute expected signature
124
+ expected_header = self._build_header(method, endpoint, body, timestamp, nonce)
125
+ expected_signature = expected_header.split(":")[-1]
126
+
127
+ return hmac_mod.compare_digest(recv_signature, expected_signature)
128
+
129
+ def _build_header(
130
+ self,
131
+ method: str,
132
+ endpoint: str,
133
+ body: bytes,
134
+ timestamp: str,
135
+ nonce: str,
136
+ ) -> str:
137
+ # Empty-body rule: when body is b"", we compute base64(md5(b"")) and
138
+ # include it in the signed message. The Python, C# and TypeScript SDKs
139
+ # all use this same canonical construction (proven by the shared golden
140
+ # vectors in test_hmac.py).
141
+ #
142
+ # CROSS-IMPL: this is the tool-provider trust boundary (TMS -> tool
143
+ # provider) and is SEPARATE from the api-gateway's inbound HMAC
144
+ # (external -> Opal). The gateway uses a different canonical (no body
145
+ # hash) on its own routes; the two boundaries are independent and are
146
+ # NOT expected to share a wire format. TMS itself has no HMAC
147
+ # implementation. This signer/verifier only needs to be internally
148
+ # consistent; do not assume interop with the gateway canonical.
149
+ body_md5 = base64.b64encode(hashlib.md5(body).digest()).decode()
150
+
151
+ message = f"{self.app_key}{method}{endpoint}{timestamp}{nonce}{body_md5}"
152
+ signature = base64.b64encode(
153
+ hmac_mod.new(self._secret_bytes, message.encode(), hashlib.sha256).digest()
154
+ ).decode()
155
+
156
+ return f"epi-hmac {self.app_key}:{timestamp}:{nonce}:{signature}"
@@ -0,0 +1,41 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+
4
+ from fastapi import Header, HTTPException
5
+
6
+
7
+ def requires_auth(provider: str, scope_bundle: str, required: bool = True):
8
+ """Decorator to indicate that a tool requires authentication.
9
+
10
+ Args:
11
+ provider: Authentication provider (e.g., "google", "microsoft")
12
+ scope_bundle: Scope bundle required (e.g., "calendar", "drive")
13
+ required: Whether authentication is mandatory (default: True)
14
+
15
+ Returns:
16
+ Decorator function
17
+ """
18
+
19
+ def decorator(func: Callable):
20
+ @wraps(func)
21
+ async def wrapper(*args, authorization: str | None = Header(None), **kwargs):
22
+ if required and not authorization:
23
+ raise HTTPException(status_code=401, detail="Authentication required")
24
+
25
+ # The Tools Management Service will provide the appropriate token
26
+ # in the Authorization header
27
+ return await func(*args, authorization=authorization, **kwargs)
28
+
29
+ # Store auth requirements in function metadata
30
+ auth_req = {"provider": provider, "scope_bundle": scope_bundle, "required": required}
31
+
32
+ # Initialize the list if it doesn't exist
33
+ if not hasattr(wrapper, "__auth_requirements__"):
34
+ wrapper.__auth_requirements__ = [] # type: ignore[attr-defined]
35
+
36
+ # Add this auth requirement to the list
37
+ wrapper.__auth_requirements__.append(auth_req) # type: ignore[attr-defined]
38
+
39
+ return wrapper
40
+
41
+ return decorator
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.42.dev0
3
+ Version: 0.1.43.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -12,6 +12,9 @@ opal_tools_sdk/proteus.py
12
12
  opal_tools_sdk/response.py
13
13
  opal_tools_sdk/service.py
14
14
  opal_tools_sdk/ui.py
15
+ opal_tools_sdk/auth/__init__.py
16
+ opal_tools_sdk/auth/epi_hmac.py
17
+ opal_tools_sdk/auth/requires_auth.py
15
18
  optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO
16
19
  optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt
17
20
  optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "optimizely-opal.opal-tools-sdk"
7
- version = "0.1.42-dev"
7
+ version = "0.1.43-dev"
8
8
  description = "SDK for creating Opal-compatible tools services"
9
9
  authors = [{ name = "Optimizely", email = "opal-team@optimizely.com" }]
10
10
  readme = "README.md"
@@ -32,8 +32,8 @@ dev = [
32
32
  "Homepage" = "https://github.com/optimizely/opal-tools-sdk"
33
33
  "Bug Tracker" = "https://github.com/optimizely/opal-tools-sdk/issues"
34
34
 
35
- [tool.setuptools]
36
- packages = ["opal_tools_sdk"]
35
+ [tool.setuptools.packages.find]
36
+ include = ["opal_tools_sdk*"]
37
37
 
38
38
  [tool.ruff]
39
39
  line-length = 100
@@ -2,7 +2,7 @@ from setuptools import find_packages, setup
2
2
 
3
3
  setup(
4
4
  name="optimizely-opal.opal-tools-sdk",
5
- version="0.1.42-dev",
5
+ version="0.1.43-dev",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  "fastapi>=0.100.0",