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.
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/PKG-INFO +1 -1
- optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/__init__.py +6 -0
- optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/epi_hmac.py +156 -0
- optimizely_opal_opal_tools_sdk-0.1.43.dev0/opal_tools_sdk/auth/requires_auth.py +41 -0
- {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
- {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
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/pyproject.toml +3 -3
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.py +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/README.md +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.cfg +0 -0
- {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
- {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
- {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
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_hmac.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_integration.py +0 -0
- {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
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_proteus.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_response.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/tests/test_rollback.py +0 -0
|
@@ -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
|
|
@@ -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.
|
|
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
|
-
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
include = ["opal_tools_sdk*"]
|
|
37
37
|
|
|
38
38
|
[tool.ruff]
|
|
39
39
|
line-length = 100
|
{optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{optimizely_opal_opal_tools_sdk-0.1.42.dev0 → optimizely_opal_opal_tools_sdk-0.1.43.dev0}/setup.cfg
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|