Skip to content

Commit ea2a71f

Browse files
Merge pull request #163 from code42/INTEG-3014/refresh-token
Integ 3014/refresh token
2 parents c42a02e + f2fcad0 commit ea2a71f

File tree

9 files changed

+222
-36
lines changed

9 files changed

+222
-36
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
how a consumer would use the library or CLI tool (e.g. adding unit tests, updating documentation, etc) are not captured
1010
here.
1111

12+
## 2.8.1 - 2026-01-21
13+
14+
### Added
15+
- A new authorization type to facilitate internal development and testing. No user-facing changes are present in this release.
16+
1217
## 2.8.0 - 2026-01-16
1318

1419
### Added

src/_incydr_cli/core.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ def make_context(self, info_name, args, parent=None, **extra):
6565
return super().make_context(info_name, args, parent=parent, **extra)
6666

6767
def invoke(self, ctx):
68-
settings = IncydrSettings(url="", api_client_id="", api_client_secret="")
68+
settings = IncydrSettings(
69+
url="temp value for logging initialization",
70+
api_client_id="temp value for logging initialization",
71+
api_client_secret="temp value for logging initialization",
72+
)
6973
try:
7074
return super().invoke(ctx)
7175
except click.UsageError as err:

src/_incydr_sdk/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-FileCopyrightText: 2022-present Code42 Software <integrations@code42.com>
22
#
33
# SPDX-License-Identifier: MIT
4-
__version__ = "2.8.0"
4+
__version__ = "2.8.1"

src/_incydr_sdk/core/auth.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from typing import Optional
22

3+
import requests
34
from pydantic import SecretStr
45
from requests import Session
56
from requests.auth import AuthBase
67
from requests.auth import HTTPBasicAuth
78

89
from _incydr_sdk.core.models import AuthResponse
10+
from _incydr_sdk.core.models import RefreshTokenAuthResponse
911

1012

1113
class APIClientAuth(AuthBase):
@@ -32,3 +34,25 @@ def __call__(self, request):
3234
token = self.token_response.access_token.get_secret_value()
3335
request.headers["Authorization"] = f"Bearer {token}"
3436
return request
37+
38+
39+
class RefreshTokenAuth(AuthBase):
40+
def __init__(self, session: Session, refresh_url: str, refresh_token: str):
41+
self.session = session
42+
self.refresh_url = refresh_url
43+
self.refresh_token = SecretStr(refresh_token)
44+
self.token_response: Optional[RefreshTokenAuthResponse] = None
45+
46+
def refresh(self):
47+
auth_body = {"refreshToken": self.refresh_token.get_secret_value()}
48+
r = requests.post(self.refresh_url, json=auth_body)
49+
r.raise_for_status()
50+
self.token_response = RefreshTokenAuthResponse.parse_response(r)
51+
self.refresh_token = self.token_response.refreshToken.tokenValue
52+
53+
def __call__(self, request):
54+
if self.token_response is None or self.token_response.accessToken.expired:
55+
self.refresh()
56+
token = self.token_response.accessToken.tokenValue.get_secret_value()
57+
request.headers["Authorization"] = f"Bearer {token}"
58+
return request

src/_incydr_sdk/core/client.py

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import logging
44
from collections import deque
55

6-
import pydantic
76
from requests_toolbelt import user_agent
87
from requests_toolbelt.sessions import BaseUrlSession
98

@@ -15,12 +14,12 @@
1514
from _incydr_sdk.audit_log.client import AuditLogClient
1615
from _incydr_sdk.cases.client import CasesClient
1716
from _incydr_sdk.core.auth import APIClientAuth
17+
from _incydr_sdk.core.auth import RefreshTokenAuth
1818
from _incydr_sdk.core.settings import IncydrSettings
1919
from _incydr_sdk.customer.client import CustomerClient
2020
from _incydr_sdk.departments.client import DepartmentsClient
2121
from _incydr_sdk.devices.client import DevicesClient
2222
from _incydr_sdk.directory_groups.client import DirectoryGroupsClient
23-
from _incydr_sdk.exceptions import AuthMissingError
2423
from _incydr_sdk.file_events.client import FileEventsClient
2524
from _incydr_sdk.files.client import FilesClient
2625
from _incydr_sdk.legal_hold.client import LegalHoldClient
@@ -60,31 +59,30 @@ def __init__(
6059
skip_auth: bool = False,
6160
**settings_kwargs,
6261
):
63-
try:
64-
self._settings = IncydrSettings(
65-
url=url,
66-
api_client_id=api_client_id,
67-
api_client_secret=api_client_secret,
68-
**settings_kwargs,
69-
)
70-
except pydantic.ValidationError as err:
71-
auth_keys = {"api_client_id", "api_client_secret", "url"}
72-
error_keys = {e["loc"][0] for e in err.errors()}
73-
if auth_keys & error_keys:
74-
raise AuthMissingError(err)
75-
else:
76-
raise
62+
self._settings = IncydrSettings(
63+
url=url,
64+
api_client_id=api_client_id,
65+
api_client_secret=api_client_secret,
66+
**settings_kwargs,
67+
)
7768
self._request_history = deque(maxlen=self._settings.max_response_history)
7869

7970
self._session = BaseUrlSession(base_url=self._settings.url)
8071
self._session.headers["User-Agent"] = (
8172
self._settings.user_agent_prefix or ""
8273
) + _base_user_agent
83-
self._session.auth = APIClientAuth(
84-
session=self._session,
85-
api_client_id=self._settings.api_client_id,
86-
api_client_secret=self._settings.api_client_secret,
87-
)
74+
if self._settings.refresh_token and self._settings.refresh_url:
75+
self._session.auth = RefreshTokenAuth(
76+
session=self._session,
77+
refresh_url=self._settings.refresh_url,
78+
refresh_token=self._settings.refresh_token.get_secret_value(),
79+
)
80+
else:
81+
self._session.auth = APIClientAuth(
82+
session=self._session,
83+
api_client_id=self._settings.api_client_id,
84+
api_client_secret=self._settings.api_client_secret,
85+
)
8886

8987
def response_hook(response, *args, **kwargs):
9088
level = self._settings.log_level

src/_incydr_sdk/core/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ def expired(self):
128128
)
129129

130130

131+
class TokenDetails(Model):
132+
tokenValue: SecretStr
133+
expiresAt: datetime
134+
135+
@property
136+
def expired(self):
137+
return datetime.now(timezone.utc) >= self.expiresAt
138+
139+
140+
class RefreshTokenAuthResponse(ResponseModel):
141+
accessToken: TokenDetails
142+
refreshToken: TokenDetails
143+
144+
131145
class CSVModel(BaseModel):
132146
"""
133147
Pydantic model class enables multiple aliases to be assigned to a single field value. If the field is required

src/_incydr_sdk/core/settings.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from io import IOBase
66
from pathlib import Path
77
from textwrap import indent
8+
from typing import Optional
89
from typing import Union
910

1011
from pydantic import Field
@@ -19,6 +20,8 @@
1920
from rich.logging import RichHandler
2021

2122
from _incydr_sdk.enums import _Enum
23+
from _incydr_sdk.exceptions import AuthMissingError
24+
2225

2326
# capture default displayhook so we can "uninstall" rich
2427
_sys_displayhook = sys.displayhook
@@ -85,8 +88,8 @@ class IncydrSettings(BaseSettings):
8588
and the Python repl. Defaults to True. env_var=`INCYDR_USE_RICH`
8689
"""
8790

88-
api_client_id: str
89-
api_client_secret: SecretStr
91+
api_client_id: Optional[str] = Field(default=None)
92+
api_client_secret: Optional[SecretStr] = Field(default=None)
9093
url: str
9194
page_size: int = Field(default=100)
9295
max_response_history: int = Field(default=5)
@@ -96,6 +99,8 @@ class IncydrSettings(BaseSettings):
9699
log_level: Union[int, str] = Field(default=logging.WARNING, validate_default=True)
97100
logger: logging.Logger = Field(default=None)
98101
user_agent_prefix: Union[str] = Field(default="")
102+
refresh_token: Optional[SecretStr] = Field(default=None)
103+
refresh_url: Optional[str] = Field(default=None)
99104

100105
model_config = SettingsConfigDict(
101106
env_prefix="incydr_",
@@ -213,6 +218,28 @@ def _configure_logging(cls, values): # noqa
213218
cls.logger = logger
214219
return values
215220

221+
@model_validator(mode="after")
222+
@classmethod
223+
def _auth_validator(cls, values): # noqa
224+
values_dict = values.dict()
225+
226+
# if we have a refresh token and a refresh url, don't validate api client auth/secret
227+
if values_dict.get("refresh_url") and values_dict.get("refresh_token"):
228+
return values
229+
230+
errors = []
231+
232+
if not values_dict.get("api_client_id"):
233+
errors.append("api_client_id")
234+
235+
if not values_dict.get("api_client_secret"):
236+
errors.append("api_client_secret")
237+
238+
if len(errors) > 0:
239+
raise AuthMissingError(errors)
240+
241+
return values
242+
216243
def _log_response_info(self, response):
217244
self.logger.info(
218245
f"{response.request.method} {response.request.url} status_code={response.status_code}"

src/_incydr_sdk/exceptions.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
1-
from pydantic import ValidationError
2-
3-
41
class IncydrException(Exception):
52
"""Base class for all Incydr specific exceptions."""
63

74
...
85

96

107
class AuthMissingError(IncydrException):
11-
def __init__(self, validation_error: ValidationError):
12-
self.pydantic_error = str(validation_error)
13-
self.errors = validation_error.errors()
14-
15-
@property
16-
def error_keys(self):
17-
return [e["loc"][0] for e in self.errors]
8+
def __init__(self, error_keys):
9+
self.error_keys = error_keys
1810

1911
def __str__(self):
12+
errors_formatted = "\n - ".join(self.error_keys)
2013
return (
21-
f"{self.pydantic_error}\n\n"
14+
f"Missing required authentication variables in environment or in initialization\n\n - {errors_formatted}\n\n"
2215
"Pass required args to the `incydr.Client` or set required values in your environment.\n\n"
2316
"See https://developer.code42.com/sdk/settings for more details."
2417
)

tests/test_core.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@
55
from pytest_httpserver import HTTPServer
66

77
from .conftest import TEST_HOST
8+
from .conftest import TEST_TOKEN
9+
from _incydr_sdk.core.auth import RefreshTokenAuth
810
from _incydr_sdk.core.models import CSVModel
911
from _incydr_sdk.core.models import Model
12+
from _incydr_sdk.core.settings import IncydrSettings
13+
from _incydr_sdk.exceptions import AuthMissingError
1014
from incydr import Client
1115

1216

@@ -122,3 +126,120 @@ class Test(Model):
122126
def test_user_agent(httpserver_auth: HTTPServer):
123127
c = Client()
124128
assert c._session.headers["User-Agent"].startswith("incydrSDK")
129+
130+
131+
@pytest.fixture
132+
def httpserver_refresh_token_auth(httpserver: HTTPServer, monkeypatch):
133+
monkeypatch.setenv("incydr_url", TEST_HOST)
134+
monkeypatch.setenv("incydr_refresh_token", "test_refresh_token")
135+
monkeypatch.setenv("incydr_refresh_url", f"{TEST_HOST}/v1/refresh")
136+
137+
refresh_response = {
138+
"accessToken": {
139+
"tokenValue": TEST_TOKEN,
140+
"expiresAt": "2099-01-01T00:00:00Z",
141+
},
142+
"refreshToken": {
143+
"tokenValue": "new_refresh_token",
144+
"expiresAt": "2099-01-01T00:00:00Z",
145+
},
146+
}
147+
httpserver.expect_request("/v1/refresh", method="POST").respond_with_json(
148+
refresh_response
149+
)
150+
return httpserver
151+
152+
153+
def test_client_init_with_refresh_token_and_refresh_url_uses_refresh_token_auth(
154+
httpserver_refresh_token_auth: HTTPServer,
155+
):
156+
c = Client()
157+
assert isinstance(c._session.auth, RefreshTokenAuth)
158+
assert c.settings.refresh_token.get_secret_value() == "test_refresh_token"
159+
assert c.settings.refresh_url == f"{TEST_HOST}/v1/refresh"
160+
161+
162+
def test_client_init_with_refresh_token_does_not_require_api_client_credentials(
163+
httpserver_refresh_token_auth: HTTPServer,
164+
):
165+
c = Client()
166+
assert c.settings.api_client_id is None
167+
assert c.settings.api_client_secret is None
168+
169+
170+
def test_client_init_with_refresh_token_passed_as_args(
171+
httpserver: HTTPServer, monkeypatch
172+
):
173+
monkeypatch.setenv("incydr_url", TEST_HOST)
174+
175+
refresh_response = {
176+
"accessToken": {
177+
"tokenValue": TEST_TOKEN,
178+
"expiresAt": "2099-01-01T00:00:00Z",
179+
},
180+
"refreshToken": {
181+
"tokenValue": "new_refresh_token",
182+
"expiresAt": "2099-01-01T00:00:00Z",
183+
},
184+
}
185+
httpserver.expect_request("/v1/refresh", method="POST").respond_with_json(
186+
refresh_response
187+
)
188+
189+
c = Client(
190+
refresh_token="arg_refresh_token",
191+
refresh_url=f"{TEST_HOST}/v1/refresh",
192+
)
193+
assert isinstance(c._session.auth, RefreshTokenAuth)
194+
assert c.settings.refresh_token.get_secret_value() == "arg_refresh_token"
195+
196+
197+
def test_settings_with_only_refresh_token_raises_auth_missing_error(monkeypatch):
198+
with pytest.raises(AuthMissingError) as exc_info:
199+
IncydrSettings(
200+
url=TEST_HOST,
201+
refresh_token="test_refresh_token",
202+
_env_file="",
203+
)
204+
205+
assert "api_client_id" in exc_info.value.error_keys
206+
assert "api_client_secret" in exc_info.value.error_keys
207+
208+
209+
def test_settings_with_only_refresh_url_raises_auth_missing_error(monkeypatch):
210+
with pytest.raises(AuthMissingError) as exc_info:
211+
IncydrSettings(
212+
url=TEST_HOST,
213+
refresh_url=f"{TEST_HOST}/v1/refresh",
214+
_env_file="",
215+
)
216+
217+
assert "api_client_id" in exc_info.value.error_keys
218+
assert "api_client_secret" in exc_info.value.error_keys
219+
220+
221+
def test_client_prefers_refresh_token_auth_when_both_auth_methods_provided(
222+
httpserver: HTTPServer, monkeypatch
223+
):
224+
monkeypatch.setenv("incydr_url", TEST_HOST)
225+
monkeypatch.setenv("incydr_api_client_id", "env_id")
226+
monkeypatch.setenv("incydr_api_client_secret", "env_secret")
227+
monkeypatch.setenv("incydr_refresh_token", "test_refresh_token")
228+
monkeypatch.setenv("incydr_refresh_url", f"{TEST_HOST}/v1/refresh")
229+
230+
refresh_response = {
231+
"accessToken": {
232+
"tokenValue": TEST_TOKEN,
233+
"expiresAt": "2099-01-01T00:00:00Z",
234+
},
235+
"refreshToken": {
236+
"tokenValue": "new_refresh_token",
237+
"expiresAt": "2099-01-01T00:00:00Z",
238+
},
239+
}
240+
httpserver.expect_request("/v1/refresh", method="POST").respond_with_json(
241+
refresh_response
242+
)
243+
244+
c = Client()
245+
assert isinstance(c._session.auth, RefreshTokenAuth)

0 commit comments

Comments
 (0)