Skip to content

Commit c115778

Browse files
authored
CAIP-79 added support for SKD for bots (#366)
* CAIP-79 WIP added basic logic for SKD * CAIP-79 fix signature and function call * CAIP-79 fix existing tests * CAIP-79 added tests * CAIP-79 exteneded tests * CAIP-79 test coverage * CAIP-79 add simple caching for skd flag
1 parent 526d6d9 commit c115778

File tree

12 files changed

+404
-29
lines changed

12 files changed

+404
-29
lines changed

symphony/bdk/core/auth/auth_session.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@
55
import logging
66

77
from symphony.bdk.core.auth.exception import AuthInitializationError
8+
from symphony.bdk.core.auth.jwt_helper import extract_token_claims
9+
810

911
logger = logging.getLogger(__name__)
1012

1113
EXPIRATION_SAFETY_BUFFER_SECONDS = 5
14+
SKD_FLAG_NAME = "canUseSimplifiedKeyDelivery"
1215

1316

1417
class AuthSession:
@@ -33,7 +36,10 @@ async def refresh(self):
3336
"""
3437
logger.debug("Authenticate")
3538
self._session_token = await self._authenticator.retrieve_session_token()
36-
self._key_manager_token = await self._authenticator.retrieve_key_manager_token()
39+
if await self.skd_enabled:
40+
self._key_manager_token = ""
41+
return
42+
self.key_manager_token = await self._authenticator.retrieve_key_manager_token()
3743

3844
@property
3945
async def session_token(self):
@@ -71,6 +77,10 @@ async def key_manager_token(self):
7177
7278
:return: the key manager token
7379
"""
80+
81+
if await self.skd_enabled:
82+
return ""
83+
7484
if self._key_manager_token is None:
7585
self._key_manager_token = await self._authenticator.retrieve_key_manager_token()
7686
return self._key_manager_token
@@ -91,6 +101,15 @@ def key_manager_token(self, value):
91101
"""
92102
self._key_manager_token = value
93103

104+
@property
105+
async def skd_enabled(self):
106+
107+
token_data = extract_token_claims(await self.session_token)
108+
if not token_data.get(SKD_FLAG_NAME, False):
109+
return False
110+
return await self._authenticator.agent_version_service.is_skd_supported()
111+
112+
94113

95114
class OboAuthSession(AuthSession):
96115
"""RSA OBO Authentication session handle to get the OBO session token from.

symphony/bdk/core/auth/bot_authenticator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from symphony.bdk.core.config.model.bdk_retry_config import BdkRetryConfig
99
from symphony.bdk.core.retry import retry
1010
from symphony.bdk.core.retry.strategy import authentication_retry
11+
from symphony.bdk.core.service.version.agent_version_service import AgentVersionService
1112
from symphony.bdk.gen.api_client import ApiClient
1213
from symphony.bdk.gen.auth_api.certificate_authentication_api import CertificateAuthenticationApi
1314
from symphony.bdk.gen.login_api.authentication_api import AuthenticationApi
@@ -24,6 +25,7 @@ def __init__(self, session_auth_client: ApiClient, key_manager_auth_client: ApiC
2425
self._session_auth_client = session_auth_client
2526
self._key_manager_auth_client = key_manager_auth_client
2627
self._retry_config = retry_config
28+
self._agent_version_service = None
2729

2830
async def retrieve_session_token(self) -> str:
2931
"""Authenticates and retrieves a new session token.
@@ -59,6 +61,15 @@ async def _authenticate_and_get_token(self, api_client: ApiClient) -> str:
5961
:return: the token as a string
6062
"""
6163

64+
@property
65+
def agent_version_service(self) -> Optional[AgentVersionService]:
66+
return self._agent_version_service
67+
68+
@agent_version_service.setter
69+
def agent_version_service(self, agent_version_service: AgentVersionService):
70+
self._agent_version_service = agent_version_service
71+
72+
6273

6374
class BotAuthenticatorRsa(BotAuthenticator):
6475
"""Bot authenticator RSA implementation.

symphony/bdk/core/auth/jwt_helper.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,13 @@ def _parse_public_key_from_x509_cert(certificate: str) -> str:
8383
return public_key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode()
8484
except ValueError as exc:
8585
raise AuthInitializationError("Unable to parse the certificate. Check certificate format.") from exc
86+
87+
88+
def extract_token_claims(session_token):
89+
try:
90+
return jwt.decode(session_token,
91+
algorithms=[JWT_ENCRYPTION_ALGORITHM],
92+
options={"verify_signature": False}
93+
)
94+
except DecodeError:
95+
return {}

symphony/bdk/core/service/version/__init__.py

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import re
2+
from datetime import datetime, timezone
3+
4+
from symphony.bdk.core.auth.auth_session import AuthSession
5+
from symphony.bdk.core.auth.jwt_helper import generate_expiration_time
6+
from symphony.bdk.core.config.model.bdk_retry_config import BdkRetryConfig
7+
from symphony.bdk.core.retry import retry
8+
from symphony.bdk.gen.agent_api.signals_api import SignalsApi
9+
from symphony.bdk.gen.exceptions import ApiException
10+
11+
MIN_MAJOR_VERSION = 24
12+
MIN_MINOR_VERSION = 12
13+
VERSION_REGEXP = r"Agent-(\d+)\.(\d+)\..*"
14+
15+
16+
class AgentVersionService:
17+
"""Service class has one purpose only. It checks if version of agents supports simplified key delivery mechanism
18+
19+
"""
20+
21+
def __init__(self, signals_api: SignalsApi, retry_config: BdkRetryConfig):
22+
self._signals_api = signals_api
23+
self._retry_config = retry_config
24+
self._is_skd_supported = None
25+
self._expire_at = -1
26+
27+
async def is_skd_supported(self) -> bool:
28+
""" AgentVersionService stores cached version flag.
29+
Caching interval is the same as in to session token caching.
30+
Once cache is expired it calls agent info api to update version.
31+
32+
:return: boolean flag if skd supported for agent
33+
"""
34+
if (
35+
self._is_skd_supported is not None
36+
and self._expire_at
37+
> datetime.now(timezone.utc).timestamp()
38+
):
39+
return self._is_skd_supported
40+
self._expire_at = generate_expiration_time()
41+
self._is_skd_supported = await self._get_agent_skd_support()
42+
return self._is_skd_supported
43+
44+
45+
@retry
46+
async def _get_agent_skd_support(self) -> bool:
47+
try:
48+
agent_info = await self._signals_api.v1_info_get()
49+
if not agent_info or not agent_info.version:
50+
return False
51+
except ApiException:
52+
return False
53+
agent_major_version, agent_minor_version = self._parse_version(agent_info.version)
54+
if not agent_major_version:
55+
return False
56+
if agent_major_version == MIN_MAJOR_VERSION:
57+
return agent_minor_version >= MIN_MINOR_VERSION
58+
return agent_major_version > MIN_MAJOR_VERSION
59+
60+
@staticmethod
61+
def _parse_version(version_string):
62+
if not version_string:
63+
return None, None
64+
match = re.match(VERSION_REGEXP, version_string)
65+
if match:
66+
return int(match.group(1)), int(match.group(2))
67+
return None, None

symphony/bdk/core/service_factory.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from symphony.bdk.core.service.signal.signal_service import SignalService, OboSignalService
1919
from symphony.bdk.core.service.stream.stream_service import StreamService, OboStreamService
2020
from symphony.bdk.core.service.user.user_service import UserService, OboUserService
21+
from symphony.bdk.core.service.version.agent_version_service import AgentVersionService
2122
from symphony.bdk.gen.agent_api.attachments_api import AttachmentsApi
2223
from symphony.bdk.gen.agent_api.audit_trail_api import AuditTrailApi
2324
from symphony.bdk.gen.agent_api.datafeed_api import DatafeedApi
@@ -203,6 +204,14 @@ def get_presence_service(self) -> PresenceService:
203204
self._config.retry
204205
)
205206

207+
def get_agent_version_service(self) -> AgentVersionService:
208+
"""Returns a fully initialized AgentVersionService
209+
210+
:return: a new AgentVersionService instance
211+
"""
212+
return AgentVersionService(SignalsApi(self._agent_client),
213+
self._config.retry)
214+
206215

207216
class OboServiceFactory:
208217
"""Factory responsible for creating BDK service instances for OBO-enabled endpoints only:

symphony/bdk/core/symphony_bdk.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ def __init__(self, config):
105105
"You can however use services in OBO mode if app authentication is configured.")
106106

107107
def _initialize_bot_services(self):
108-
self._bot_session = AuthSession(self._authenticator_factory.get_bot_authenticator())
108+
bot_authenticator = self._authenticator_factory.get_bot_authenticator()
109+
self._bot_session = AuthSession(bot_authenticator)
109110
self._service_factory = ServiceFactory(self._api_client_factory, self._bot_session, self._config)
110111
self._user_service = self._service_factory.get_user_service()
111112
self._message_service = self._service_factory.get_message_service()
@@ -123,6 +124,7 @@ def _initialize_bot_services(self):
123124
self._datafeed_loop.subscribe(self._activity_registry)
124125
# initialises extension service and register decorated extensions
125126
self._extension_service = ExtensionService(self._api_client_factory, self._bot_session, self._config)
127+
bot_authenticator.agent_version_service = self._service_factory.get_agent_version_service()
126128

127129
@bot_service
128130
def bot_session(self) -> AuthSession:

tests/conftest.py

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import datetime
6+
from unittest.mock import patch
67

78
import pytest
89

@@ -12,33 +13,50 @@
1213
from cryptography.hazmat.primitives.asymmetric import rsa
1314
from cryptography.x509 import NameOID
1415

16+
from symphony.bdk.core.auth.auth_session import SKD_FLAG_NAME
1517

16-
@pytest.fixture(name="root_key", scope="session") # the fixture will be created only once for entire test session.
18+
19+
@pytest.fixture(
20+
name="root_key", scope="session"
21+
) # the fixture will be created only once for entire test session.
1722
def fixture_root_key():
1823
return rsa.generate_private_key(
19-
public_exponent=65537,
20-
key_size=4096,
21-
backend=default_backend())
24+
public_exponent=65537, key_size=4096, backend=default_backend()
25+
)
2226

2327

2428
@pytest.fixture(name="rsa_key", scope="session")
2529
def fixture_rsa_key(root_key):
2630
return root_key.private_bytes(
2731
encoding=serialization.Encoding.PEM,
2832
format=serialization.PrivateFormat.PKCS8,
29-
encryption_algorithm=serialization.NoEncryption()).decode("utf-8")
33+
encryption_algorithm=serialization.NoEncryption(),
34+
).decode("utf-8")
3035

3136

3237
@pytest.fixture(name="certificate", scope="session")
3338
def fixture_certificate(root_key):
34-
subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, u"commonName")])
39+
subject = issuer = x509.Name(
40+
[x509.NameAttribute(NameOID.COMMON_NAME, "commonName")]
41+
)
3542
now = datetime.datetime.utcnow()
36-
cert = x509.CertificateBuilder() \
37-
.subject_name(subject) \
38-
.issuer_name(issuer) \
39-
.public_key(root_key.public_key()) \
40-
.serial_number(x509.random_serial_number()) \
41-
.not_valid_before(now) \
42-
.not_valid_after(now + datetime.timedelta(days=30)) \
43+
cert = (
44+
x509.CertificateBuilder()
45+
.subject_name(subject)
46+
.issuer_name(issuer)
47+
.public_key(root_key.public_key())
48+
.serial_number(x509.random_serial_number())
49+
.not_valid_before(now)
50+
.not_valid_after(now + datetime.timedelta(days=30))
4351
.sign(root_key, hashes.SHA512(), default_backend())
52+
)
4453
return cert.public_bytes(encoding=serialization.Encoding.PEM).decode("utf-8")
54+
55+
56+
@pytest.fixture(autouse=True)
57+
def mock_jwt_decode_for_skd():
58+
with patch(
59+
"symphony.bdk.core.auth.auth_session.extract_token_claims",
60+
return_value={SKD_FLAG_NAME: False},
61+
) as mock:
62+
yield mock

0 commit comments

Comments
 (0)