Skip to content

Commit dd89e8f

Browse files
author
Adam Weeks
committed
refactor: use explicit token providers
1 parent 54d334c commit dd89e8f

10 files changed

Lines changed: 424 additions & 454 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ jobs:
1818
with:
1919
python-version: ${{ matrix.python-version }}
2020
- run: python -m pip install --upgrade pip
21-
- run: python -m pip install -e ".[aws]" pytest ruff
21+
- run: python -m pip install -e ".[dev]"
2222
- run: ruff check .
2323
- run: python -m pytest -q

README.md

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,87 @@
11
# Webex BYODS Python SDK
22

33
Reusable Python utilities for managing Webex Bring Your Own Data Source (BYODS)
4-
data sources. The SDK supports data source registration, retrieval, updates,
5-
schema discovery, JWT claim inspection, service-app authentication, and data
6-
source token extension.
4+
data sources. The SDK supports registration, retrieval, updates, schema
5+
discovery, JWT claim inspection, service-app authentication, and data source
6+
token extension.
77

8-
This repository is the source of truth for the SDK. The companion CLI project
9-
uses this package as a consumer.
8+
The SDK does not read credentials from files, environment variables, or cloud
9+
secret managers. The host application owns secret retrieval and provides an
10+
explicit token provider to the SDK.
1011

1112
## Install
1213

13-
Until the package is published, install it directly from a local checkout:
14+
Until publication, install from a local checkout:
1415

1516
```bash
1617
python3 -m pip install -e .
1718
```
1819

19-
Install AWS Secrets Manager support when running in Lambda or supplying a
20-
secret name:
20+
## Authenticate
2121

22-
```bash
23-
python3 -m pip install -e ".[aws]"
24-
```
25-
26-
## Configure authentication
22+
For an already-issued service-app token, use a static provider:
2723

28-
Copy the safe template and provide service-app credentials plus a personal
29-
token that can request service-app tokens:
24+
```python
25+
from webex_byods import StaticAccessTokenProvider, WebexDataSourceClient
3026

31-
```bash
32-
cp token-config.json.template token-config.json
27+
client = WebexDataSourceClient(
28+
token_provider=StaticAccessTokenProvider("service-app-access-token")
29+
)
3330
```
3431

35-
Do not commit `token-config.json`.
36-
37-
## Use the SDK
32+
For automatic service-app token acquisition, the host application supplies
33+
credentials and a personal-token provider. It can obtain those values from its
34+
own configuration, AWS Secrets Manager, or another secret system.
3835

3936
```python
40-
from webex_byods import TokenManager, WebexDataSourceManager
41-
42-
token_manager = TokenManager(config_path="token-config.json")
43-
client = WebexDataSourceManager(
44-
token_manager.get_service_app_token(), token_manager=token_manager
37+
from webex_byods import (
38+
ServiceAppCredentials,
39+
StaticAccessTokenProvider,
40+
WebexDataSourceClient,
41+
WebexServiceAppTokenProvider,
4542
)
4643

47-
data_sources = client.list_all_data_sources()
48-
schemas = client.get_data_source_schemas()
44+
credentials = ServiceAppCredentials(
45+
app_id="service-app-id",
46+
client_id="service-app-client-id",
47+
client_secret="service-app-client-secret",
48+
target_org_id="target-org-id",
49+
)
50+
service_token_provider = WebexServiceAppTokenProvider(
51+
credentials,
52+
StaticAccessTokenProvider("personal-access-token"),
53+
)
54+
client = WebexDataSourceClient(token_provider=service_token_provider)
4955
```
5056

51-
BYODS API operations preserve a dictionary result contract containing
52-
`success`, `data` or `error`, and `status_code`. If a data-source API request
53-
returns HTTP 401, the client obtains a fresh service-app token and retries the
54-
request once.
57+
`OAuthRefreshTokenProvider` is available when the host application manages an
58+
OAuth refresh token. Providers use `InMemoryTokenStore` by default; applications
59+
that need persistence can supply their own `TokenStore` implementation.
5560

56-
To rotate a data source nonce and obtain a fresh JWS token:
61+
## Use the client
5762

5863
```python
59-
result = token_manager.extend_data_source_token(
64+
data_sources = client.list_all_data_sources()
65+
schemas = client.get_data_source_schemas()
66+
67+
result = client.extend_data_source_token(
6068
data_source_id="data-source-id",
6169
token_lifetime_minutes=1440,
6270
)
6371
```
6472

73+
BYODS API operations return dictionaries containing `success`, `data` or
74+
`error`, and `status_code`. A 401 response causes one retry with
75+
`token_provider.get_access_token(force_refresh=True)`.
76+
77+
`WebexDataSourceManager` remains an alias for `WebexDataSourceClient` during
78+
the transition. `TokenManager` is deprecated; use
79+
`WebexServiceAppTokenProvider` instead.
80+
6581
## Development
6682

6783
```bash
68-
python3 -m pip install -e ".[aws]"
69-
python3 -m pip install pytest ruff
84+
python3 -m pip install -e ".[dev]"
7085
python3 -m pytest -q
7186
ruff check .
7287
```

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "webex-byods-sdk"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "Reusable Webex Bring Your Own Data Source management utilities"
99
readme = "README.md"
1010
requires-python = ">=3.8"
@@ -14,7 +14,6 @@ dependencies = [
1414
]
1515

1616
[project.optional-dependencies]
17-
aws = ["boto3>=1.28.0"]
1817
dev = ["pytest>=7", "ruff>=0.5"]
1918

2019
[project.urls]

tests/test_auth.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
from unittest.mock import Mock, patch
1+
from unittest.mock import Mock, call, patch
22

33
import jwt
44

5-
from webex_byods import TokenManager
5+
from webex_byods import StaticAccessTokenProvider, WebexDataSourceClient
66

77

8-
@patch("webex_byods.auth.uuid.uuid4", return_value="new-nonce")
9-
@patch("webex_byods.auth.requests.put")
10-
@patch("webex_byods.auth.requests.get")
8+
@patch("webex_byods.client.uuid.uuid4", return_value="new-nonce")
9+
@patch("webex_byods.client.requests.request")
1110
def test_extend_data_source_token_updates_nonce_and_preserves_claim_values(
12-
get, put, uuid4
11+
request, uuid4
1312
):
1413
jws_token = jwt.encode(
1514
{
@@ -26,12 +25,12 @@ def test_extend_data_source_token_updates_nonce_and_preserves_claim_values(
2625
"url": "https://gateway.example.com/data",
2726
"status": "active",
2827
}
29-
get.return_value = get_response
3028
put_response = Mock(status_code=200)
3129
put_response.json.return_value = {"tokenExpiryTime": "2026-07-10T00:00:00Z"}
32-
put.return_value = put_response
33-
manager = TokenManager(config_path="unused.json")
34-
manager.get_service_app_token = Mock(return_value="service-token")
30+
request.side_effect = [get_response, put_response]
31+
manager = WebexDataSourceClient(
32+
token_provider=StaticAccessTokenProvider("service-token")
33+
)
3534

3635
result = manager.extend_data_source_token("source-1", 60)
3736

@@ -43,12 +42,15 @@ def test_extend_data_source_token_updates_nonce_and_preserves_claim_values(
4342
"token_expiry": "2026-07-10T00:00:00Z",
4443
"message": "Data source token extended successfully. New expiry: 2026-07-10T00:00:00Z",
4544
}
46-
put.assert_called_once_with(
45+
assert request.call_args_list[1] == call(
46+
"PUT",
4747
"https://webexapis.com/v1/dataSources/source-1",
4848
headers={
4949
"Authorization": "Bearer service-token",
50+
"Accept": "application/json",
5051
"Content-Type": "application/json",
5152
},
53+
timeout=30,
5254
json={
5355
"audience": "audience-from-token",
5456
"nonce": "new-nonce",
@@ -62,7 +64,9 @@ def test_extend_data_source_token_updates_nonce_and_preserves_claim_values(
6264

6365

6466
def test_extend_data_source_token_rejects_lifetime_over_limit():
65-
result = TokenManager(config_path="unused.json").extend_data_source_token(
67+
result = WebexDataSourceClient(
68+
token_provider=StaticAccessTokenProvider("service-token")
69+
).extend_data_source_token(
6670
"source-1", 1441
6771
)
6872

tests/test_client.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from unittest.mock import Mock, call, patch
22

3-
from webex_byods import WebexDataSourceManager
3+
from webex_byods import StaticAccessTokenProvider, WebexDataSourceClient
44

55

66
@patch("webex_byods.client.requests.request")
@@ -9,7 +9,9 @@ def test_list_data_sources_returns_existing_result_shape(request):
99
response.json.return_value = {"items": [{"id": "source-1"}]}
1010
request.return_value = response
1111

12-
result = WebexDataSourceManager("token").list_all_data_sources()
12+
result = WebexDataSourceClient(
13+
token_provider=StaticAccessTokenProvider("token")
14+
).list_all_data_sources()
1315

1416
assert result == {
1517
"success": True,
@@ -35,12 +37,15 @@ def test_request_retries_once_after_token_refresh(request):
3537
refreshed_response.json.return_value = {"items": []}
3638
request.side_effect = [expired_response, refreshed_response]
3739
token_manager = Mock()
38-
token_manager.get_service_app_token.return_value = "new-token"
40+
token_manager.get_access_token.side_effect = ["old-token", "new-token"]
3941

40-
result = WebexDataSourceManager("old-token", token_manager=token_manager).list_all_data_sources()
42+
result = WebexDataSourceClient(token_provider=token_manager).list_all_data_sources()
4143

4244
assert result["success"] is True
43-
token_manager.get_service_app_token.assert_called_once_with(force_refresh=True)
45+
assert token_manager.get_access_token.call_args_list == [
46+
call(force_refresh=False),
47+
call(force_refresh=True),
48+
]
4449
assert request.call_args_list == [
4550
call(
4651
"GET",
@@ -72,7 +77,7 @@ def test_schema_cache_is_loaded_once(request):
7277
"items": [{"id": "schema-1", "serviceType": "Contact Center"}]
7378
}
7479
request.return_value = response
75-
manager = WebexDataSourceManager("token")
80+
manager = WebexDataSourceClient(token_provider=StaticAccessTokenProvider("token"))
7681

7782
assert manager.get_schema_display_name("schema-1") == "Contact Center (schema-1...)"
7883
assert manager.get_available_schemas() == [

tests/test_token_providers.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from unittest.mock import Mock, call, patch
2+
3+
from webex_byods import (
4+
OAuthRefreshTokenProvider,
5+
ServiceAppCredentials,
6+
StaticAccessTokenProvider,
7+
WebexServiceAppTokenProvider,
8+
)
9+
10+
11+
@patch("webex_byods.auth.requests.post")
12+
def test_oauth_refresh_provider_caches_and_forces_access_tokens(post):
13+
first_response = Mock()
14+
first_response.status_code = 200
15+
first_response.json.return_value = {"access_token": "first-personal-token"}
16+
second_response = Mock()
17+
second_response.status_code = 200
18+
second_response.json.return_value = {"access_token": "second-personal-token"}
19+
post.side_effect = [first_response, second_response]
20+
provider = OAuthRefreshTokenProvider(
21+
client_id="client-id",
22+
client_secret="client-secret",
23+
refresh_token="refresh-token",
24+
)
25+
26+
assert provider.get_access_token() == "first-personal-token"
27+
assert provider.get_access_token() == "first-personal-token"
28+
assert provider.get_access_token(force_refresh=True) == "second-personal-token"
29+
assert post.call_args_list == [
30+
call(
31+
"https://webexapis.com/v1/access_token",
32+
headers={"Content-Type": "application/x-www-form-urlencoded"},
33+
data={
34+
"grant_type": "refresh_token",
35+
"client_id": "client-id",
36+
"client_secret": "client-secret",
37+
"refresh_token": "refresh-token",
38+
},
39+
),
40+
call(
41+
"https://webexapis.com/v1/access_token",
42+
headers={"Content-Type": "application/x-www-form-urlencoded"},
43+
data={
44+
"grant_type": "refresh_token",
45+
"client_id": "client-id",
46+
"client_secret": "client-secret",
47+
"refresh_token": "refresh-token",
48+
},
49+
),
50+
]
51+
52+
53+
@patch("webex_byods.auth.requests.post")
54+
def test_service_app_provider_uses_explicit_credentials_and_caches_token(post):
55+
response = Mock()
56+
response.json.return_value = {"access_token": "service-token"}
57+
post.return_value = response
58+
credentials = ServiceAppCredentials(
59+
app_id="app-id",
60+
client_id="client-id",
61+
client_secret="client-secret",
62+
target_org_id="org-id",
63+
)
64+
provider = WebexServiceAppTokenProvider(
65+
credentials, StaticAccessTokenProvider("personal-token")
66+
)
67+
68+
assert provider.get_access_token() == "service-token"
69+
assert provider.get_access_token() == "service-token"
70+
71+
post.assert_called_once_with(
72+
"https://webexapis.com/v1/applications/app-id/token",
73+
headers={
74+
"Authorization": "Bearer personal-token",
75+
"Content-Type": "application/json",
76+
},
77+
json={
78+
"clientId": "client-id",
79+
"clientSecret": "client-secret",
80+
"targetOrgId": "org-id",
81+
},
82+
)
83+
84+
85+
@patch("webex_byods.auth.requests.post")
86+
def test_service_app_provider_forces_a_new_service_token(post):
87+
first_response = Mock()
88+
first_response.json.return_value = {"access_token": "first-token"}
89+
second_response = Mock()
90+
second_response.json.return_value = {"access_token": "second-token"}
91+
post.side_effect = [first_response, second_response]
92+
provider = WebexServiceAppTokenProvider(
93+
ServiceAppCredentials("app-id", "client-id", "client-secret", "org-id"),
94+
StaticAccessTokenProvider("personal-token"),
95+
)
96+
97+
assert provider.get_access_token() == "first-token"
98+
assert provider.get_access_token(force_refresh=True) == "second-token"
99+
assert post.call_count == 2

token-config.json.template

Lines changed: 0 additions & 14 deletions
This file was deleted.

0 commit comments

Comments
 (0)